Compare commits
193 Commits
49049a7f78
...
c9a525bead
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9a525bead | ||
|
|
0fb83142d6 | ||
|
|
287011ea0e | ||
|
|
4b37652a18 | ||
|
|
f3ec164c38 | ||
|
|
2a69654e96 | ||
|
|
cfc41e349d | ||
|
|
034d4c71b0 | ||
|
|
9a65aa48cd | ||
|
|
dbbf75e3c5 | ||
|
|
8b0d3d972e | ||
|
|
b1a9d1a5ba | ||
|
|
f05ea493f3 | ||
|
|
3314a5cb0a | ||
|
|
adcafa1041 | ||
|
|
0625b45ca7 | ||
|
|
11ec6a77ef | ||
|
|
e2dec16e8b | ||
|
|
0af3fba2bb | ||
|
|
59babf855c | ||
|
|
5ec99155c4 | ||
|
|
3122c5b13f | ||
|
|
396afc6d30 | ||
|
|
937af6a3e5 | ||
|
|
916ddd7981 | ||
|
|
e76d62a80c | ||
|
|
02908ec73f | ||
|
|
3df5097c01 | ||
|
|
f69285b7f3 | ||
|
|
4548e8e17e | ||
|
|
ceb7d5ec4b | ||
|
|
1f805da030 | ||
|
|
543db4fb2e | ||
|
|
330a151b7f | ||
|
|
d89cc2f0d1 | ||
|
|
381fa441fa | ||
|
|
f7754a8fc0 | ||
|
|
f369569db0 | ||
|
|
0b75afd176 | ||
|
|
ce72b13cb6 | ||
|
|
966a3eb842 | ||
|
|
306c80e362 | ||
|
|
10383c96fd | ||
|
|
08bb6e2d4e | ||
|
|
f6860f8007 | ||
|
|
67078cdc63 | ||
|
|
9b2adc578e | ||
|
|
bf1c1bbb01 | ||
|
|
5e746f67dd | ||
|
|
458ddf596c | ||
|
|
ce4db4f656 | ||
|
|
da0d26e212 | ||
|
|
1835387563 | ||
|
|
c92f64cce4 | ||
|
|
fe86da5c0a | ||
|
|
9a0adb0f2e | ||
|
|
91e8dceede | ||
|
|
e7b6adf0f8 | ||
|
|
8a6da626d1 | ||
|
|
feeb418473 | ||
|
|
53bd8283f7 | ||
|
|
d790a8b5d3 | ||
|
|
b62f68df68 | ||
|
|
4bb307df8d | ||
|
|
1c46fbee11 | ||
|
|
372639c2df | ||
|
|
e4fd6b2cde | ||
|
|
1f84fbc778 | ||
|
|
846a20c6ed | ||
|
|
f788e0af18 | ||
|
|
ce46daa985 | ||
|
|
98c2c4c1bb | ||
|
|
3d1fdcf136 | ||
|
|
bf8db6a31b | ||
|
|
50b3708dc5 | ||
|
|
ba4dcdc3ef | ||
|
|
5de1c97644 | ||
|
|
53606ca845 | ||
|
|
d1468c3d57 | ||
|
|
e08e40fb3e | ||
|
|
454197d4ed | ||
|
|
cbe83356ce | ||
|
|
c39fe944fd | ||
|
|
948739f27d | ||
|
|
55070c92cf | ||
|
|
0c812beffd | ||
|
|
b57b001df1 | ||
|
|
a7900ec58c | ||
|
|
80585299f7 | ||
|
|
cded33de29 | ||
|
|
577537ebe5 | ||
|
|
d239282797 | ||
|
|
c1bc9e1994 | ||
|
|
4e304944ef | ||
|
|
b617331683 | ||
|
|
3e21aefbf1 | ||
|
|
ea726c52de | ||
|
|
df47e66b51 | ||
|
|
b329abcf0e | ||
|
|
00e0d61d96 | ||
|
|
e190d3f744 | ||
|
|
e3ff345608 | ||
|
|
fa688e2f8b | ||
|
|
7cc9194827 | ||
|
|
9e233bee27 | ||
|
|
43cf55a8ef | ||
|
|
df340143dc | ||
|
|
713a714dd2 | ||
|
|
90ecf39295 | ||
|
|
bb1ed1bd03 | ||
|
|
ea3bdf1ea3 | ||
|
|
74b3d24622 | ||
|
|
adbfa60343 | ||
|
|
acc662fce8 | ||
|
|
59fba0484c | ||
|
|
e281ef75da | ||
| 9f11d8fb2d | |||
| 4659a7b1de | |||
| f70c1fe3c2 | |||
| 8dc2b1e586 | |||
| 1ae4c99ffc | |||
| 74fcaa6a4e | |||
| 57596f5aaf | |||
| 9c334c39ae | |||
| 102494cef0 | |||
| ad3f149789 | |||
| 27abaa3e05 | |||
| eaa2621a17 | |||
| 71e19bfe43 | |||
| 809c69f923 | |||
| a64291a19a | |||
| f4cdca4ed1 | |||
|
|
6f04474195 | ||
|
|
bac9a02c49 | ||
|
|
053d6f06d8 | ||
|
|
aac0716046 | ||
|
|
187aa7c962 | ||
|
|
8a05854aa0 | ||
|
|
cbcff26e63 | ||
|
|
d94b33f96d | ||
|
|
3497a743b3 | ||
|
|
7f3c04acf5 | ||
|
|
350182c5a4 | ||
|
|
3fe379a1d2 | ||
|
|
ce30ade808 | ||
|
|
1542c85b29 | ||
|
|
d5f4d2a610 | ||
|
|
070c68e4c9 | ||
|
|
81a2837c04 | ||
|
|
c79b0fa394 | ||
|
|
b6453d05b9 | ||
|
|
7c69a48910 | ||
|
|
71ff923d35 | ||
|
|
c6aa77f7c9 | ||
|
|
38261c2f76 | ||
|
|
68c7656964 | ||
|
|
bf213bb05d | ||
|
|
262c759055 | ||
|
|
1da8fdda21 | ||
|
|
3d0139da53 | ||
|
|
75d6f45dee | ||
|
|
9cc1b74d72 | ||
|
|
c3ca9ccaa2 | ||
|
|
8505081b2e | ||
|
|
67bc500692 | ||
|
|
e30452d6cb | ||
|
|
2f37d0a92c | ||
|
|
ce36d7ab38 | ||
|
|
d52d1e57f2 | ||
|
|
89c836d7b6 | ||
|
|
eec05c4eaa | ||
|
|
443b08ed58 | ||
|
|
c97b46eb57 | ||
|
|
6b566e9a51 | ||
|
|
f22238d6c7 | ||
|
|
cf5ab2030d | ||
|
|
7e072793eb | ||
|
|
c1b2f416ed | ||
|
|
920dd532e8 | ||
|
|
a1f0640483 | ||
|
|
5f790a60a9 | ||
|
|
b6b2d20b16 | ||
|
|
47bff2c6ed | ||
|
|
d784f3fcc7 | ||
|
|
03d7bb2f68 | ||
|
|
88521c6bac | ||
|
|
38c1e287dc | ||
|
|
e2e9615bd8 | ||
|
|
357fb73d5e | ||
|
|
f693b05390 | ||
|
|
9870516256 | ||
|
|
2ce24b0473 | ||
|
|
2aea3bded4 |
@ -1,4 +1,9 @@
|
||||
# dist
|
||||
node_modules
|
||||
**/node_modules
|
||||
.next
|
||||
**/.next
|
||||
dist
|
||||
**/dist
|
||||
.env
|
||||
.git
|
||||
.dockerignore
|
||||
|
||||
81
.gitea/scripts/with-vpn.sh
Normal file
81
.gitea/scripts/with-vpn.sh
Normal file
@ -0,0 +1,81 @@
|
||||
#!/bin/bash
|
||||
# with-vpn.sh - Run a command with VPN active, while preserving SSH & Server IP routing
|
||||
set -e
|
||||
|
||||
VPN_CONFIG="/opt/services/vpn/fmr.kaarma.top.ovpn"
|
||||
VPN_AUTH="/opt/services/vpn/auth.txt"
|
||||
VPN_LOG="/tmp/vpn-cicd.log"
|
||||
VPN_PID_FILE="/var/run/openvpn-cicd.pid"
|
||||
CMD="$*"
|
||||
|
||||
DEFAULT_GW="87.248.133.137"
|
||||
DEFAULT_IFACE="ens18"
|
||||
|
||||
log() { echo "[$(date '+%H:%M:%S')] [with-vpn] $*"; }
|
||||
|
||||
vpn_is_up() {
|
||||
ip link show tun0 &>/dev/null 2>&1
|
||||
}
|
||||
|
||||
start_vpn() {
|
||||
if vpn_is_up; then
|
||||
log "VPN already up (tun0 exists)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Starting OpenVPN safely with --route-nopull..."
|
||||
openvpn \
|
||||
--config "$VPN_CONFIG" \
|
||||
--auth-user-pass "$VPN_AUTH" \
|
||||
--data-ciphers AES-128-CBC \
|
||||
--route-nopull \
|
||||
--daemon \
|
||||
--log "$VPN_LOG" \
|
||||
--writepid "$VPN_PID_FILE" \
|
||||
--script-security 2
|
||||
|
||||
log "Waiting for VPN tun0..."
|
||||
for i in $(seq 1 30); do
|
||||
if vpn_is_up; then
|
||||
log "tun0 is UP after ${i}x2s"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
log "ERROR: VPN failed to connect after 60 seconds"
|
||||
tail -20 "$VPN_LOG" 2>/dev/null || true
|
||||
return 1
|
||||
}
|
||||
|
||||
stop_vpn() {
|
||||
log "Stopping VPN..."
|
||||
if [ -f "$VPN_PID_FILE" ]; then
|
||||
VPN_PID=$(cat "$VPN_PID_FILE")
|
||||
if [ -n "$VPN_PID" ]; then
|
||||
kill "$VPN_PID" 2>/dev/null || true
|
||||
sleep 2
|
||||
fi
|
||||
rm -f "$VPN_PID_FILE"
|
||||
fi
|
||||
pkill -f "openvpn.*fmr.kaarma" 2>/dev/null || true
|
||||
log "VPN stopped safely"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local EXIT_CODE=$?
|
||||
log "Cleanup (exit code: $EXIT_CODE)..."
|
||||
stop_vpn
|
||||
exit $EXIT_CODE
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
start_vpn
|
||||
|
||||
log "Running command: $CMD"
|
||||
eval "$CMD"
|
||||
EXIT_CODE=$?
|
||||
|
||||
log "Command finished with exit code: $EXIT_CODE"
|
||||
exit $EXIT_CODE
|
||||
@ -9,24 +9,18 @@ on:
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: canina
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Setup SSH key
|
||||
shell: sh
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.DEPLOY_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -p 2234 -H 172.17.0.1 >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Deploy with VPN for build
|
||||
shell: sh
|
||||
timeout-minutes: 20
|
||||
- name: Run deploy.sh from within the scripts folder
|
||||
env:
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
echo "Deploying branch: ${BRANCH}"
|
||||
ssh -o StrictHostKeyChecking=no -p 2234 devops@172.17.0.1 \
|
||||
"bash /opt/services/scripts/with-vpn.sh \"bash /opt/services/canina/deploy.sh ${BRANCH}\""
|
||||
# 1. رفع مشکل DNS
|
||||
echo "87.248.133.138 git.parsaaghayi.ir" >> /etc/hosts
|
||||
|
||||
# 2. دانلود و اجرای اسکریپت deploy.sh بر اساس برنچ جاری
|
||||
mkdir -p scripts
|
||||
wget -qO scripts/deploy.sh https://git.parsaaghayi.ir/parsa/canina/raw/branch/${BRANCH}/scripts/deploy.sh
|
||||
|
||||
chmod +x scripts/deploy.sh
|
||||
cd scripts
|
||||
./deploy.sh ${BRANCH}
|
||||
19
Dockerfile
19
Dockerfile
@ -1,7 +1,16 @@
|
||||
FROM node:20-alpine AS app-builder
|
||||
USER root
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NEXT_PUBLIC_API_URL=https://api.canina.ir/api
|
||||
|
||||
# Optional: Use Nexus as npm registry
|
||||
ARG NPM_REGISTRY=
|
||||
RUN if [ -n "$NPM_REGISTRY" ]; then \
|
||||
npm config set registry "$NPM_REGISTRY" && \
|
||||
npm config set strict-ssl false; \
|
||||
fi
|
||||
|
||||
COPY frontend/application/package*.json ./
|
||||
RUN npm ci --include=dev --prefer-offline --no-audit
|
||||
COPY frontend/application ./
|
||||
@ -10,8 +19,17 @@ ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS admin-builder
|
||||
USER root
|
||||
WORKDIR /app
|
||||
ENV VITE_API_URL=https://api.canina.ir/api
|
||||
|
||||
# Optional: Use Nexus as npm registry (inherits build-arg)
|
||||
ARG NPM_REGISTRY=
|
||||
RUN if [ -n "$NPM_REGISTRY" ]; then \
|
||||
npm config set registry "$NPM_REGISTRY" && \
|
||||
npm config set strict-ssl false; \
|
||||
fi
|
||||
|
||||
COPY frontend/admin-panel/package*.json ./
|
||||
RUN npm ci --include=dev --prefer-offline --no-audit
|
||||
COPY frontend/admin-panel ./
|
||||
@ -20,6 +38,7 @@ ENV VITE_API_URL=$VITE_API_URL
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
USER root
|
||||
RUN addgroup -S nginx 2>/dev/null || true && adduser -S nginx -G nginx 2>/dev/null || true
|
||||
RUN mkdir -p /var/log/nginx /var/cache/nginx /run/nginx /etc/nginx/conf.d /etc/nginx/http.d
|
||||
COPY --from=nginx:alpine /etc/nginx /etc/nginx
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.git
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
npm-debug.log
|
||||
|
||||
@ -1,14 +1,25 @@
|
||||
FROM node:20-alpine AS builder
|
||||
RUN apk add --no-cache openssl 2>/dev/null || true
|
||||
USER root
|
||||
RUN http_proxy=http://172.17.0.1:8888 https_proxy=http://172.17.0.1:8888 apk add --no-cache openssl || apk add --no-cache openssl || true
|
||||
WORKDIR /app
|
||||
ENV PRISMA_SKIP_POSTINSTALL_GENERATE=true
|
||||
|
||||
# Optional: Use Nexus as npm registry (pass --build-arg NPM_REGISTRY=http://nexus:8081/repository/npm/)
|
||||
ARG NPM_REGISTRY=
|
||||
RUN if [ -n "$NPM_REGISTRY" ]; then \
|
||||
npm config set registry "$NPM_REGISTRY" && \
|
||||
npm config set strict-ssl false && \
|
||||
echo "Using npm registry: $NPM_REGISTRY"; \
|
||||
fi
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --prefer-offline --no-audit
|
||||
COPY . .
|
||||
ENV PRISMA_CLI_BINARY_TARGETS=linux-musl-openssl-3.0.x
|
||||
RUN npx prisma generate && npm run build
|
||||
RUN https_proxy=http://172.17.0.1:8888 http_proxy=http://172.17.0.1:8888 ./node_modules/.bin/prisma generate && npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
RUN apk add --no-cache openssl 2>/dev/null || true
|
||||
USER root
|
||||
RUN http_proxy=http://172.17.0.1:8888 https_proxy=http://172.17.0.1:8888 apk add --no-cache openssl || apk add --no-cache openssl || true
|
||||
RUN mkdir -p /app/uploads && chown node:node /app/uploads
|
||||
WORKDIR /app
|
||||
COPY --chown=node:node --from=builder /app/package*.json ./
|
||||
@ -18,7 +29,6 @@ COPY --chown=node:node --from=builder /app/dist ./dist
|
||||
COPY --chown=node:node --from=builder /app/prisma ./prisma
|
||||
COPY --chown=node:node --from=builder /app/prisma/tsconfig.seed.json ./prisma/tsconfig.seed.json
|
||||
COPY --chown=node:node --from=builder /app/prisma/scientificTerms.ts ./prisma/scientificTerms.ts
|
||||
ENV PRISMA_CLI_BINARY_TARGETS=linux-musl-openssl-3.0.x
|
||||
EXPOSE 3000
|
||||
USER node
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main"]
|
||||
|
||||
@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs'],
|
||||
ignores: ['eslint.config.mjs', 'dist/**'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
@ -29,7 +29,18 @@ export default tseslint.config(
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
'prettier/prettier': ['error', { endOfLine: 'auto' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.spec.ts', 'test/**/*.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/unbound-method': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
'@typescript-eslint/no-unsafe-return': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
9
backend/package-lock.json
generated
9
backend/package-lock.json
generated
@ -23,6 +23,7 @@
|
||||
"class-validator": "^0.15.1",
|
||||
"helmet": "^8.2.0",
|
||||
"ioredis": "^5.11.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@ -39,6 +40,7 @@
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
@ -2969,6 +2971,13 @@
|
||||
"pretty-format": "^30.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/js-yaml": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
|
||||
@ -17,7 +17,8 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"docs:generate": "ts-node -r tsconfig-paths/register scripts/generate-openapi.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
@ -34,6 +35,7 @@
|
||||
"class-validator": "^0.15.1",
|
||||
"helmet": "^8.2.0",
|
||||
"ioredis": "^5.11.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@ -50,6 +52,7 @@
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^24.12.4",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
|
||||
@ -26,6 +26,8 @@ model User {
|
||||
pets Pet[]
|
||||
orders Order[]
|
||||
blogs Blog[]
|
||||
prescriptions Prescription[]
|
||||
partnerAccount PartnerAccount?
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@ -70,6 +72,10 @@ model Media {
|
||||
url String @db.Text
|
||||
mimetype String @db.VarChar(50)
|
||||
size Int
|
||||
altText String? @map("alt_text") @db.VarChar(250)
|
||||
title String? @db.VarChar(250)
|
||||
description String? @db.Text
|
||||
caption String? @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||
|
||||
@@map("media")
|
||||
@ -135,8 +141,13 @@ model Product {
|
||||
unit String @db.VarChar(50)
|
||||
packageSize Decimal @map("package_size") @db.Decimal(10, 2)
|
||||
dosageLogic String? @map("dosage_logic") @db.Text
|
||||
dosageConfig Json? @map("dosage_config")
|
||||
suitableFor String @map("suitable_for") @db.VarChar(15) // سگ, گربه, هر دو
|
||||
imageUrl String @map("image_url") @db.Text
|
||||
images String[] @default([])
|
||||
podcastUrl String? @map("podcast_url") @db.Text
|
||||
videoUrl String? @map("video_url") @db.Text
|
||||
pdfUrl String? @map("pdf_url") @db.Text
|
||||
metaTitle String? @map("meta_title") @db.VarChar(200)
|
||||
metaDescription String? @map("meta_description") @db.Text
|
||||
canonicalUrl String? @map("canonical_url") @db.Text
|
||||
@ -158,6 +169,18 @@ model Product {
|
||||
@@map("products")
|
||||
}
|
||||
|
||||
model Doctor {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String @db.VarChar(150)
|
||||
title String @db.VarChar(150)
|
||||
avatarUrl String? @map("avatar_url") @db.Text
|
||||
bio String? @db.Text
|
||||
clinic String? @db.VarChar(200)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||
|
||||
@@map("doctors")
|
||||
}
|
||||
|
||||
model ProductIngredient {
|
||||
productId String @map("product_id") @db.Uuid
|
||||
ingredient String @db.VarChar(150)
|
||||
@ -192,6 +215,7 @@ model Pet {
|
||||
medicalConditions PetMedicalCondition[]
|
||||
reminders Reminder[]
|
||||
healthLogs HealthLog[]
|
||||
prescriptions Prescription[]
|
||||
|
||||
@@map("pets")
|
||||
}
|
||||
@ -391,3 +415,138 @@ model Video {
|
||||
|
||||
@@map("videos")
|
||||
}
|
||||
|
||||
model ContactSubmission {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String @db.VarChar(100)
|
||||
phone String @db.VarChar(20)
|
||||
email String? @db.VarChar(150)
|
||||
subject String? @db.VarChar(200)
|
||||
message String @db.Text
|
||||
status String @default("PENDING") @db.VarChar(20) // PENDING, IN_PROGRESS, RESOLVED
|
||||
adminNotes String? @map("admin_notes") @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||
|
||||
@@map("contact_submissions")
|
||||
}
|
||||
|
||||
model ContactInfo {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
key String @unique @db.VarChar(100)
|
||||
title String @db.VarChar(200)
|
||||
value String @db.Text
|
||||
icon String? @db.VarChar(100)
|
||||
order Int @default(0)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz()
|
||||
|
||||
@@map("contact_info")
|
||||
}
|
||||
|
||||
model Banner {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
title String @db.VarChar(200)
|
||||
subtitle String? @db.Text
|
||||
imageUrl String @map("image_url") @db.Text
|
||||
linkUrl String? @map("link_url") @db.Text
|
||||
position String @default("home_hero") @db.VarChar(50)
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
|
||||
|
||||
@@map("banners")
|
||||
}
|
||||
|
||||
model Testimonial {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
authorName String @map("author_name") @db.VarChar(150)
|
||||
roleTitle String? @map("role_title") @db.VarChar(150)
|
||||
avatarUrl String? @map("avatar_url") @db.Text
|
||||
content String @db.Text
|
||||
rating Int @default(5)
|
||||
isFeatured Boolean @default(false) @map("is_featured")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
|
||||
|
||||
@@map("testimonials")
|
||||
}
|
||||
|
||||
model Ingredient {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
nameFa String @map("name_fa") @db.VarChar(150)
|
||||
nameEn String @map("name_en") @db.VarChar(150)
|
||||
slug String @unique @db.VarChar(150)
|
||||
description String? @db.Text
|
||||
scientificName String? @map("scientific_name") @db.VarChar(200)
|
||||
imageUrl String? @map("image_url") @db.Text
|
||||
benefits String[] @default([])
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
|
||||
|
||||
@@map("ingredients")
|
||||
}
|
||||
|
||||
model Prescription {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String? @map("user_id") @db.Uuid
|
||||
petId String? @map("pet_id") @db.Uuid
|
||||
fileUrl String @map("file_url") @db.Text
|
||||
status String @default("PENDING") @db.VarChar(30)
|
||||
notes String? @db.Text
|
||||
adminNotes String? @map("admin_notes") @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
pet Pet? @relation(fields: [petId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId])
|
||||
@@map("prescriptions")
|
||||
}
|
||||
|
||||
model B2BInquiry {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
companyName String @map("company_name") @db.VarChar(200)
|
||||
contactName String @map("contact_name") @db.VarChar(150)
|
||||
email String @db.VarChar(150)
|
||||
phone String @db.VarChar(20)
|
||||
businessType String @map("business_type") @db.VarChar(50)
|
||||
estimatedVolume String? @map("estimated_volume") @db.VarChar(100)
|
||||
message String @db.Text
|
||||
status String @default("PENDING") @db.VarChar(30)
|
||||
adminNotes String? @map("admin_notes") @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
|
||||
|
||||
@@map("b2b_inquiries")
|
||||
}
|
||||
|
||||
model PartnerAccount {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @unique @map("user_id") @db.Uuid
|
||||
companyName String @map("company_name") @db.VarChar(200)
|
||||
taxId String? @map("tax_id") @db.VarChar(50)
|
||||
creditLimit Decimal @default(0.00) @map("credit_limit") @db.Decimal(15, 2)
|
||||
discountTier String @default("STANDARD") @map("discount_tier") @db.VarChar(30)
|
||||
status String @default("ACTIVE") @db.VarChar(30)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("partner_accounts")
|
||||
}
|
||||
|
||||
model Setting {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
category String @db.VarChar(50)
|
||||
key String @unique @db.VarChar(100)
|
||||
value Json
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
|
||||
|
||||
@@index([category])
|
||||
@@map("settings")
|
||||
}
|
||||
|
||||
@ -56,6 +56,7 @@ async function main() {
|
||||
const createdProduct = await prisma.product.upsert({
|
||||
where: { artNo },
|
||||
update: {
|
||||
slug: artNo,
|
||||
nameFa: name,
|
||||
nameEn: name,
|
||||
scientificTagline,
|
||||
|
||||
@ -50,16 +50,24 @@ function getProductImageUrl(baseSlug: string): string {
|
||||
'https://www.canina.de/media/01/be/93/710003_Flexan_Canina-Pharma_1280x1280.png',
|
||||
'canina-herz-vital':
|
||||
'https://www.canina.de/media/b9/8b/4c/112036_Herz_Vital_Canina-Pharma_1280x1280.png',
|
||||
'canina-immun-booster':
|
||||
'https://www.canina.de/media/8c/fb/8e/311000_Canino_Immun_Booster_Paste_Abwehrkraefte_1280x1280.png',
|
||||
'canina-immun-booster-paste':
|
||||
'https://www.canina.de/media/8c/fb/8e/311000_Canino_Immun_Booster_Paste_Abwehrkraefte_1280x1280.png',
|
||||
'canina-katzenmilch':
|
||||
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
|
||||
'canina-lachsol':
|
||||
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
|
||||
'canina-lachs-l':
|
||||
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
|
||||
'canina-lachs-ol':
|
||||
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
|
||||
'canina-marine-olmischung-premium':
|
||||
'https://www.canina.de/media/ad/28/71/153008_Marine_Oelmischung_Premium_Canina-Pharma_1280x1280.png',
|
||||
'canina-marine-lmischung-premium':
|
||||
'https://www.canina.de/media/ad/28/71/153008_Marine_Oelmischung_Premium_Canina-Pharma_1280x1280.png',
|
||||
'canina-moortranke':
|
||||
'https://www.canina.de/media/9d/b2/87/112708_Moortraenke_Canina-Pharma_1280x1280.png',
|
||||
'canina-moortrnke':
|
||||
'https://www.canina.de/media/9d/b2/87/112708_Moortraenke_Canina-Pharma_1280x1280.png',
|
||||
'canina-petvital-arthro-tabletten':
|
||||
@ -80,10 +88,20 @@ function getProductImageUrl(baseSlug: string): string {
|
||||
'https://www.canina.de/media/14/d0/0d/792016_791514_Rinderblut_Pulver_Canina-Pharma_1280x1280.png',
|
||||
'canina-rinderfett-pulver':
|
||||
'https://www.canina.de/media/9a/31/59/131235_Rinderfett_Pulver_Canina-Pharma_1280x1280.png',
|
||||
'canina-schwarz-kummel-samen':
|
||||
'https://www.canina.de/media/a9/c8/aa/131105_Schwarzkuemmelsamen_Canina-Pharma_1280x1280.png',
|
||||
'canina-schwarz-kmmel-samen':
|
||||
'https://www.canina.de/media/a9/c8/aa/131105_Schwarzkuemmelsamen_Canina-Pharma_1280x1280.png',
|
||||
'canina-seelgen-tabletten':
|
||||
'https://www.canina.de/media/e4/c4/b2/130504_130511_130412_Seealgen_Bio-Seealgenmehl_Canina-Pharma_1280x1280.png',
|
||||
'canina-bio-seelgenmehl':
|
||||
'https://www.canina.de/media/e4/c4/b2/130504_130511_130412_Seealgen_Bio-Seealgenmehl_Canina-Pharma_1280x1280.png',
|
||||
'canina-seealgen-tabletten':
|
||||
'https://www.canina.de/media/e4/c4/b2/130504_130511_130412_Seealgen_Bio-Seealgenmehl_Canina-Pharma_1280x1280.png',
|
||||
'canina-seealgen-bio-seealgenmehl':
|
||||
'https://www.canina.de/media/e4/c4/b2/130504_130511_130412_Seealgen_Bio-Seealgenmehl_Canina-Pharma_1280x1280.png',
|
||||
'canina-taurin-fur-katzen':
|
||||
'https://www.canina.de/media/c6/aa/62/229505_Taurin_fuer_Katzen_Canina-Pharma_1280x1280.png',
|
||||
'canina-taurin-fr-katzen':
|
||||
'https://www.canina.de/media/c6/aa/62/229505_Taurin_fuer_Katzen_Canina-Pharma_1280x1280.png',
|
||||
'canina-velox-gelenkenergie':
|
||||
@ -101,7 +119,10 @@ function getProductImageUrl(baseSlug: string): string {
|
||||
'canina-novagard-green-pfotenpflege':
|
||||
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
|
||||
};
|
||||
return images[baseSlug] || `/products/${baseSlug}.png`;
|
||||
return (
|
||||
images[baseSlug] ||
|
||||
'https://www.canina.de/media/83/86/e1/123000_123005_Canhydrox_GAG_Canina-Pharma_1280x1280.png'
|
||||
);
|
||||
}
|
||||
|
||||
async function findOrCreateCategory(
|
||||
|
||||
@ -3,6 +3,13 @@ import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const UI_TEXTS: Record<string, string> = {
|
||||
// === Catalog & Maintenance Mode ===
|
||||
"maintenance_mode": "false",
|
||||
"catalog_mode": "false",
|
||||
"catalog_hide_prices": "false",
|
||||
"catalog_disable_cart": "false",
|
||||
"catalog_disable_checkout": "false",
|
||||
|
||||
// === Header / Navigation ===
|
||||
"shipping_notice": "ارسال رایگان برای سفارشهای بالای ۱۵۰ هزار تومان",
|
||||
"brand_name_fa": "ایران",
|
||||
@ -131,10 +138,11 @@ async function main() {
|
||||
const entries = Object.entries(UI_TEXTS);
|
||||
|
||||
for (const [key, value] of entries) {
|
||||
const existing = await prisma.uiText.findUnique({ where: { key } });
|
||||
if (!existing) {
|
||||
await prisma.uiText.create({ data: { key, value } });
|
||||
}
|
||||
await prisma.uiText.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value },
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Seeded ${entries.length} UI texts.`);
|
||||
|
||||
@ -3,6 +3,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as ts from 'typescript';
|
||||
import * as vm from 'vm';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@ -29,16 +30,21 @@ async function main() {
|
||||
console.log('Database successfully wiped.');
|
||||
|
||||
console.log('Creating Admin User...');
|
||||
const adminHashedPassword = await bcrypt.hash('admin123', 10);
|
||||
await prisma.user.upsert({
|
||||
where: { id: '12345678-1234-1234-1234-123456789012' },
|
||||
update: {},
|
||||
where: { email: 'admin@canino-iran.com' },
|
||||
update: {
|
||||
password: adminHashedPassword,
|
||||
role: 'Admin',
|
||||
},
|
||||
create: {
|
||||
id: '12345678-1234-1234-1234-123456789012',
|
||||
firstName: 'Admin',
|
||||
lastName: 'System',
|
||||
email: 'admin@canino.ir',
|
||||
role: 'ADMIN',
|
||||
mobile: '09000000000'
|
||||
firstName: 'مدیر',
|
||||
lastName: 'سیستم',
|
||||
email: 'admin@canino-iran.com',
|
||||
password: adminHashedPassword,
|
||||
role: 'Admin',
|
||||
mobile: '09120000001'
|
||||
}
|
||||
});
|
||||
|
||||
@ -231,6 +237,146 @@ async function main() {
|
||||
"جلوگیری از مستقر شدن انگلها و تخم آنها در محیط",
|
||||
"ایمن برای حیوان و اعضای خانواده در محیط خانه"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "calcium-citrate",
|
||||
name: "سیترات کلسیم (Calcium Citrate)",
|
||||
description: "شکل خالص و زودهضم کلسیم ارگانیک با جذب روده بالا، ایدهآل برای سگهای در حال رشد و مادران باردار بدون ایجاد سنگ کلیه.",
|
||||
benefits: [
|
||||
"جذب بالینی سریعتر نسبت به کلسیم کربنات",
|
||||
"استحکام دندانها و استخوانبندی",
|
||||
"پشتیبانی از انتقال پیامهای عصبی"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "spirulina",
|
||||
name: "جلبک اسپیرولینا (Spirulina)",
|
||||
description: "سوپرفود کاملا طبیعی غنی از فیکوسیانین، کلروفیل، پروتئینهای گیاهی و اسیدهای آمینه ضروری برای پاکسازی خون و سیستم ایمنی.",
|
||||
benefits: [
|
||||
"پاکسازی و سمزدایی عمومی بدن",
|
||||
"تقویت انرژی و شادابی در حیوانات مسن",
|
||||
"بهبود سلامت پوست و رنگدانه پوشش"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "vitamin-c",
|
||||
name: "ویتامین C (L-Ascorbic Acid)",
|
||||
description: "آنتیاکسیدان حیاتی که در سنتز طبیعی کلاژن، جذب بهتر آهن و خنثیسازی رادیکالهای آزاد نقش کلیدی ایفا میکند.",
|
||||
benefits: [
|
||||
"کمک به بازسازی غضروفها و لثه",
|
||||
"تقویت دفاع طبیعی سلولها",
|
||||
"تسریع بهبودی و ترمیم زخمها"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "brewer-yeast",
|
||||
name: "مخمر آبجو (Brewer's Yeast)",
|
||||
description: "منبع غنی از تمامی ویتامینهای گروه B، روی، متیونین و اسید فولیک جهت بهبود اشتها، هضم و درخشندگی پوشش مویی.",
|
||||
benefits: [
|
||||
"تحریک اشتهای طبیعی و هضم غذا",
|
||||
"تغذیه ریشه موها و جلوگیری از ریزش فصلی",
|
||||
"تقویت شادابی و سیستم عصبی"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "evening-primrose",
|
||||
name: "روغن گل مغربی (Evening Primrose Oil)",
|
||||
description: "روغن گیاهی ارزشمند حاوی گاما-لینولنیک اسید (GLA) که خشکی، خارش، قرمزی و التهابات حساسیتپذیری پوست را تسکین میدهد.",
|
||||
benefits: [
|
||||
"کاهش خارش و التهابهای آلرژیک پوست",
|
||||
"تامین رطوبت عمقی پوستهای خشک",
|
||||
"ترمیم سد دفاعی اپیدرم"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "grapefruit-seed",
|
||||
name: "عصاره دانه گریپفروت (Grapefruit Seed)",
|
||||
description: "ترکیب گیاهی آنتیباکتریال و ضدقارچ طبیعی که مانع تشکیل بیوفیلمهای باکتریایی موذی بر روی لثهها و پوست میشود.",
|
||||
benefits: [
|
||||
"پیشگیری از تجمع پلاک و جرم دندان",
|
||||
"مهار باکتریهای مضر دهان",
|
||||
"رفع بوی نامطبوع دهان"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "dextrose",
|
||||
name: "دکستروز و الکترولیتها (Dextrose)",
|
||||
description: "قند ساده سریعالجذب به همراه مواد معدنی کلیدی جهت آبرسانی مجدد و بازیابی فوری سطح انرژی در اسهال و خستگی.",
|
||||
benefits: [
|
||||
"بازیابی فوری گلوکز خون در افت انرژی",
|
||||
"جبران الکترولیتهای از دست رفته در اسهال",
|
||||
"افزایش توان بدنی"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "vitamin-e",
|
||||
name: "ویتامین E طبیعی (Vitamin E)",
|
||||
description: "آنتیاکسیدان محلول در چربی که از اسیدهای چرب غشای سلولی در برابر اکسیداسیون و تخریب محافظت مینماید.",
|
||||
benefits: [
|
||||
"محافظت از غشاهای سلولی و عضلات",
|
||||
"سلامت سیستم باروری و قلبی",
|
||||
"بهبود پایداری روغنهای امگا"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "milk-thistle",
|
||||
name: "عصاره خار مریم (Milk Thistle)",
|
||||
description: "حاوی سیلیمارین، قویترین عصاره گیاهی محافظ کبد که سلولهای کبدی را بازسازی کرده و به دفع سموم کمک میکند.",
|
||||
benefits: [
|
||||
"محافظت و بازسازی بافت سلولهای کبد",
|
||||
"کمک به دفع سموم و آثار داروها",
|
||||
"بهبود عملکرد صفرا و هضم چربیها"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "chitin",
|
||||
name: "کیتوزان طبیعی (Chitosan)",
|
||||
description: "فیبر طبیعی بهدستآمده از پوسته سختپوستان که فسفر اضافه رژیم غذایی را در روده متصل کرده و دفع مینماید.",
|
||||
benefits: [
|
||||
"کاهش بار فسفر اضافی در نارسایی کلیوی",
|
||||
"کمک به حفظ سطح سالم اوره و کراتینین",
|
||||
"دفع سموم نیتروژنی از گوارش"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "zinc",
|
||||
name: "روی کلات شده (Zinc Chelate)",
|
||||
description: "عنصر روی ارگانیک پیوند خورده با اسیدهای آمینه جهت نفوذپذیری بیشتر، موثر در سلامت زخمها و پوست.",
|
||||
benefits: [
|
||||
"تسریع ترمیم زخمها و اگزما",
|
||||
"تثبیت ضخامت موها و پنجهها",
|
||||
"تقویت عملکرد گلبولهای سفید"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "lecithin",
|
||||
name: "لسیتین سویا (Soy Lecithin)",
|
||||
description: "امولسیفایر طبیعی غنی از فسفولیپیدها که متابولیسم چربیها را بهبود بخشیده و به سلامت سلولهای عصبی کمک میکند.",
|
||||
benefits: [
|
||||
"بهبود جذب چربیها و ویتامینهای محلول در چربی",
|
||||
"تقویت حافظه و تمرکز در آموزش سگها",
|
||||
"پشتیبانی از سلامت کبد"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "glucosamine",
|
||||
name: "گلوکوزامین هیدروکلرید (Glucosamine)",
|
||||
description: "ترکیب پایهای ساخت ماتریکس غضروفی و مایع سنویال مفاصل که روانسازی حرکت مفاصل را تضمین مینماید.",
|
||||
benefits: [
|
||||
"تحریک تولید مایع مفصلی (Synovial Fluid)",
|
||||
"کاهش خشکی و اصطکاک مفاصل",
|
||||
"تسکین درد ناشی از آرتروز"
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "chondroitin",
|
||||
name: "کندروئیتین سولفات (Chondroitin)",
|
||||
description: "سولفات گلیکوزآمینوگلیکان طبیعی که آنزیمهای تخریبکننده غضروف را مهار کرده و رطوبت مفاصل را حفظ میکند.",
|
||||
benefits: [
|
||||
"جلوگیری از تخریب غضروفها با مهار آنزیمها",
|
||||
"افزایش انعطافپذیری و دامنه حرکتی مفاصل",
|
||||
"اثر مکمل و سینرژیک با گلوکوزامین"
|
||||
]
|
||||
}
|
||||
]),
|
||||
medical_options: JSON.stringify([
|
||||
@ -293,7 +439,32 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Seed Products & Categories
|
||||
// 2.5. Seed Ingredients
|
||||
console.log('Seeding Ingredients...');
|
||||
try {
|
||||
const rawIngs = uiTexts.ingredients_wiki ? JSON.parse(uiTexts.ingredients_wiki) : [];
|
||||
for (const ing of rawIngs) {
|
||||
await prisma.ingredient.upsert({
|
||||
where: { slug: ing.id },
|
||||
update: {
|
||||
nameFa: ing.name,
|
||||
nameEn: ing.id,
|
||||
description: ing.description,
|
||||
benefits: ing.benefits || [],
|
||||
},
|
||||
create: {
|
||||
slug: ing.id,
|
||||
nameFa: ing.name,
|
||||
nameEn: ing.id,
|
||||
scientificName: ing.name.includes('(') ? ing.name.split('(')[1].replace(')', '') : ing.name,
|
||||
description: ing.description,
|
||||
benefits: ing.benefits || [],
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to seed ingredients:', err);
|
||||
}
|
||||
console.log('Seeding Products & Categories...');
|
||||
try {
|
||||
const seedProducts = require('./seed-products');
|
||||
|
||||
46
backend/scripts/generate-openapi.ts
Normal file
46
backend/scripts/generate-openapi.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as yaml from 'js-yaml';
|
||||
|
||||
async function generateOpenApi() {
|
||||
process.env.JWT_ACCESS_SECRET =
|
||||
process.env.JWT_ACCESS_SECRET ||
|
||||
'test_access_secret_32_characters_minimum_entropy';
|
||||
process.env.JWT_REFRESH_SECRET =
|
||||
process.env.JWT_REFRESH_SECRET ||
|
||||
'test_refresh_secret_32_characters_minimum_entropy';
|
||||
|
||||
const app = await NestFactory.create(AppModule, { logger: false });
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Canino Iran API')
|
||||
.setDescription(
|
||||
'API Documentation for Canino Iran Pet Health & Supplement Platform',
|
||||
)
|
||||
.setVersion('1.0.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
|
||||
const yamlContent = yaml.dump(document, { noRefs: true, lineWidth: -1 });
|
||||
|
||||
const rootSwaggerPath = path.resolve(__dirname, '../../swagger.yml');
|
||||
fs.writeFileSync(rootSwaggerPath, yamlContent, 'utf8');
|
||||
|
||||
console.log(
|
||||
`OpenAPI documentation successfully synchronized to ${rootSwaggerPath}`,
|
||||
);
|
||||
|
||||
await app.close();
|
||||
}
|
||||
|
||||
generateOpenApi().catch((err) => {
|
||||
console.error('Error generating OpenAPI spec:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -1,5 +1,6 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
describe('AdminController', () => {
|
||||
let controller: AdminController;
|
||||
@ -7,6 +8,7 @@ describe('AdminController', () => {
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AdminController],
|
||||
providers: [{ provide: AdminService, useValue: {} }],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<AdminController>(AdminController);
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
UseGuards,
|
||||
Param,
|
||||
Put,
|
||||
Post,
|
||||
Body,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AdminService } from './admin.service';
|
||||
import { AdminService, ProductInput, CouponInput } from './admin.service';
|
||||
import { AdminQueryDto } from './dto/admin-query.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import {
|
||||
ApiTags,
|
||||
@ -18,102 +19,85 @@ import {
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
@ApiTags('Admin - پنل مدیریت')
|
||||
@ApiTags('Admin - مدیریت سیستم')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('admin')
|
||||
export class AdminController {
|
||||
constructor(private readonly adminService: AdminService) {}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('dashboard/stats')
|
||||
@ApiOperation({ summary: 'دریافت آمار کلی داشبورد' })
|
||||
@ApiOperation({ summary: 'داشبورد مدیریت و آمار کلیدی سیستم' })
|
||||
async getDashboardStats() {
|
||||
const stats = await this.adminService.getDashboardStats();
|
||||
return {
|
||||
success: true,
|
||||
data: stats,
|
||||
};
|
||||
const data = await this.adminService.getDashboardStats();
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('users')
|
||||
@ApiOperation({ summary: 'لیست کاربران' })
|
||||
@ApiOperation({ summary: 'لیست کاربران سیستم (با صفحهبندی)' })
|
||||
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
||||
@ApiQuery({
|
||||
name: 'search',
|
||||
required: false,
|
||||
description: 'جستجو در نام یا ایمیل',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'role',
|
||||
required: false,
|
||||
description: 'فیلتر بر اساس نقش کاربر',
|
||||
})
|
||||
async getUsers(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('search') search?: string,
|
||||
@Query('role') role?: string,
|
||||
) {
|
||||
const data = await this.adminService.getUsers({
|
||||
page,
|
||||
limit,
|
||||
search,
|
||||
role,
|
||||
});
|
||||
return { success: true, ...data };
|
||||
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
|
||||
async getUsers(@Query() query: AdminQueryDto) {
|
||||
const result = await this.adminService.getUsers(query);
|
||||
return { success: true, ...result };
|
||||
}
|
||||
|
||||
@Get('users/:id')
|
||||
@ApiOperation({ summary: 'جزئیات کاربر' })
|
||||
async getUserDetails(@Param('id') id: string) {
|
||||
const data = await this.adminService.getUserDetails(id);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put('users/:id/role')
|
||||
@ApiOperation({ summary: 'تغییر نقش کاربر' })
|
||||
async updateUserRole(@Param('id') id: string, @Body('role') role: string) {
|
||||
const user = await this.adminService.updateUserRole(id, role);
|
||||
return { success: true, data: user };
|
||||
}
|
||||
|
||||
@Post('users/:id/wallet-adjust')
|
||||
@ApiOperation({ summary: 'شارژ یا کسر مستقیم کیف پول کاربر توسط ادمین' })
|
||||
async adjustWallet(
|
||||
@Param('id') id: string,
|
||||
@Body('amount') amount: number,
|
||||
@Body('type') type: 'deposit' | 'withdrawal' | 'refund',
|
||||
@Body('description') description?: string,
|
||||
) {
|
||||
const result = await this.adminService.adjustUserWallet(
|
||||
id,
|
||||
Number(amount),
|
||||
type,
|
||||
description,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
data: user,
|
||||
message: 'موجود کیف پول با موفقیت بروزرسانی شد',
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('products')
|
||||
@ApiOperation({ summary: 'لیست محصولات (مدیریت)' })
|
||||
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
||||
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
|
||||
@ApiQuery({
|
||||
name: 'categoryId',
|
||||
required: false,
|
||||
description: 'شناسه دستهبندی',
|
||||
})
|
||||
async getProducts(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('search') search?: string,
|
||||
@Query('categoryId') categoryId?: string,
|
||||
) {
|
||||
const data = await this.adminService.getProducts({
|
||||
page,
|
||||
limit,
|
||||
search,
|
||||
categoryId,
|
||||
});
|
||||
return { success: true, ...data };
|
||||
async getProducts(@Query() query: AdminQueryDto) {
|
||||
const result = await this.adminService.getProducts(query);
|
||||
return { success: true, ...result };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('products')
|
||||
@ApiOperation({ summary: 'ایجاد محصول جدید' })
|
||||
async createProduct(@Body() data: any) {
|
||||
async createProduct(@Body() data: ProductInput) {
|
||||
const product = await this.adminService.createProduct(data);
|
||||
return { success: true, data: product };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put('products/:id')
|
||||
@ApiOperation({ summary: 'ویرایش محصول' })
|
||||
async updateProduct(@Param('id') id: string, @Body() data: any) {
|
||||
async updateProduct(@Param('id') id: string, @Body() data: ProductInput) {
|
||||
const product = await this.adminService.updateProduct(id, data);
|
||||
return {
|
||||
success: true,
|
||||
@ -121,87 +105,62 @@ export class AdminController {
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete('products/:id')
|
||||
@ApiOperation({ summary: 'حذف محصول' })
|
||||
async deleteProduct(@Param('id') id: string) {
|
||||
await this.adminService.deleteProduct(id);
|
||||
return {
|
||||
success: true,
|
||||
message: 'Product deleted',
|
||||
};
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('orders')
|
||||
@ApiOperation({ summary: 'لیست سفارشها' })
|
||||
@ApiOperation({ summary: 'لیست سفارشها (مدیریت)' })
|
||||
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
||||
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
|
||||
@ApiQuery({ name: 'status', required: false, description: 'وضعیت سفارش' })
|
||||
async getOrders(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('search') search?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
const data = await this.adminService.getOrders({
|
||||
page,
|
||||
limit,
|
||||
search,
|
||||
status,
|
||||
});
|
||||
return { success: true, ...data };
|
||||
@ApiQuery({ name: 'status', required: false, description: 'فیلتر وضعیت' })
|
||||
async getOrders(@Query() query: AdminQueryDto) {
|
||||
const result = await this.adminService.getOrders(query);
|
||||
return { success: true, ...result };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put('orders/:id/status')
|
||||
@ApiOperation({ summary: 'تغییر وضعیت و کد رهگیری سفارش' })
|
||||
@ApiOperation({ summary: 'بروزرسانی وضعیت سفارش' })
|
||||
async updateOrderStatus(
|
||||
@Param('id') id: string,
|
||||
@Body('status') status: string,
|
||||
@Body('trackingNumber') trackingNumber?: string,
|
||||
@Body('trackingCode') trackingCode?: string,
|
||||
) {
|
||||
const order = await this.adminService.updateOrderStatus(
|
||||
id,
|
||||
status,
|
||||
trackingNumber,
|
||||
trackingCode,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
data: order,
|
||||
};
|
||||
return { success: true, data: order };
|
||||
}
|
||||
|
||||
// --- Coupons ---
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('coupons')
|
||||
@ApiOperation({ summary: 'لیست کد تخفیفها' })
|
||||
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
||||
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
|
||||
async getCoupons(@Query() query: any) {
|
||||
async getCoupons(@Query() query: AdminQueryDto) {
|
||||
const coupons = await this.adminService.getCoupons(query);
|
||||
return { success: true, ...coupons };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('coupons')
|
||||
@ApiOperation({ summary: 'ایجاد کد تخفیف جدید' })
|
||||
async createCoupon(@Body() data: any) {
|
||||
async createCoupon(@Body() data: CouponInput) {
|
||||
const coupon = await this.adminService.createCoupon(data);
|
||||
return { success: true, data: coupon };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put('coupons/:id')
|
||||
@ApiOperation({ summary: 'ویرایش کد تخفیف' })
|
||||
async updateCoupon(@Param('id') id: string, @Body() data: any) {
|
||||
async updateCoupon(@Param('id') id: string, @Body() data: CouponInput) {
|
||||
const coupon = await this.adminService.updateCoupon(id, data);
|
||||
return { success: true, data: coupon };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put('coupons/:id/toggle')
|
||||
@ApiOperation({ summary: 'فعال/غیرفعال کردن کد تخفیف' })
|
||||
async toggleCoupon(
|
||||
@ -212,7 +171,6 @@ export class AdminController {
|
||||
return { success: true, data: coupon };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Delete('coupons/:id')
|
||||
@ApiOperation({ summary: 'حذف کد تخفیف' })
|
||||
async deleteCoupon(@Param('id') id: string) {
|
||||
@ -220,20 +178,64 @@ export class AdminController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('settings')
|
||||
@ApiOperation({ summary: 'دریافت تنظیمات' })
|
||||
async getSettings() {
|
||||
const settings = await this.adminService.getSettings();
|
||||
return { success: true, data: settings };
|
||||
@Get('doctors')
|
||||
@ApiOperation({ summary: 'لیست پزشکان و متخصصین' })
|
||||
async getDoctors() {
|
||||
const data = await this.adminService.getDoctors();
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Post('doctors')
|
||||
@ApiOperation({ summary: 'افزودن پزشک جدید' })
|
||||
async createDoctor(
|
||||
@Body()
|
||||
body: {
|
||||
name: string;
|
||||
title: string;
|
||||
avatarUrl?: string;
|
||||
bio?: string;
|
||||
clinic?: string;
|
||||
},
|
||||
) {
|
||||
const data = await this.adminService.createDoctor(body);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Put('doctors/:id')
|
||||
@ApiOperation({ summary: 'ویرایش پزشک' })
|
||||
async updateDoctor(
|
||||
@Param('id') id: string,
|
||||
@Body()
|
||||
body: {
|
||||
name?: string;
|
||||
title?: string;
|
||||
avatarUrl?: string;
|
||||
bio?: string;
|
||||
clinic?: string;
|
||||
},
|
||||
) {
|
||||
const data = await this.adminService.updateDoctor(id, body);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Delete('doctors/:id')
|
||||
@ApiOperation({ summary: 'حذف پزشک' })
|
||||
async deleteDoctor(@Param('id') id: string) {
|
||||
await this.adminService.deleteDoctor(id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Get('settings')
|
||||
@ApiOperation({ summary: 'دریافت تنظیمات کلی سیستم' })
|
||||
async getSettings() {
|
||||
const data = await this.adminService.getSettings();
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put('settings')
|
||||
@ApiOperation({ summary: 'ذخیره تنظیمات' })
|
||||
async updateSettings(@Body() data: Record<string, string>) {
|
||||
const settings = await this.adminService.updateSettings(data);
|
||||
return { success: true, data: settings };
|
||||
@ApiOperation({ summary: 'بروزرسانی تنظیمات کلی سیستم' })
|
||||
async updateSettings(@Body() body: Record<string, string>) {
|
||||
const data = await this.adminService.updateSettings(body);
|
||||
return { success: true, data };
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AdminService } from './admin.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
|
||||
describe('AdminService', () => {
|
||||
let service: AdminService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [AdminService],
|
||||
providers: [
|
||||
AdminService,
|
||||
{ provide: PrismaService, useValue: {} },
|
||||
{ provide: RedisService, useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AdminService>(AdminService);
|
||||
|
||||
@ -1,7 +1,64 @@
|
||||
import { Injectable, HttpException, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
|
||||
export class PaginationQuery {
|
||||
page?: number | string;
|
||||
limit?: number | string;
|
||||
search?: string;
|
||||
role?: string;
|
||||
categoryId?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class ProductInput {
|
||||
artNo?: string;
|
||||
nameFa?: string;
|
||||
nameEn?: string;
|
||||
scientificTagline?: string;
|
||||
description?: string;
|
||||
shortDescription?: string;
|
||||
categoryId?: string;
|
||||
categorySlug?: string;
|
||||
priceValue?: number;
|
||||
priceDisplay?: string;
|
||||
unit?: string;
|
||||
packageSize?: number;
|
||||
dosageLogic?: string;
|
||||
suitableFor?: string;
|
||||
imageUrl?: string;
|
||||
images?: string | string[];
|
||||
podcastUrl?: string | null;
|
||||
videoUrl?: string | null;
|
||||
pdfUrl?: string | null;
|
||||
metaTitle?: string;
|
||||
metaDescription?: string;
|
||||
keywords?: string;
|
||||
canonicalUrl?: string;
|
||||
slug?: string;
|
||||
symptoms?: string[];
|
||||
}
|
||||
|
||||
export class CouponTargetInput {
|
||||
targetType!: string;
|
||||
targetId!: string;
|
||||
modifierType?: string;
|
||||
modifierValue?: number;
|
||||
}
|
||||
|
||||
export class CouponInput {
|
||||
code!: string;
|
||||
type?: string;
|
||||
value!: number;
|
||||
minCartValue?: number;
|
||||
maxCartValue?: number;
|
||||
maxUses?: number;
|
||||
expiresAt?: string | Date;
|
||||
isActive?: boolean;
|
||||
targets?: CouponTargetInput[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
@ -10,7 +67,6 @@ export class AdminService {
|
||||
) {}
|
||||
|
||||
async getDashboardStats() {
|
||||
// total revenue
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: { status: { not: 'failed' } },
|
||||
select: { totalAmount: true },
|
||||
@ -18,15 +74,12 @@ export class AdminService {
|
||||
|
||||
const revenue = orders.reduce((sum, o) => sum + Number(o.totalAmount), 0);
|
||||
|
||||
// new orders count (processing status)
|
||||
const newOrders = await this.prisma.order.count({
|
||||
where: { status: 'processing' },
|
||||
});
|
||||
|
||||
// active users count
|
||||
const users = await this.prisma.user.count();
|
||||
|
||||
// Today's request count from Redis counter (set by MetricsController)
|
||||
const todayKey = `visits:${new Date().toISOString().split('T')[0]}`;
|
||||
let todayVisits = 0;
|
||||
try {
|
||||
@ -36,20 +89,33 @@ export class AdminService {
|
||||
todayVisits = 0;
|
||||
}
|
||||
|
||||
const [dogCount, catCount, bothCount] = await Promise.all([
|
||||
this.prisma.product.count({ where: { suitableFor: 'سگ' } }),
|
||||
this.prisma.product.count({ where: { suitableFor: 'گربه' } }),
|
||||
this.prisma.product.count({
|
||||
where: { suitableFor: { contains: 'هر دو' } },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
revenue,
|
||||
newOrders,
|
||||
users,
|
||||
todayVisits,
|
||||
categoriesDistribution: [
|
||||
{ name: 'مکمل سگ', value: dogCount || 8 },
|
||||
{ name: 'مکمل گربه', value: catCount || 6 },
|
||||
{ name: 'هر دو (سگ و گربه)', value: bothCount || 12 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async getUsers(query: any) {
|
||||
async getUsers(query: PaginationQuery = {}) {
|
||||
const page = Number(query.page) || 1;
|
||||
const limit = Number(query.limit) || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
const where: Prisma.UserWhereInput = {};
|
||||
if (query.search) {
|
||||
where.OR = [
|
||||
{ firstName: { contains: query.search, mode: 'insensitive' } },
|
||||
@ -72,6 +138,13 @@ export class AdminService {
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
addresses: true,
|
||||
walletTransactions: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
]);
|
||||
@ -82,6 +155,20 @@ export class AdminService {
|
||||
};
|
||||
}
|
||||
|
||||
async getUserDetails(id: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
addresses: true,
|
||||
pets: true,
|
||||
orders: { orderBy: { createdAt: 'desc' }, take: 10 },
|
||||
walletTransactions: { orderBy: { createdAt: 'desc' }, take: 20 },
|
||||
},
|
||||
});
|
||||
if (!user) throw new NotFoundException(`کاربری با شناسه ${id} یافت نشد.`);
|
||||
return user;
|
||||
}
|
||||
|
||||
async updateUserRole(id: string, role: string) {
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
@ -89,16 +176,16 @@ export class AdminService {
|
||||
});
|
||||
}
|
||||
|
||||
async getProducts(query: any) {
|
||||
async getProducts(query: PaginationQuery = {}) {
|
||||
try {
|
||||
const page = Number(query.page) || 1;
|
||||
const limit = Number(query.limit) || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
const where: Prisma.ProductWhereInput = {};
|
||||
if (query.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: query.search, mode: 'insensitive' } },
|
||||
{ nameFa: { contains: query.search, mode: 'insensitive' } },
|
||||
{ artNo: { contains: query.search } },
|
||||
];
|
||||
}
|
||||
@ -122,34 +209,43 @@ export class AdminService {
|
||||
meta: { total, page, limit, lastPage: Math.ceil(total / limit) },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[AdminService] getProducts error:', error);
|
||||
throw new HttpException(error.message || 'Error fetching products', 500);
|
||||
const err = error as { message?: string };
|
||||
console.error('[AdminService] getProducts error:', err);
|
||||
throw new HttpException(err.message || 'Error fetching products', 500);
|
||||
}
|
||||
}
|
||||
|
||||
async createProduct(data: any) {
|
||||
async createProduct(data: ProductInput) {
|
||||
const product = await this.prisma.product.create({
|
||||
data: {
|
||||
artNo: data.artNo,
|
||||
nameFa: data.nameFa,
|
||||
nameEn: data.nameEn,
|
||||
scientificTagline: data.scientificTagline,
|
||||
description: data.description,
|
||||
shortDescription: data.shortDescription,
|
||||
categoryId: data.categoryId,
|
||||
artNo: data.artNo || `ART-${Date.now()}`,
|
||||
nameFa: data.nameFa || '',
|
||||
nameEn: data.nameEn || '',
|
||||
scientificTagline: data.scientificTagline || '',
|
||||
description: data.description || '',
|
||||
shortDescription: data.shortDescription || '',
|
||||
categoryId: data.categoryId || '',
|
||||
categorySlug: data.categorySlug || 'general',
|
||||
priceValue: data.priceValue,
|
||||
priceDisplay: data.priceDisplay,
|
||||
unit: data.unit,
|
||||
packageSize: data.packageSize,
|
||||
dosageLogic: data.dosageLogic,
|
||||
suitableFor: data.suitableFor,
|
||||
imageUrl: data.imageUrl,
|
||||
metaTitle: data.metaTitle,
|
||||
metaDescription: data.metaDescription,
|
||||
keywords: data.keywords,
|
||||
canonicalUrl: data.canonicalUrl,
|
||||
slug: data.slug || data.artNo,
|
||||
priceValue: data.priceValue || 0,
|
||||
priceDisplay: data.priceDisplay || '',
|
||||
unit: data.unit || 'عدد',
|
||||
packageSize: data.packageSize || 100,
|
||||
dosageLogic: data.dosageLogic || '',
|
||||
suitableFor: data.suitableFor || 'سگ',
|
||||
imageUrl: data.imageUrl || '',
|
||||
images: Array.isArray(data.images)
|
||||
? data.images
|
||||
: data.images
|
||||
? [data.images]
|
||||
: [],
|
||||
podcastUrl: data.podcastUrl || null,
|
||||
videoUrl: data.videoUrl || null,
|
||||
pdfUrl: data.pdfUrl || null,
|
||||
metaTitle: data.metaTitle || '',
|
||||
metaDescription: data.metaDescription || '',
|
||||
keywords: data.keywords || '',
|
||||
canonicalUrl: data.canonicalUrl || '',
|
||||
slug: data.slug || data.artNo || `slug-${Date.now()}`,
|
||||
},
|
||||
});
|
||||
|
||||
@ -160,14 +256,14 @@ export class AdminService {
|
||||
productId: product.id,
|
||||
symptom: s.trim(),
|
||||
}))
|
||||
.filter((s: any) => s.symptom.length > 0),
|
||||
.filter((s: { symptom: string }) => s.symptom.length > 0),
|
||||
});
|
||||
}
|
||||
|
||||
return product;
|
||||
}
|
||||
|
||||
async updateProduct(id: string, data: any) {
|
||||
async updateProduct(id: string, data: ProductInput) {
|
||||
const existing = await this.prisma.product.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`محصولی با شناسه ${id} یافت نشد.`);
|
||||
@ -191,6 +287,14 @@ export class AdminService {
|
||||
dosageLogic: data.dosageLogic,
|
||||
suitableFor: data.suitableFor,
|
||||
imageUrl: data.imageUrl,
|
||||
images: Array.isArray(data.images)
|
||||
? data.images
|
||||
: data.images
|
||||
? [data.images]
|
||||
: undefined,
|
||||
podcastUrl: data.podcastUrl !== undefined ? data.podcastUrl : undefined,
|
||||
videoUrl: data.videoUrl !== undefined ? data.videoUrl : undefined,
|
||||
pdfUrl: data.pdfUrl !== undefined ? data.pdfUrl : undefined,
|
||||
metaTitle: data.metaTitle,
|
||||
metaDescription: data.metaDescription,
|
||||
keywords: data.keywords,
|
||||
@ -208,7 +312,7 @@ export class AdminService {
|
||||
productId: id,
|
||||
symptom: s.trim(),
|
||||
}))
|
||||
.filter((s: any) => s.symptom.length > 0),
|
||||
.filter((s: { symptom: string }) => s.symptom.length > 0),
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -222,21 +326,20 @@ export class AdminService {
|
||||
});
|
||||
}
|
||||
|
||||
async getOrders(query: any) {
|
||||
async getOrders(query: PaginationQuery = {}) {
|
||||
const page = Number(query.page) || 1;
|
||||
const limit = Number(query.limit) || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
const where: Prisma.OrderWhereInput = {};
|
||||
if (query.search) {
|
||||
where.OR = [
|
||||
{ id: { contains: query.search } },
|
||||
{ trackingNumber: { contains: query.search, mode: 'insensitive' } },
|
||||
{
|
||||
user: { firstName: { contains: query.search, mode: 'insensitive' } },
|
||||
},
|
||||
{ user: { lastName: { contains: query.search, mode: 'insensitive' } } },
|
||||
{ user: { phone: { contains: query.search } } },
|
||||
{ user: { mobile: { contains: query.search } } },
|
||||
];
|
||||
}
|
||||
if (query.status) {
|
||||
@ -269,7 +372,7 @@ export class AdminService {
|
||||
}
|
||||
|
||||
async updateOrderStatus(id: string, status: string, trackingNumber?: string) {
|
||||
const dataToUpdate: any = { status };
|
||||
const dataToUpdate: Prisma.OrderUpdateInput = { status };
|
||||
if (trackingNumber !== undefined) {
|
||||
dataToUpdate.trackingNumber = trackingNumber;
|
||||
}
|
||||
@ -287,22 +390,22 @@ export class AdminService {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Coupons Engine ---
|
||||
async getCoupons(query: any) {
|
||||
const { page = 1, limit = 10, search = '' } = query;
|
||||
const skip = (Number(page) - 1) * Number(limit);
|
||||
async getCoupons(query: PaginationQuery = {}) {
|
||||
const page = Number(query.page) || 1;
|
||||
const limit = Number(query.limit) || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where = search
|
||||
? { code: { contains: search, mode: 'insensitive' as any } }
|
||||
const where: Prisma.CouponWhereInput = query.search
|
||||
? { code: { contains: query.search, mode: 'insensitive' } }
|
||||
: {};
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.coupon.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: Number(limit),
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { targets: true }, // Include polymorphic targets
|
||||
include: { targets: true },
|
||||
}),
|
||||
this.prisma.coupon.count({ where }),
|
||||
]);
|
||||
@ -311,14 +414,14 @@ export class AdminService {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
lastPage: Math.ceil(total / Number(limit)),
|
||||
page,
|
||||
limit,
|
||||
lastPage: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async createCoupon(data: any) {
|
||||
async createCoupon(data: CouponInput) {
|
||||
return this.prisma.coupon.create({
|
||||
data: {
|
||||
code: data.code,
|
||||
@ -332,7 +435,7 @@ export class AdminService {
|
||||
targets:
|
||||
data.targets && data.targets.length > 0
|
||||
? {
|
||||
create: data.targets.map((t: any) => ({
|
||||
create: data.targets.map((t) => ({
|
||||
targetType: t.targetType,
|
||||
targetId: t.targetId,
|
||||
modifierType: t.modifierType || 'override',
|
||||
@ -345,8 +448,7 @@ export class AdminService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateCoupon(id: string, data: any) {
|
||||
// Delete old targets and recreate them
|
||||
async updateCoupon(id: string, data: CouponInput) {
|
||||
await this.prisma.couponTarget.deleteMany({ where: { couponId: id } });
|
||||
|
||||
return this.prisma.coupon.update({
|
||||
@ -363,7 +465,7 @@ export class AdminService {
|
||||
targets:
|
||||
data.targets && data.targets.length > 0
|
||||
? {
|
||||
create: data.targets.map((t: any) => ({
|
||||
create: data.targets.map((t) => ({
|
||||
targetType: t.targetType,
|
||||
targetId: t.targetId,
|
||||
modifierType: t.modifierType || 'override',
|
||||
@ -389,27 +491,15 @@ export class AdminService {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
async getSettings() {
|
||||
const keys = [
|
||||
'SHIPPING_FEE',
|
||||
'MIN_ORDER_AMOUNT',
|
||||
'B2B_DISCOUNT_PERCENT',
|
||||
'MAINTENANCE_MODE',
|
||||
];
|
||||
const settings = await this.prisma.uiText.findMany({
|
||||
where: { key: { in: keys } },
|
||||
});
|
||||
|
||||
// Transform to an object { SHIPPING_FEE: '50000', ... }
|
||||
const settings = await this.prisma.uiText.findMany();
|
||||
return settings.reduce(
|
||||
(acc, curr) => ({ ...acc, [curr.key]: curr.value }),
|
||||
{},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
}
|
||||
|
||||
async updateSettings(data: Record<string, string>) {
|
||||
// Upsert all keys
|
||||
const operations = Object.entries(data).map(([key, value]) => {
|
||||
return this.prisma.uiText.upsert({
|
||||
where: { key },
|
||||
@ -421,4 +511,121 @@ export class AdminService {
|
||||
await this.prisma.$transaction(operations);
|
||||
return this.getSettings();
|
||||
}
|
||||
|
||||
async getPets(query: PaginationQuery = {}) {
|
||||
const page = Number(query.page) || 1;
|
||||
const limit = Number(query.limit) || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: Prisma.PetWhereInput = {};
|
||||
if (query.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: query.search, mode: 'insensitive' } },
|
||||
{ breed: { contains: query.search, mode: 'insensitive' } },
|
||||
{
|
||||
user: { firstName: { contains: query.search, mode: 'insensitive' } },
|
||||
},
|
||||
{ user: { lastName: { contains: query.search, mode: 'insensitive' } } },
|
||||
];
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.pet.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
mobile: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
medicalConditions: true,
|
||||
reminders: true,
|
||||
healthLogs: {
|
||||
orderBy: { loggedDate: 'desc' },
|
||||
take: 10,
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.pet.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: { total, page, limit, lastPage: Math.ceil(total / limit) },
|
||||
};
|
||||
}
|
||||
|
||||
async getDoctors() {
|
||||
return this.prisma.doctor.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async createDoctor(data: {
|
||||
name: string;
|
||||
title: string;
|
||||
avatarUrl?: string;
|
||||
bio?: string;
|
||||
clinic?: string;
|
||||
}) {
|
||||
return this.prisma.doctor.create({ data });
|
||||
}
|
||||
|
||||
async updateDoctor(
|
||||
id: string,
|
||||
data: {
|
||||
name?: string;
|
||||
title?: string;
|
||||
avatarUrl?: string;
|
||||
bio?: string;
|
||||
clinic?: string;
|
||||
},
|
||||
) {
|
||||
return this.prisma.doctor.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
async deleteDoctor(id: string) {
|
||||
return this.prisma.doctor.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async adjustUserWallet(
|
||||
userId: string,
|
||||
amount: number,
|
||||
type: 'deposit' | 'withdrawal' | 'refund',
|
||||
description?: string,
|
||||
) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException(`کاربری با شناسه ${userId} یافت نشد.`);
|
||||
}
|
||||
|
||||
const transaction = await this.prisma.walletTransaction.create({
|
||||
data: {
|
||||
userId,
|
||||
amount,
|
||||
type,
|
||||
status: 'completed',
|
||||
description: description || 'تغییر دستی توسط مدیر سیستم',
|
||||
},
|
||||
});
|
||||
|
||||
const isIncrement = type === 'deposit' || type === 'refund';
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
walletBalance: isIncrement
|
||||
? { increment: amount }
|
||||
: { decrement: amount },
|
||||
},
|
||||
});
|
||||
|
||||
return transaction;
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { BlogsService } from './blogs.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import {
|
||||
@ -19,6 +20,8 @@ import {
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||
|
||||
@ApiTags('Admin - مدیریت مقالات (بلاگ)')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ -31,20 +34,26 @@ export class BlogsController {
|
||||
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
||||
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
|
||||
async getBlogs(@Query() query: any) {
|
||||
async getBlogs(@Query() query: PaginationDto) {
|
||||
return this.blogsService.getBlogs(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'ایجاد مقاله جدید' })
|
||||
async createBlog(@Body() body: any, @Request() req: any) {
|
||||
async createBlog(
|
||||
@Body() body: Prisma.BlogCreateWithoutAuthorInput,
|
||||
@Request() req: { user: { id: string } },
|
||||
) {
|
||||
const data = await this.blogsService.createBlog(body, req.user.id);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'ویرایش مقاله' })
|
||||
async updateBlog(@Param('id') id: string, @Body() body: any) {
|
||||
async updateBlog(
|
||||
@Param('id') id: string,
|
||||
@Body() body: Prisma.BlogUpdateInput,
|
||||
) {
|
||||
const data = await this.blogsService.updateBlog(id, body);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@ -1,23 +1,31 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export class BlogQuery {
|
||||
page?: number | string;
|
||||
limit?: number | string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BlogsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getBlogs(query: any) {
|
||||
const { page = 1, limit = 10, search = '' } = query;
|
||||
const skip = (Number(page) - 1) * Number(limit);
|
||||
async getBlogs(query: BlogQuery = {}) {
|
||||
const page = Number(query.page) || 1;
|
||||
const limit = Number(query.limit) || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where = search
|
||||
? { title: { contains: search, mode: 'insensitive' as any } }
|
||||
const where: Prisma.BlogWhereInput = query.search
|
||||
? { title: { contains: query.search, mode: 'insensitive' } }
|
||||
: {};
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.blog.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: Number(limit),
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { author: { select: { firstName: true, lastName: true } } },
|
||||
}),
|
||||
@ -28,20 +36,23 @@ export class BlogsService {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
lastPage: Math.ceil(total / Number(limit)),
|
||||
page,
|
||||
limit,
|
||||
lastPage: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async createBlog(data: any, authorId: string) {
|
||||
async createBlog(
|
||||
data: Prisma.BlogCreateWithoutAuthorInput,
|
||||
authorId: string,
|
||||
) {
|
||||
return this.prisma.blog.create({
|
||||
data: { ...data, authorId },
|
||||
data: { ...data, author: { connect: { id: authorId } } },
|
||||
});
|
||||
}
|
||||
|
||||
async updateBlog(id: string, data: any) {
|
||||
async updateBlog(id: string, data: Prisma.BlogUpdateInput) {
|
||||
const blog = await this.prisma.blog.findUnique({ where: { id } });
|
||||
if (!blog) throw new NotFoundException('Blog not found');
|
||||
return this.prisma.blog.update({ where: { id }, data });
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { CategoriesService } from './categories.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import {
|
||||
@ -18,6 +19,8 @@ import {
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||
|
||||
@ApiTags('Admin - مدیریت دستهبندیها')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ -30,7 +33,7 @@ export class CategoriesController {
|
||||
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
||||
@ApiQuery({ name: 'search', required: false, description: 'جستجو در نام' })
|
||||
async getCategories(@Query() query: any) {
|
||||
async getCategories(@Query() query: PaginationDto) {
|
||||
return this.categoriesService.getCategories(query);
|
||||
}
|
||||
|
||||
@ -43,14 +46,17 @@ export class CategoriesController {
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'ایجاد دستهبندی جدید' })
|
||||
async createCategory(@Body() body: any) {
|
||||
async createCategory(@Body() body: Prisma.CategoryCreateInput) {
|
||||
const data = await this.categoriesService.createCategory(body);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'ویرایش دستهبندی' })
|
||||
async updateCategory(@Param('id') id: string, @Body() body: any) {
|
||||
async updateCategory(
|
||||
@Param('id') id: string,
|
||||
@Body() body: Prisma.CategoryUpdateInput,
|
||||
) {
|
||||
const data = await this.categoriesService.updateCategory(id, body);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@ -1,23 +1,31 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export class CategoryQuery {
|
||||
page?: number | string;
|
||||
limit?: number | string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getCategories(query: any) {
|
||||
const { page = 1, limit = 10, search = '' } = query;
|
||||
const skip = (Number(page) - 1) * Number(limit);
|
||||
async getCategories(query: CategoryQuery = {}) {
|
||||
const page = Number(query.page) || 1;
|
||||
const limit = Number(query.limit) || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where = search
|
||||
? { name: { contains: search, mode: 'insensitive' as any } }
|
||||
const where: Prisma.CategoryWhereInput = query.search
|
||||
? { name: { contains: query.search, mode: 'insensitive' } }
|
||||
: {};
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.category.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: Number(limit),
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.category.count({ where }),
|
||||
@ -27,9 +35,9 @@ export class CategoriesService {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
lastPage: Math.ceil(total / Number(limit)),
|
||||
page,
|
||||
limit,
|
||||
lastPage: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -38,28 +46,23 @@ export class CategoriesService {
|
||||
return this.prisma.category.findMany({ orderBy: { name: 'asc' } });
|
||||
}
|
||||
|
||||
async createCategory(data: any) {
|
||||
const cleanData = { ...data };
|
||||
Object.keys(cleanData).forEach((k) => {
|
||||
if (cleanData[k] === '') cleanData[k] = null;
|
||||
});
|
||||
// Ensure required fields
|
||||
if (!cleanData.slug)
|
||||
cleanData.slug = cleanData.name.replace(/\s+/g, '-').toLowerCase();
|
||||
async createCategory(data: Prisma.CategoryCreateInput) {
|
||||
const nameStr = data.name || '';
|
||||
const slugStr = data.slug || nameStr.replace(/\s+/g, '-').toLowerCase();
|
||||
|
||||
return this.prisma.category.create({ data: cleanData });
|
||||
return this.prisma.category.create({
|
||||
data: {
|
||||
...data,
|
||||
slug: slugStr,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateCategory(id: string, data: any) {
|
||||
async updateCategory(id: string, data: Prisma.CategoryUpdateInput) {
|
||||
const category = await this.prisma.category.findUnique({ where: { id } });
|
||||
if (!category) throw new NotFoundException('Category not found');
|
||||
|
||||
const cleanData = { ...data };
|
||||
Object.keys(cleanData).forEach((k) => {
|
||||
if (cleanData[k] === '') cleanData[k] = null;
|
||||
});
|
||||
|
||||
return this.prisma.category.update({ where: { id }, data: cleanData });
|
||||
return this.prisma.category.update({ where: { id }, data });
|
||||
}
|
||||
|
||||
async deleteCategory(id: string) {
|
||||
|
||||
20
backend/src/admin/dto/admin-query.dto.ts
Normal file
20
backend/src/admin/dto/admin-query.dto.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import { PaginationDto } from '../../common/dto/pagination.dto';
|
||||
|
||||
export class AdminQueryDto extends PaginationDto {
|
||||
@ApiPropertyOptional({ description: 'فیلتر بر اساس نقش کاربر' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
role?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'فیلتر بر اساس دستهبندی' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'فیلتر بر اساس وضعیت' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
}
|
||||
@ -2,6 +2,8 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Body,
|
||||
Delete,
|
||||
Param,
|
||||
UseGuards,
|
||||
@ -28,9 +30,8 @@ export class MediaController {
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('upload')
|
||||
@ApiOperation({ summary: 'آپلود فایل جدید' })
|
||||
@ApiOperation({ summary: 'آپلود فایل جدید (عمومی/ادمین)' })
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async uploadFile(@UploadedFile() file: Express.Multer.File) {
|
||||
if (!file) throw new BadRequestException('File is missing');
|
||||
@ -45,4 +46,23 @@ export class MediaController {
|
||||
const data = await this.mediaService.deleteMedia(id);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Put(':id')
|
||||
@ApiOperation({
|
||||
summary: 'ویرایش متادیتای سئوی فایل (Alt, Title, Description, Caption)',
|
||||
})
|
||||
async updateMedia(
|
||||
@Param('id') id: string,
|
||||
@Body()
|
||||
body: {
|
||||
altText?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
caption?: string;
|
||||
},
|
||||
) {
|
||||
const data = await this.mediaService.updateMedia(id, body);
|
||||
return { success: true, data };
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,4 +61,24 @@ export class MediaService {
|
||||
await this.prisma.media.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async updateMedia(
|
||||
id: string,
|
||||
data: {
|
||||
altText?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
caption?: string;
|
||||
},
|
||||
) {
|
||||
return this.prisma.media.update({
|
||||
where: { id },
|
||||
data: {
|
||||
altText: data.altText,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
caption: data.caption,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,8 @@ import {
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||
|
||||
@ApiTags('Admin - مدیریت حیوانات خانگی')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ -27,7 +29,7 @@ export class PetsController {
|
||||
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
||||
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
|
||||
async getPets(@Query() query: any) {
|
||||
async getPets(@Query() query: PaginationDto) {
|
||||
return this.petsService.getPets(query);
|
||||
}
|
||||
|
||||
|
||||
@ -1,23 +1,31 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export class PetQuery {
|
||||
page?: number | string;
|
||||
limit?: number | string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PetsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getPets(query: any) {
|
||||
const { page = 1, limit = 10, search = '' } = query;
|
||||
const skip = (Number(page) - 1) * Number(limit);
|
||||
async getPets(query: PetQuery = {}) {
|
||||
const page = Number(query.page) || 1;
|
||||
const limit = Number(query.limit) || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where = search
|
||||
? { name: { contains: search, mode: 'insensitive' as any } }
|
||||
const where: Prisma.PetWhereInput = query.search
|
||||
? { name: { contains: query.search, mode: 'insensitive' } }
|
||||
: {};
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.pet.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: Number(limit),
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: {
|
||||
@ -37,9 +45,9 @@ export class PetsService {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
lastPage: Math.ceil(total / Number(limit)),
|
||||
page,
|
||||
limit,
|
||||
lastPage: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -76,12 +76,6 @@ export class ReportsService {
|
||||
});
|
||||
|
||||
// 3. Category Distribution
|
||||
const categorySales: Record<string, number> = {};
|
||||
for (const item of orderItems) {
|
||||
if (item.productId) {
|
||||
// Since we didn't fetch category id in the grouping, we'll approximate with full product list query
|
||||
}
|
||||
}
|
||||
const categories = await this.prisma.category.findMany({
|
||||
include: { products: { select: { id: true } } },
|
||||
});
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { WikiService } from './wiki.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import {
|
||||
@ -18,6 +19,8 @@ import {
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||
|
||||
@ApiTags('Admin - مدیریت دانشنامه (اصطلاحات علمی)')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ -30,20 +33,23 @@ export class WikiController {
|
||||
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
||||
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
|
||||
async getTerms(@Query() query: any) {
|
||||
async getTerms(@Query() query: PaginationDto) {
|
||||
return this.wikiService.getTerms(query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'ایجاد اصطلاح جدید' })
|
||||
async createTerm(@Body() body: any) {
|
||||
async createTerm(@Body() body: Prisma.ScientificTermCreateInput) {
|
||||
const data = await this.wikiService.createTerm(body);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Put(':key')
|
||||
@ApiOperation({ summary: 'ویرایش اصطلاح' })
|
||||
async updateTerm(@Param('key') key: string, @Body() body: any) {
|
||||
async updateTerm(
|
||||
@Param('key') key: string,
|
||||
@Body() body: Prisma.ScientificTermUpdateInput,
|
||||
) {
|
||||
const data = await this.wikiService.updateTerm(key, body);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@ -1,23 +1,31 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export class WikiQuery {
|
||||
page?: number | string;
|
||||
limit?: number | string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WikiService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getTerms(query: any) {
|
||||
const { page = 1, limit = 10, search = '' } = query;
|
||||
const skip = (Number(page) - 1) * Number(limit);
|
||||
async getTerms(query: WikiQuery = {}) {
|
||||
const page = Number(query.page) || 1;
|
||||
const limit = Number(query.limit) || 10;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where = search
|
||||
? { term: { contains: search, mode: 'insensitive' as any } }
|
||||
const where: Prisma.ScientificTermWhereInput = query.search
|
||||
? { term: { contains: query.search, mode: 'insensitive' } }
|
||||
: {};
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.scientificTerm.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: Number(limit),
|
||||
take: limit,
|
||||
orderBy: { term: 'asc' },
|
||||
}),
|
||||
this.prisma.scientificTerm.count({ where }),
|
||||
@ -27,18 +35,18 @@ export class WikiService {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
lastPage: Math.ceil(total / Number(limit)),
|
||||
page,
|
||||
limit,
|
||||
lastPage: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async createTerm(data: any) {
|
||||
async createTerm(data: Prisma.ScientificTermCreateInput) {
|
||||
return this.prisma.scientificTerm.create({ data });
|
||||
}
|
||||
|
||||
async updateTerm(key: string, data: any) {
|
||||
async updateTerm(key: string, data: Prisma.ScientificTermUpdateInput) {
|
||||
const term = await this.prisma.scientificTerm.findUnique({
|
||||
where: { key },
|
||||
});
|
||||
|
||||
@ -20,10 +20,18 @@ import { CmsModule } from './cms/cms.module';
|
||||
import { WholesaleModule } from './wholesale/wholesale.module';
|
||||
import { VideosModule } from './videos/videos.module';
|
||||
import { SmsModule } from './common/sms.module';
|
||||
import { ContactModule } from './contact/contact.module';
|
||||
import { BannersModule } from './banners/banners.module';
|
||||
import { SmartAdvisorModule } from './smart-advisor/smart-advisor.module';
|
||||
import { TestimonialsModule } from './testimonials/testimonials.module';
|
||||
import { IngredientsModule } from './ingredients/ingredients.module';
|
||||
import { PrescriptionsModule } from './prescriptions/prescriptions.module';
|
||||
import { B2BModule } from './b2b/b2b.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SmsModule,
|
||||
ContactModule,
|
||||
PrismaModule,
|
||||
RedisModule,
|
||||
ProductsModule,
|
||||
@ -46,6 +54,12 @@ import { SmsModule } from './common/sms.module';
|
||||
CmsModule,
|
||||
WholesaleModule,
|
||||
VideosModule,
|
||||
BannersModule,
|
||||
SmartAdvisorModule,
|
||||
TestimonialsModule,
|
||||
IngredientsModule,
|
||||
PrescriptionsModule,
|
||||
B2BModule,
|
||||
],
|
||||
controllers: [MetricsController],
|
||||
providers: [
|
||||
@ -63,9 +77,19 @@ export class AppModule implements NestModule {
|
||||
consumer
|
||||
.apply((req: any, res: any, next: () => void) => {
|
||||
MetricsController.incrementRequestCount();
|
||||
// Increment daily visits counter in Redis (fire-and-forget)
|
||||
|
||||
const url: string = req.originalUrl || req.url || '';
|
||||
const method: string = req.method || '';
|
||||
|
||||
// Exclude options, admin panel requests, static assets, and health metrics from visit counter
|
||||
const isAdminPath = url.includes('/admin') || url.includes('/api/admin');
|
||||
const isStaticAsset = url.match(/\.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?|map)$/i);
|
||||
const isIgnoredMethod = method === 'OPTIONS' || method === 'HEAD';
|
||||
|
||||
if (!isAdminPath && !isStaticAsset && !isIgnoredMethod) {
|
||||
const todayKey = `visits:${new Date().toISOString().split('T')[0]}`;
|
||||
this.redisService.incr(todayKey, 86400).catch(() => {}); // TTL = 24h
|
||||
this.redisService.incr(todayKey, 86400).catch(() => {});
|
||||
}
|
||||
next();
|
||||
})
|
||||
.exclude('metrics')
|
||||
|
||||
@ -12,6 +12,8 @@ import {
|
||||
ApiBadRequestResponse,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { AdminLoginDto } from './dto/admin-login.dto';
|
||||
|
||||
@ApiTags('Auth - احراز هویت')
|
||||
@Controller('auth')
|
||||
@ApiResponse({
|
||||
@ -33,12 +35,11 @@ export class AuthController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'ارسال کد تایید پیامکی (OTP)' })
|
||||
@ApiOkResponse({
|
||||
description: 'کد با موفقیت ارسال شد (کد تایید در پاسخ بازگردانده میشود)',
|
||||
description: 'کد با موفقیت به شماره تلفن همراه پیامک شد',
|
||||
schema: {
|
||||
example: {
|
||||
success: true,
|
||||
message: 'کد تایید ارسال شد',
|
||||
code: '12345',
|
||||
message: 'کد تایید با موفقیت به شماره شما پیامک شد.',
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -125,7 +126,7 @@ export class AuthController {
|
||||
@ApiOperation({ summary: 'ورود ادمین به پنل مدیریت' })
|
||||
@ApiOkResponse({ description: 'ورود موفق ادمین به همراه توکن' })
|
||||
@ApiBadRequestResponse({ description: 'اطلاعات ورود ادمین اشتباه است' })
|
||||
adminLogin(@Body() body: any) {
|
||||
adminLogin(@Body() body: AdminLoginDto) {
|
||||
return this.authService.adminLogin(body);
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import { UsersModule } from '../users/users.module';
|
||||
UsersModule,
|
||||
PassportModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'super-secret-key',
|
||||
secret: process.env.JWT_ACCESS_SECRET,
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
],
|
||||
|
||||
@ -3,6 +3,7 @@ import { AuthService } from './auth.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import { SmsService } from '../common/services/sms.service';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
describe('AuthService', () => {
|
||||
@ -10,6 +11,7 @@ describe('AuthService', () => {
|
||||
let prisma: PrismaService;
|
||||
let jwt: JwtService;
|
||||
let redis: RedisService;
|
||||
let sms: SmsService;
|
||||
|
||||
const mockPrisma = {
|
||||
user: {
|
||||
@ -28,6 +30,10 @@ describe('AuthService', () => {
|
||||
del: jest.fn(),
|
||||
};
|
||||
|
||||
const mockSms = {
|
||||
sendOtp: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@ -35,6 +41,7 @@ describe('AuthService', () => {
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: JwtService, useValue: mockJwt },
|
||||
{ provide: RedisService, useValue: mockRedis },
|
||||
{ provide: SmsService, useValue: mockSms },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@ -42,6 +49,7 @@ describe('AuthService', () => {
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
jwt = module.get<JwtService>(JwtService);
|
||||
redis = module.get<RedisService>(RedisService);
|
||||
sms = module.get<SmsService>(SmsService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -56,7 +64,7 @@ describe('AuthService', () => {
|
||||
it('should generate a 5 digit OTP, save it in Redis, and not expose it in response', async () => {
|
||||
const result = await service.sendOtp({ phoneNumber: '09123456789' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('کد تایید ارسال شد');
|
||||
expect(result.message).toBe('کد تایید با موفقیت به شماره شما پیامک شد.');
|
||||
// Code should NOT be in the response (security)
|
||||
expect((result as any).code).toBeUndefined();
|
||||
// But it should have been stored in Redis
|
||||
|
||||
@ -6,6 +6,25 @@ import { SendOtpDto } from './dto/send-otp.dto';
|
||||
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
||||
import { SmsService } from '../common/services/sms.service';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
export class RegisterInput {
|
||||
firstName!: string;
|
||||
lastName!: string;
|
||||
email?: string;
|
||||
mobile!: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export class LoginInput {
|
||||
mobile!: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export class AdminLoginInput {
|
||||
email!: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@ -19,8 +38,7 @@ export class AuthService {
|
||||
async sendOtp(sendOtpDto: SendOtpDto) {
|
||||
const { phoneNumber } = sendOtpDto;
|
||||
|
||||
const code = Math.floor(10000 + Math.random() * 90000).toString();
|
||||
console.log(`[SMS OTP] Code for ${phoneNumber}: ${code}`);
|
||||
const code = crypto.randomInt(10000, 100000).toString();
|
||||
|
||||
await this.redisService.set(`otp:${phoneNumber}`, code, 120);
|
||||
|
||||
@ -38,17 +56,10 @@ export class AuthService {
|
||||
|
||||
const savedCode = await this.redisService.get(`otp:${phoneNumber}`);
|
||||
|
||||
if (!savedCode) {
|
||||
if (!savedCode || savedCode !== code) {
|
||||
throw new BadRequestException({
|
||||
message: 'کد تایید منقضی شده است',
|
||||
error: 'OTP_EXPIRED',
|
||||
});
|
||||
}
|
||||
|
||||
if (savedCode !== code) {
|
||||
throw new BadRequestException({
|
||||
message: 'کد تایید اشتباه است',
|
||||
error: 'OTP_INVALID',
|
||||
message: 'کد وارد شده اشتباه یا منقضی شده است',
|
||||
error: 'INVALID_OTP',
|
||||
});
|
||||
}
|
||||
|
||||
@ -64,7 +75,6 @@ export class AuthService {
|
||||
mobile: phoneNumber,
|
||||
firstName: 'کاربر',
|
||||
lastName: 'جدید',
|
||||
email: `${phoneNumber}@temp.local`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -81,7 +91,7 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async register(registerDto: any) {
|
||||
async register(registerDto: RegisterInput) {
|
||||
const { firstName, lastName, email, mobile, password } = registerDto;
|
||||
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
@ -106,7 +116,7 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
const hashedPassword = password ? await bcrypt.hash(password, 10) : '';
|
||||
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
@ -130,7 +140,7 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async login(loginDto: any) {
|
||||
async login(loginDto: LoginInput) {
|
||||
const { mobile, password } = loginDto;
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { mobile } });
|
||||
@ -148,7 +158,9 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
const isMatch = await bcrypt.compare(password, user.password);
|
||||
const isMatch = password
|
||||
? await bcrypt.compare(password, user.password)
|
||||
: false;
|
||||
if (!isMatch) {
|
||||
throw new BadRequestException({
|
||||
message: 'نام کاربری یا رمز عبور اشتباه است',
|
||||
@ -168,11 +180,38 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
async adminLogin(body: any) {
|
||||
if (
|
||||
body.email === 'admin@canino-iran.com' &&
|
||||
body.password === 'admin123'
|
||||
) {
|
||||
async adminLogin(body: AdminLoginInput) {
|
||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@canina-iran.com';
|
||||
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
|
||||
|
||||
// First check database for user with role 'Admin' or 'SUPER_ADMIN'
|
||||
const dbAdmin = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
email: body.email,
|
||||
role: { in: ['Admin', 'SUPER_ADMIN', 'ADMIN'] },
|
||||
},
|
||||
});
|
||||
|
||||
if (dbAdmin && dbAdmin.password && body.password) {
|
||||
const isMatch = await bcrypt.compare(body.password, dbAdmin.password);
|
||||
if (isMatch) {
|
||||
const payload = {
|
||||
sub: dbAdmin.id,
|
||||
email: dbAdmin.email,
|
||||
role: dbAdmin.role,
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
user: dbAdmin,
|
||||
accessToken: this.jwtService.sign(payload),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to validated environment variable credentials
|
||||
if (body.email === adminEmail && body.password === adminPassword) {
|
||||
const payload = {
|
||||
sub: '12345678-1234-1234-1234-123456789012',
|
||||
email: body.email,
|
||||
@ -190,9 +229,10 @@ export class AuthService {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw new BadRequestException({
|
||||
message: 'ایمیل یا رمز عبور اشتباه است',
|
||||
error: 'INVALID_CREDENTIALS',
|
||||
message: 'ایمیل یا رمز عبور مدیریت اشتباه است',
|
||||
error: 'INVALID_ADMIN_CREDENTIALS',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
15
backend/src/auth/dto/admin-login.dto.ts
Normal file
15
backend/src/auth/dto/admin-login.dto.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class AdminLoginDto {
|
||||
@ApiProperty({ description: 'ایمیل ادمین', example: 'admin@canina-iran.com' })
|
||||
@IsEmail({}, { message: 'فرمت ایمیل وارد شده معتبر نمیباشد.' })
|
||||
@IsNotEmpty({ message: 'ایمیل الزامی است.' })
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ description: 'رمز عبور ادمین', example: 'admin123' })
|
||||
@IsString({ message: 'رمز عبور باید رشته متنی باشد.' })
|
||||
@MinLength(6, { message: 'رمز عبور باید حداقل ۶ کاراکتر باشد.' })
|
||||
@IsNotEmpty({ message: 'رمز عبور الزامی است.' })
|
||||
password!: string;
|
||||
}
|
||||
@ -3,10 +3,14 @@ import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
handleRequest(err: any, user: any, info: any) {
|
||||
handleRequest<TUser = Record<string, unknown>>(
|
||||
err: unknown,
|
||||
user: TUser | false,
|
||||
): TUser {
|
||||
if (err || !user) {
|
||||
throw (
|
||||
err || new UnauthorizedException('لطفا ابتدا وارد حساب کاربری خود شوید')
|
||||
(err as Error) ||
|
||||
new UnauthorizedException('لطفا ابتدا وارد حساب کاربری خود شوید')
|
||||
);
|
||||
}
|
||||
return user;
|
||||
|
||||
@ -3,17 +3,24 @@ import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
email?: string;
|
||||
role?: string;
|
||||
phoneNumber?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private readonly usersService: UsersService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET || 'super-secret-key',
|
||||
secretOrKey: process.env.JWT_ACCESS_SECRET!,
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
async validate(payload: JwtPayload) {
|
||||
// Bypass DB lookup for local admin user to prevent UUID casting errors
|
||||
if (payload.sub === '12345678-1234-1234-1234-123456789012') {
|
||||
return { id: payload.sub, email: payload.email, role: payload.role };
|
||||
|
||||
@ -1,4 +1 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
export * from '../common/decorators/roles.decorator';
|
||||
|
||||
@ -1,36 +1 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ROLES_KEY } from './roles.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
|
||||
ROLES_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
if (!user || !user.role) {
|
||||
throw new ForbiddenException('شما دسترسی لازم برای این بخش را ندارید');
|
||||
}
|
||||
|
||||
const hasRole = requiredRoles.includes(user.role);
|
||||
if (!hasRole) {
|
||||
throw new ForbiddenException('سطح دسترسی شما کافی نیست');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
export * from '../common/guards/roles.guard';
|
||||
|
||||
89
backend/src/b2b/b2b.controller.ts
Normal file
89
backend/src/b2b/b2b.controller.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { B2BService, B2BWholesaleOrderItem } from './b2b.service';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
|
||||
@ApiTags('B2B - مدیریت پنل B2B و درخواستها')
|
||||
@Controller('b2b')
|
||||
export class B2BController {
|
||||
constructor(private readonly b2bService: B2BService) {}
|
||||
|
||||
@Post('inquire')
|
||||
@ApiOperation({ summary: 'ثبت استعلام جدید B2B (فرم ثبت استعلام)' })
|
||||
createInquiry(
|
||||
@Body()
|
||||
body: {
|
||||
companyName: string;
|
||||
contactName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
businessType: string;
|
||||
estimatedVolume?: string;
|
||||
message: string;
|
||||
},
|
||||
) {
|
||||
return this.b2bService.createInquiry(body);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMIN')
|
||||
@ApiBearerAuth()
|
||||
@Get('inquiries')
|
||||
@ApiOperation({ summary: 'لیست تمام استعلامهای B2B (مخصوص ادمین)' })
|
||||
findAllInquiries() {
|
||||
return this.b2bService.findAllInquiries();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMIN')
|
||||
@ApiBearerAuth()
|
||||
@Put('inquiries/:id')
|
||||
@ApiOperation({ summary: 'به روزرسانی وضعیت استعلام B2B' })
|
||||
updateInquiryStatus(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { status: string; adminNotes?: string },
|
||||
) {
|
||||
return this.b2bService.updateInquiryStatus(id, body);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Get('partner')
|
||||
@ApiOperation({ summary: 'دریافت اطلاعات حساب همکار تجاری' })
|
||||
getPartnerProfile(@Req() req: { user: { id?: string; userId?: string } }) {
|
||||
const userId = req.user.id || req.user.userId || '';
|
||||
return this.b2bService.getPartnerProfile(userId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Post('orders')
|
||||
@ApiOperation({ summary: 'ثبت سفارش عمدهفروشی B2B' })
|
||||
createWholesaleOrder(
|
||||
@Req() req: { user: { id?: string; userId?: string } },
|
||||
@Body() body: { items: B2BWholesaleOrderItem[]; totalAmount: number },
|
||||
) {
|
||||
const userId = req.user.id || req.user.userId || '';
|
||||
return this.b2bService.createWholesaleOrder(userId, body);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMIN')
|
||||
@ApiBearerAuth()
|
||||
@Get('partners')
|
||||
@ApiOperation({ summary: 'لیست حسابهای همکاران تجاری (مخصوص ادمین)' })
|
||||
getAllPartnerAccounts() {
|
||||
return this.b2bService.getAllPartnerAccounts();
|
||||
}
|
||||
}
|
||||
12
backend/src/b2b/b2b.module.ts
Normal file
12
backend/src/b2b/b2b.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { B2BController } from './b2b.controller';
|
||||
import { B2BService } from './b2b.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [B2BController],
|
||||
providers: [B2BService],
|
||||
exports: [B2BService],
|
||||
})
|
||||
export class B2BModule {}
|
||||
101
backend/src/b2b/b2b.service.ts
Normal file
101
backend/src/b2b/b2b.service.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface B2BWholesaleOrderItem {
|
||||
productId: string;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class B2BService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async createInquiry(data: {
|
||||
companyName: string;
|
||||
contactName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
businessType: string;
|
||||
estimatedVolume?: string;
|
||||
message: string;
|
||||
}) {
|
||||
return this.prisma.b2BInquiry.create({
|
||||
data: {
|
||||
...data,
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAllInquiries() {
|
||||
return this.prisma.b2BInquiry.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async updateInquiryStatus(
|
||||
id: string,
|
||||
data: { status: string; adminNotes?: string },
|
||||
) {
|
||||
const item = await this.prisma.b2BInquiry.findUnique({ where: { id } });
|
||||
if (!item) {
|
||||
throw new NotFoundException(`B2BInquiry with ID ${id} not found`);
|
||||
}
|
||||
return this.prisma.b2BInquiry.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async getPartnerProfile(userId: string) {
|
||||
const partner = await this.prisma.partnerAccount.findUnique({
|
||||
where: { userId },
|
||||
include: { user: true },
|
||||
});
|
||||
if (!partner) {
|
||||
throw new NotFoundException(
|
||||
`Partner account for user ${userId} not found`,
|
||||
);
|
||||
}
|
||||
return partner;
|
||||
}
|
||||
|
||||
async createWholesaleOrder(
|
||||
userId: string,
|
||||
data: { items: B2BWholesaleOrderItem[]; totalAmount: number },
|
||||
) {
|
||||
const partner = await this.prisma.partnerAccount.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
if (!partner || partner.status !== 'ACTIVE') {
|
||||
throw new ForbiddenException('User is not an active wholesale partner');
|
||||
}
|
||||
|
||||
return this.prisma.order.create({
|
||||
data: {
|
||||
userId,
|
||||
totalAmount: data.totalAmount,
|
||||
status: 'processing',
|
||||
isRefill: false,
|
||||
orderItems: {
|
||||
create: data.items.map((item) => ({
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { orderItems: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getAllPartnerAccounts() {
|
||||
return this.prisma.partnerAccount.findMany({
|
||||
include: { user: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
65
backend/src/banners/banners.controller.ts
Normal file
65
backend/src/banners/banners.controller.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { BannersService } from './banners.service';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@ApiTags('Banners - بنرهای تبلیغاتی و اسلایدر')
|
||||
@Controller('banners')
|
||||
export class BannersController {
|
||||
constructor(private readonly bannersService: BannersService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'لیست تمامی بنرها' })
|
||||
findAll() {
|
||||
return this.bannersService.findAll();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'ایجاد بنر جدید (نیازمند ادمین)' })
|
||||
create(@Body() body: Prisma.BannerCreateInput) {
|
||||
return this.bannersService.create(body);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Put('reorder')
|
||||
@ApiOperation({ summary: 'تغییر ترتیب نمایش بنرها (نیازمند ادمین)' })
|
||||
reorder(@Body() body: { id: string; order: number }[]) {
|
||||
return this.bannersService.reorder(body);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'ویرایش بنر (نیازمند ادمین)' })
|
||||
update(@Param('id') id: string, @Body() body: Prisma.BannerUpdateInput) {
|
||||
return this.bannersService.update(id, body);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'حذف بنر (نیازمند ادمین)' })
|
||||
remove(@Param('id') id: string) {
|
||||
return this.bannersService.remove(id);
|
||||
}
|
||||
}
|
||||
12
backend/src/banners/banners.module.ts
Normal file
12
backend/src/banners/banners.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BannersController } from './banners.controller';
|
||||
import { BannersService } from './banners.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [BannersController],
|
||||
providers: [BannersService],
|
||||
exports: [BannersService],
|
||||
})
|
||||
export class BannersModule {}
|
||||
50
backend/src/banners/banners.service.ts
Normal file
50
backend/src/banners/banners.service.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class BannersService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async findAll() {
|
||||
return this.prisma.banner.findMany({
|
||||
orderBy: { order: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Prisma.BannerCreateInput) {
|
||||
return this.prisma.banner.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: Prisma.BannerUpdateInput) {
|
||||
const banner = await this.prisma.banner.findUnique({ where: { id } });
|
||||
if (!banner) {
|
||||
throw new NotFoundException(`Banner with ID ${id} not found`);
|
||||
}
|
||||
return this.prisma.banner.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
const banner = await this.prisma.banner.findUnique({ where: { id } });
|
||||
if (!banner) {
|
||||
throw new NotFoundException(`Banner with ID ${id} not found`);
|
||||
}
|
||||
return this.prisma.banner.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async reorder(items: { id: string; order: number }[]) {
|
||||
const updates = items.map((item) =>
|
||||
this.prisma.banner.update({
|
||||
where: { id: item.id },
|
||||
data: { order: item.order },
|
||||
}),
|
||||
);
|
||||
await this.prisma.$transaction(updates);
|
||||
return { success: true, message: 'Banners reordered successfully' };
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,6 @@ import {
|
||||
ApiResponse,
|
||||
ApiOkResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||
|
||||
@ -15,7 +16,7 @@ export class BlogsService {
|
||||
sortOrder = 'desc',
|
||||
} = filters;
|
||||
|
||||
const whereClause: any = { isPublished: true };
|
||||
const whereClause: Prisma.BlogWhereInput = { isPublished: true };
|
||||
if (search) {
|
||||
whereClause.OR = [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
|
||||
4
backend/src/common/decorators/roles.decorator.ts
Normal file
4
backend/src/common/decorators/roles.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@ -49,10 +49,15 @@ export class CustomHttpExceptionFilter implements ExceptionFilter {
|
||||
};
|
||||
});
|
||||
} else {
|
||||
const rawMsg = typeof resObj.message === 'string' ? resObj.message : exception.message;
|
||||
const rawMsg =
|
||||
typeof resObj.message === 'string'
|
||||
? resObj.message
|
||||
: exception.message;
|
||||
message = this.translateGenericMessage(rawMsg, status);
|
||||
const rawCode = typeof resObj.code === 'string' ? resObj.code : undefined;
|
||||
const rawError = typeof resObj.error === 'string' ? resObj.error : undefined;
|
||||
const rawCode =
|
||||
typeof resObj.code === 'string' ? resObj.code : undefined;
|
||||
const rawError =
|
||||
typeof resObj.error === 'string' ? resObj.error : undefined;
|
||||
code = rawCode || this.deriveErrorCode(status, rawError);
|
||||
details = (resObj.details as Record<string, unknown>) || {};
|
||||
}
|
||||
|
||||
71
backend/src/common/guards/roles.guard.spec.ts
Normal file
71
backend/src/common/guards/roles.guard.spec.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { RolesGuard } from './roles.guard';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
|
||||
describe('RolesGuard', () => {
|
||||
let guard: RolesGuard;
|
||||
let reflector: Reflector;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
RolesGuard,
|
||||
{
|
||||
provide: Reflector,
|
||||
useValue: {
|
||||
getAllAndOverride: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
guard = module.get<RolesGuard>(RolesGuard);
|
||||
reflector = module.get<Reflector>(Reflector);
|
||||
});
|
||||
|
||||
const createMockContext = (user?: any): ExecutionContext => {
|
||||
return {
|
||||
getHandler: jest.fn(),
|
||||
getClass: jest.fn(),
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ user }),
|
||||
}),
|
||||
} as any;
|
||||
};
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(guard).toBeDefined();
|
||||
});
|
||||
|
||||
it('should allow access if no roles are required', () => {
|
||||
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined);
|
||||
const context = createMockContext({ role: 'User_PetOwner' });
|
||||
expect(guard.canActivate(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException if user is missing', () => {
|
||||
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['Admin']);
|
||||
const context = createMockContext(undefined);
|
||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException if user has non-matching role', () => {
|
||||
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['Admin']);
|
||||
const context = createMockContext({ role: 'User_PetOwner' });
|
||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should allow access if user has matching role', () => {
|
||||
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['Admin']);
|
||||
const context = createMockContext({ role: 'Admin' });
|
||||
expect(guard.canActivate(context)).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow access if user has case-insensitive matching admin role', () => {
|
||||
jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(['Admin']);
|
||||
const context = createMockContext({ role: 'SUPER_ADMIN' });
|
||||
expect(guard.canActivate(context)).toBe(true);
|
||||
});
|
||||
});
|
||||
48
backend/src/common/guards/roles.guard.ts
Normal file
48
backend/src/common/guards/roles.guard.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
|
||||
interface RequestWithUser {
|
||||
user?: {
|
||||
role?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
|
||||
ROLES_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||
const user = request.user;
|
||||
if (!user || !user.role) {
|
||||
throw new ForbiddenException('شما دسترسی لازم برای این بخش را ندارید');
|
||||
}
|
||||
|
||||
const userRoleLower = user.role.toLowerCase();
|
||||
const hasRole = requiredRoles.some(
|
||||
(r) =>
|
||||
r.toLowerCase() === userRoleLower ||
|
||||
(userRoleLower.includes('admin') && r.toLowerCase().includes('admin')),
|
||||
);
|
||||
if (!hasRole) {
|
||||
throw new ForbiddenException('سطح دسترسی شما کافی نیست');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
67
backend/src/common/interceptors/decimal.interceptor.spec.ts
Normal file
67
backend/src/common/interceptors/decimal.interceptor.spec.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { DecimalInterceptor } from './decimal.interceptor';
|
||||
import { ExecutionContext, CallHandler } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
describe('DecimalInterceptor', () => {
|
||||
let interceptor: DecimalInterceptor;
|
||||
|
||||
beforeEach(() => {
|
||||
interceptor = new DecimalInterceptor();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(interceptor).toBeDefined();
|
||||
});
|
||||
|
||||
it('should transform Prisma.Decimal instances to exact string representations', (done) => {
|
||||
const mockData = {
|
||||
id: 'order-1',
|
||||
totalAmount: new Prisma.Decimal('64.98'),
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
unitPrice: new Prisma.Decimal('32.49'),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const executionContext = {} as ExecutionContext;
|
||||
const callHandler: CallHandler = {
|
||||
handle: () => of(mockData),
|
||||
};
|
||||
|
||||
interceptor.intercept(executionContext, callHandler).subscribe((result) => {
|
||||
expect(result).toEqual({
|
||||
id: 'order-1',
|
||||
totalAmount: '64.98',
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
unitPrice: '32.49',
|
||||
},
|
||||
],
|
||||
});
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should leave non-decimal values unchanged', (done) => {
|
||||
const mockData = {
|
||||
name: 'Test',
|
||||
age: 5,
|
||||
isActive: true,
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
const executionContext = {} as ExecutionContext;
|
||||
const callHandler: CallHandler = {
|
||||
handle: () => of(mockData),
|
||||
};
|
||||
|
||||
interceptor.intercept(executionContext, callHandler).subscribe((result) => {
|
||||
expect(result).toEqual(mockData);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
41
backend/src/common/interceptors/decimal.interceptor.ts
Normal file
41
backend/src/common/interceptors/decimal.interceptor.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class DecimalInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
return next.handle().pipe(map((data: unknown) => this.transform(data)));
|
||||
}
|
||||
|
||||
private transform(data: unknown): unknown {
|
||||
if (data === null || data === undefined) {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (Prisma.Decimal.isDecimal(data)) {
|
||||
return data.toString();
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item: unknown) => this.transform(item));
|
||||
}
|
||||
|
||||
if (typeof data === 'object' && !(data instanceof Date)) {
|
||||
const obj = data as Record<string, unknown>;
|
||||
const transformedObj: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(obj)) {
|
||||
transformedObj[key] = this.transform(obj[key]);
|
||||
}
|
||||
return transformedObj;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { Controller, Get, Res } from '@nestjs/common';
|
||||
import { ApiExcludeController } from '@nestjs/swagger';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import * as express from 'express';
|
||||
import type { Response } from 'express';
|
||||
|
||||
@ApiExcludeController()
|
||||
@Controller('metrics')
|
||||
@ -15,14 +15,14 @@ export class MetricsController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
async getMetrics(@Res() res: express.Response) {
|
||||
async getMetrics(@Res() res: Response) {
|
||||
const memory = process.memoryUsage();
|
||||
const cpu = process.cpuUsage();
|
||||
|
||||
let dbStatus = 1;
|
||||
try {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
dbStatus = 0;
|
||||
}
|
||||
|
||||
|
||||
@ -22,7 +22,7 @@ export class PaginatedResponse<T> {
|
||||
meta: PaginationMeta;
|
||||
}
|
||||
|
||||
export function createPaginatedSchema(dtoModel: any) {
|
||||
export function createPaginatedSchema(dtoModel: { name: string }) {
|
||||
return {
|
||||
schema: {
|
||||
allOf: [
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
|
||||
export interface SendPatternSmsOptions {
|
||||
@ -8,10 +7,15 @@ export interface SendPatternSmsOptions {
|
||||
args: string[]; // Dynamic variables inside pattern
|
||||
}
|
||||
|
||||
interface MeliPayamakResponse {
|
||||
Value?: number;
|
||||
RetStatus?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsService {
|
||||
private readonly logger = new Logger(SmsService.name);
|
||||
private readonly username = process.env.MELIPAYAMAK_USERNAME || '';
|
||||
private readonly username = process.env.MELIPAYAMAK_USERNAME || '9364100228';
|
||||
private readonly password = process.env.MELIPAYAMAK_PASSWORD || '';
|
||||
|
||||
/**
|
||||
@ -20,7 +24,7 @@ export class SmsService {
|
||||
async sendPatternSms(options: SendPatternSmsOptions): Promise<boolean> {
|
||||
if (!this.username || !this.password) {
|
||||
this.logger.warn(
|
||||
`[SMS Disabled] MeliPayamak credentials missing. Simulated dispatch to ${options.to} (Pattern: ${options.bodyId}, Args: ${options.args.join(', ')})`,
|
||||
`[SMS Simulated] MeliPayamak dispatch to ${options.to} (Pattern: ${options.bodyId}, Args: ${options.args.join(', ')})`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@ -48,15 +52,16 @@ export class SmsService {
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
if (json && (json.Value > 15 || json.RetStatus === 1)) {
|
||||
const json = JSON.parse(data) as MeliPayamakResponse;
|
||||
const val = json.Value ?? 0;
|
||||
if (json && (val > 15 || json.RetStatus === 1)) {
|
||||
this.logger.log(
|
||||
`[SMS Sent] Pattern SMS ${options.bodyId} dispatched to ${options.to} (RecId: ${json.Value})`,
|
||||
`[SMS Sent] Pattern SMS ${options.bodyId} dispatched to ${options.to} (RecId: ${val})`,
|
||||
);
|
||||
resolve(true);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`[SMS Error] Failed sending pattern SMS to ${options.to}. Code: ${json?.Value}`,
|
||||
`[SMS Error] Failed sending pattern SMS to ${options.to}. Code: ${val}`,
|
||||
);
|
||||
resolve(false);
|
||||
}
|
||||
@ -80,10 +85,13 @@ export class SmsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send OTP Verification Code
|
||||
* Send OTP Verification Code (Pattern 508079)
|
||||
*/
|
||||
async sendOtp(phone: string, otpCode: string): Promise<boolean> {
|
||||
const bodyId = parseInt(process.env.MELIPAYAMAK_OTP_BODY_ID || '0', 10);
|
||||
const bodyId = parseInt(
|
||||
process.env.MELIPAYAMAK_OTP_BODY_ID || '508079',
|
||||
10,
|
||||
);
|
||||
return this.sendPatternSms({
|
||||
to: phone,
|
||||
bodyId,
|
||||
@ -92,14 +100,17 @@ export class SmsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Order Confirmation SMS
|
||||
* Send Order Confirmation SMS (Pattern 508081)
|
||||
*/
|
||||
async sendOrderConfirmation(
|
||||
phone: string,
|
||||
orderNumber: string,
|
||||
amount: string,
|
||||
): Promise<boolean> {
|
||||
const bodyId = parseInt(process.env.MELIPAYAMAK_ORDER_BODY_ID || '0', 10);
|
||||
const bodyId = parseInt(
|
||||
process.env.MELIPAYAMAK_ORDER_BODY_ID || '508081',
|
||||
10,
|
||||
);
|
||||
return this.sendPatternSms({
|
||||
to: phone,
|
||||
bodyId,
|
||||
@ -108,7 +119,7 @@ export class SmsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Shipping Status SMS with Tracking Code
|
||||
* Send Shipping Status SMS with Tracking Code (Pattern 508082)
|
||||
*/
|
||||
async sendShippingNotification(
|
||||
phone: string,
|
||||
@ -116,7 +127,7 @@ export class SmsService {
|
||||
trackingCode: string,
|
||||
): Promise<boolean> {
|
||||
const bodyId = parseInt(
|
||||
process.env.MELIPAYAMAK_SHIPPING_BODY_ID || '0',
|
||||
process.env.MELIPAYAMAK_SHIPPING_BODY_ID || '508082',
|
||||
10,
|
||||
);
|
||||
return this.sendPatternSms({
|
||||
@ -126,6 +137,24 @@ export class SmsService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send B2B Application Notification SMS (Pattern 508083)
|
||||
*/
|
||||
async sendB2bNotification(
|
||||
phone: string,
|
||||
applicantName: string,
|
||||
): Promise<boolean> {
|
||||
const bodyId = parseInt(
|
||||
process.env.MELIPAYAMAK_B2B_BODY_ID || '508083',
|
||||
10,
|
||||
);
|
||||
return this.sendPatternSms({
|
||||
to: phone,
|
||||
bodyId,
|
||||
args: [applicantName],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Pet Care Vaccination / Deworming Reminder SMS
|
||||
*/
|
||||
@ -144,4 +173,12 @@ export class SmsService {
|
||||
args: [petName, reminderType],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic text SMS dispatch
|
||||
*/
|
||||
async sendSms(phone: string, message: string): Promise<boolean> {
|
||||
this.logger.log(`[SMS Text Sent] To: ${phone}, Content: ${message}`);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
}
|
||||
|
||||
85
backend/src/contact/contact.controller.ts
Normal file
85
backend/src/contact/contact.controller.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ContactService } from './contact.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
|
||||
@Controller('contact')
|
||||
export class ContactController {
|
||||
constructor(private readonly contactService: ContactService) {}
|
||||
|
||||
@Post()
|
||||
async submitContact(
|
||||
@Body()
|
||||
body: {
|
||||
name: string;
|
||||
phone: string;
|
||||
email?: string;
|
||||
subject?: string;
|
||||
message: string;
|
||||
},
|
||||
) {
|
||||
return this.contactService.submitContactForm(body);
|
||||
}
|
||||
|
||||
@Get('info')
|
||||
async getContactInfo() {
|
||||
return this.contactService.getContactInfo();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMIN', 'Admin', 'SUPERADMIN', 'SuperAdmin', 'ADMIN_EXPERT')
|
||||
@Get('submissions')
|
||||
async getAllSubmissions(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.contactService.getAllSubmissions(
|
||||
page ? parseInt(page, 10) : 1,
|
||||
limit ? parseInt(limit, 10) : 20,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMIN', 'Admin', 'SUPERADMIN', 'SuperAdmin', 'ADMIN_EXPERT')
|
||||
@Put('submissions/:id')
|
||||
async updateSubmissionStatus(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { status: string; adminNotes?: string },
|
||||
) {
|
||||
return this.contactService.updateSubmissionStatus(
|
||||
id,
|
||||
body.status,
|
||||
body.adminNotes,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('ADMIN', 'Admin', 'SUPERADMIN', 'SuperAdmin', 'ADMIN_EXPERT')
|
||||
@Put('info')
|
||||
async updateContactInfo(
|
||||
@Body()
|
||||
body: {
|
||||
items: Array<{
|
||||
key: string;
|
||||
title: string;
|
||||
value: string;
|
||||
icon?: string;
|
||||
order?: number;
|
||||
}>;
|
||||
},
|
||||
) {
|
||||
return this.contactService.updateContactInfoItems(body.items);
|
||||
}
|
||||
}
|
||||
12
backend/src/contact/contact.module.ts
Normal file
12
backend/src/contact/contact.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ContactController } from './contact.controller';
|
||||
import { ContactService } from './contact.service';
|
||||
import { SmsModule } from '../common/sms.module';
|
||||
|
||||
@Module({
|
||||
imports: [SmsModule],
|
||||
controllers: [ContactController],
|
||||
providers: [ContactService],
|
||||
exports: [ContactService],
|
||||
})
|
||||
export class ContactModule {}
|
||||
180
backend/src/contact/contact.service.ts
Normal file
180
backend/src/contact/contact.service.ts
Normal file
@ -0,0 +1,180 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsService } from '../common/services/sms.service';
|
||||
|
||||
export interface CreateContactSubmissionDto {
|
||||
name: string;
|
||||
phone: string;
|
||||
email?: string;
|
||||
subject?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface UpdateContactInfoItemDto {
|
||||
key: string;
|
||||
title: string;
|
||||
value: string;
|
||||
icon?: string;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContactService {
|
||||
private readonly logger = new Logger(ContactService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly smsService: SmsService,
|
||||
) {}
|
||||
|
||||
async submitContactForm(dto: CreateContactSubmissionDto) {
|
||||
const submission = await this.prisma.contactSubmission.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
phone: dto.phone,
|
||||
email: dto.email,
|
||||
subject: dto.subject,
|
||||
message: dto.message,
|
||||
},
|
||||
});
|
||||
|
||||
// Send SMS confirmation to User & notification to Admin
|
||||
try {
|
||||
const userPatternId = parseInt(
|
||||
process.env.MELIPAYAMAK_CONTACT_USER_BODY_ID || '508081',
|
||||
10,
|
||||
);
|
||||
await this.smsService.sendPatternSms({
|
||||
to: dto.phone,
|
||||
bodyId: userPatternId,
|
||||
args: [dto.name, 'فرم تماس'],
|
||||
});
|
||||
|
||||
const adminPhone = process.env.ADMIN_MOBILE || '09364100228';
|
||||
const adminPatternId = parseInt(
|
||||
process.env.MELIPAYAMAK_CONTACT_ADMIN_BODY_ID || '508083',
|
||||
10,
|
||||
);
|
||||
await this.smsService.sendPatternSms({
|
||||
to: adminPhone,
|
||||
bodyId: adminPatternId,
|
||||
args: [dto.name, dto.phone],
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`SMS trigger error on contact submission: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message:
|
||||
'پیام شما با موفقیت ثبت شد و بهزودی کارشناسان ما با شما تماس خواهند گرفت.',
|
||||
submissionId: submission.id,
|
||||
};
|
||||
}
|
||||
|
||||
async getContactInfo() {
|
||||
let items = await this.prisma.contactInfo.findMany({
|
||||
orderBy: { order: 'asc' },
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
// Seed default items if empty
|
||||
const defaultItems = [
|
||||
{
|
||||
key: 'branch_info',
|
||||
title: 'اطلاعات نمایندگی',
|
||||
value:
|
||||
'تلفنهای تماس\n۰۲۱-۸۸۸۸ ۴۴۴۴\n\nشنبه تا چهارشنبه ۹:۰۰ الی ۱۸:۰۰',
|
||||
icon: 'phone',
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
key: 'email_info',
|
||||
title: 'پست الکترونیک',
|
||||
value: 'info@canina-iran.com\n\nپاسخگویی در کمتر از ۲۴ ساعت کاری',
|
||||
icon: 'mail',
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
key: 'address_info',
|
||||
title: 'نشانی دفتر مرکزی',
|
||||
value: 'تهران، جردن، خیابان سعیدی، ساختمان کانینا، طبقه ۵، واحد ۱۹',
|
||||
icon: 'map-pin',
|
||||
order: 3,
|
||||
},
|
||||
];
|
||||
|
||||
for (const item of defaultItems) {
|
||||
await this.prisma.contactInfo.upsert({
|
||||
where: { key: item.key },
|
||||
update: {},
|
||||
create: item,
|
||||
});
|
||||
}
|
||||
|
||||
items = await this.prisma.contactInfo.findMany({
|
||||
orderBy: { order: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
async getAllSubmissions(page = 1, limit = 20, status?: string) {
|
||||
const skip = (page - 1) * limit;
|
||||
const where = status ? { status } : {};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.contactSubmission.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.contactSubmission.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async updateSubmissionStatus(
|
||||
id: string,
|
||||
status: string,
|
||||
adminNotes?: string,
|
||||
) {
|
||||
return this.prisma.contactSubmission.update({
|
||||
where: { id },
|
||||
data: { status, adminNotes },
|
||||
});
|
||||
}
|
||||
|
||||
async updateContactInfoItems(items: UpdateContactInfoItemDto[]) {
|
||||
for (const item of items) {
|
||||
await this.prisma.contactInfo.upsert({
|
||||
where: { key: item.key },
|
||||
update: {
|
||||
title: item.title,
|
||||
value: item.value,
|
||||
icon: item.icon,
|
||||
order: item.order ?? 0,
|
||||
},
|
||||
create: {
|
||||
key: item.key,
|
||||
title: item.title,
|
||||
value: item.value,
|
||||
icon: item.icon,
|
||||
order: item.order ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
return this.getContactInfo();
|
||||
}
|
||||
}
|
||||
61
backend/src/ingredients/ingredients.controller.ts
Normal file
61
backend/src/ingredients/ingredients.controller.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { IngredientsService } from './ingredients.service';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@ApiTags('Ingredients - دانشنامه ترکیبات و مواد موثره')
|
||||
@Controller('ingredients')
|
||||
export class IngredientsController {
|
||||
constructor(private readonly ingredientsService: IngredientsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'دریافت لیست ترکیبات فعال' })
|
||||
findAll() {
|
||||
return this.ingredientsService.findAll();
|
||||
}
|
||||
|
||||
@Get(':idOrSlug')
|
||||
@ApiOperation({ summary: 'دریافت اطلاعات یک ترکیب بر اساس آیدی یا اسلاگ' })
|
||||
findOne(@Param('idOrSlug') idOrSlug: string) {
|
||||
return this.ingredientsService.findOne(idOrSlug);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'ایجاد ترکیب جدید (نیازمند ادمین)' })
|
||||
create(@Body() body: Prisma.IngredientCreateInput) {
|
||||
return this.ingredientsService.create(body);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'ویرایش اطلاعات ترکیب (نیازمند ادمین)' })
|
||||
update(@Param('id') id: string, @Body() body: Prisma.IngredientUpdateInput) {
|
||||
return this.ingredientsService.update(id, body);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'حذف ترکیب (نیازمند ادمین)' })
|
||||
remove(@Param('id') id: string) {
|
||||
return this.ingredientsService.remove(id);
|
||||
}
|
||||
}
|
||||
12
backend/src/ingredients/ingredients.module.ts
Normal file
12
backend/src/ingredients/ingredients.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IngredientsController } from './ingredients.controller';
|
||||
import { IngredientsService } from './ingredients.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [IngredientsController],
|
||||
providers: [IngredientsService],
|
||||
exports: [IngredientsService],
|
||||
})
|
||||
export class IngredientsModule {}
|
||||
54
backend/src/ingredients/ingredients.service.ts
Normal file
54
backend/src/ingredients/ingredients.service.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class IngredientsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async findAll() {
|
||||
return this.prisma.ingredient.findMany({
|
||||
orderBy: { nameFa: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(idOrSlug: string) {
|
||||
const isUuid =
|
||||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(
|
||||
idOrSlug,
|
||||
);
|
||||
const ingredient = await this.prisma.ingredient.findFirst({
|
||||
where: isUuid ? { id: idOrSlug } : { slug: idOrSlug },
|
||||
});
|
||||
|
||||
if (!ingredient) {
|
||||
throw new NotFoundException(`Ingredient ${idOrSlug} not found`);
|
||||
}
|
||||
return ingredient;
|
||||
}
|
||||
|
||||
async create(data: Prisma.IngredientCreateInput) {
|
||||
return this.prisma.ingredient.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: Prisma.IngredientUpdateInput) {
|
||||
const item = await this.prisma.ingredient.findUnique({ where: { id } });
|
||||
if (!item) {
|
||||
throw new NotFoundException(`Ingredient with ID ${id} not found`);
|
||||
}
|
||||
return this.prisma.ingredient.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
const item = await this.prisma.ingredient.findUnique({ where: { id } });
|
||||
if (!item) {
|
||||
throw new NotFoundException(`Ingredient with ID ${id} not found`);
|
||||
}
|
||||
return this.prisma.ingredient.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@ -6,9 +6,37 @@ import { ValidationPipe, BadRequestException } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { CustomHttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { PrismaExceptionFilter } from './common/filters/prisma-exception.filter';
|
||||
import { DecimalInterceptor } from './common/interceptors/decimal.interceptor';
|
||||
import helmet from 'helmet';
|
||||
|
||||
async function bootstrap() {
|
||||
// Fallback defaults for development environment
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
process.env.JWT_ACCESS_SECRET =
|
||||
process.env.JWT_ACCESS_SECRET ||
|
||||
'dev_jwt_access_secret_32_characters_minimum_len';
|
||||
process.env.JWT_REFRESH_SECRET =
|
||||
process.env.JWT_REFRESH_SECRET ||
|
||||
'dev_jwt_refresh_secret_32_characters_minimum_len';
|
||||
}
|
||||
|
||||
const jwtAccessSecret = process.env.JWT_ACCESS_SECRET;
|
||||
const jwtRefreshSecret = process.env.JWT_REFRESH_SECRET;
|
||||
|
||||
if (!jwtAccessSecret || jwtAccessSecret.trim().length < 32) {
|
||||
console.error(
|
||||
'FATAL ERROR: JWT_ACCESS_SECRET is missing, empty, or less than 32 characters long.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!jwtRefreshSecret || jwtRefreshSecret.trim().length < 32) {
|
||||
console.error(
|
||||
'FATAL ERROR: JWT_REFRESH_SECRET is missing, empty, or less than 32 characters long.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
// Serve static uploads folder
|
||||
@ -63,6 +91,12 @@ async function bootstrap() {
|
||||
new PrismaExceptionFilter(),
|
||||
);
|
||||
|
||||
app.useGlobalInterceptors(new DecimalInterceptor());
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const enableSwagger = process.env.ENABLE_SWAGGER === 'true';
|
||||
|
||||
if (!isProduction || enableSwagger) {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Canina Iran API')
|
||||
.setDescription(
|
||||
@ -74,7 +108,14 @@ async function bootstrap() {
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
console.log('Swagger UI is ACTIVE on /api/docs');
|
||||
} else {
|
||||
console.log('Swagger UI is DISABLED for security (production environment)');
|
||||
}
|
||||
|
||||
await app.listen(process.env.PORT ?? 4001);
|
||||
}
|
||||
bootstrap();
|
||||
bootstrap().catch((err: unknown) => {
|
||||
console.error('Bootstrap error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@ -2,7 +2,6 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
@ -13,6 +12,7 @@ import {
|
||||
import { OrdersService } from './orders.service';
|
||||
import { CreateOrderDto } from './dto/create-order.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiBearerAuth,
|
||||
@ -22,7 +22,6 @@ import {
|
||||
ApiCreatedResponse,
|
||||
ApiBadRequestResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiBody,
|
||||
} from '@nestjs/swagger';
|
||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||
import { IsString, IsNumber, IsNotEmpty } from 'class-validator';
|
||||
@ -83,7 +82,10 @@ export class OrdersController {
|
||||
},
|
||||
},
|
||||
})
|
||||
create(@Req() req: any, @Body() createOrderDto: CreateOrderDto) {
|
||||
create(
|
||||
@Req() req: { user: { id: string } },
|
||||
@Body() createOrderDto: CreateOrderDto,
|
||||
) {
|
||||
return this.ordersService.create(req.user.id, createOrderDto);
|
||||
}
|
||||
|
||||
@ -105,10 +107,13 @@ export class OrdersController {
|
||||
@ApiBadRequestResponse({
|
||||
description: 'کد تخفیف نامعتبر، منقضی، یا شرایط آن برقرار نیست',
|
||||
})
|
||||
validateCoupon(@Req() req: any, @Body() body: ValidateCouponDto) {
|
||||
validateCoupon(
|
||||
@Req() req: { user: { id: string } },
|
||||
@Body() body: ValidateCouponDto,
|
||||
) {
|
||||
return this.ordersService.validateCoupon(
|
||||
body.code,
|
||||
body.cartTotal,
|
||||
new Prisma.Decimal(body.cartTotal),
|
||||
req.user.id,
|
||||
);
|
||||
}
|
||||
@ -138,7 +143,7 @@ export class OrdersController {
|
||||
},
|
||||
},
|
||||
})
|
||||
findAll(@Req() req: any, @Query() query: PaginationDto) {
|
||||
findAll(@Req() req: { user: { id: string } }, @Query() query: PaginationDto) {
|
||||
return this.ordersService.findAllByUser(req.user.id, query);
|
||||
}
|
||||
|
||||
@ -181,7 +186,7 @@ export class OrdersController {
|
||||
},
|
||||
},
|
||||
})
|
||||
findOne(@Req() req: any, @Param('id') id: string) {
|
||||
findOne(@Req() req: { user: { id: string } }, @Param('id') id: string) {
|
||||
return this.ordersService.findOne(id, req.user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { OrdersService } from './orders.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsService } from '../common/services/sms.service';
|
||||
import { NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
describe('OrdersService', () => {
|
||||
let service: OrdersService;
|
||||
@ -10,6 +12,7 @@ describe('OrdersService', () => {
|
||||
const mockPrisma = {
|
||||
product: {
|
||||
findUnique: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
order: {
|
||||
create: jest.fn(),
|
||||
@ -17,6 +20,17 @@ describe('OrdersService', () => {
|
||||
findFirst: jest.fn(),
|
||||
count: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
walletTransaction: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockSmsService = {
|
||||
sendOrderConfirmation: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@ -24,6 +38,7 @@ describe('OrdersService', () => {
|
||||
providers: [
|
||||
OrdersService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: SmsService, useValue: mockSmsService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@ -40,14 +55,6 @@ describe('OrdersService', () => {
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should throw NotFoundException if product does not exist', async () => {
|
||||
mockPrisma.product.findUnique.mockResolvedValue(null);
|
||||
const dto = { items: [{ productId: 'invalid-prod', quantity: 2 }] };
|
||||
await expect(service.create('user-id', dto)).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException if items are empty', async () => {
|
||||
const dto = { items: [] };
|
||||
await expect(service.create('user-id', dto)).rejects.toThrow(
|
||||
@ -55,35 +62,68 @@ describe('OrdersService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should successfully create order and sum amounts', async () => {
|
||||
const prod = { id: 'prod-1', priceValue: 1000 };
|
||||
mockPrisma.product.findUnique.mockResolvedValue(prod);
|
||||
it('should throw BadRequestException for duplicate product IDs in order items', async () => {
|
||||
const dto = {
|
||||
items: [
|
||||
{ productId: 'prod-1', quantity: 1 },
|
||||
{ productId: 'prod-1', quantity: 2 },
|
||||
],
|
||||
};
|
||||
await expect(service.create('user-id', dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException for non-positive or non-integer quantities', async () => {
|
||||
const dtoZero = { items: [{ productId: 'prod-1', quantity: 0 }] };
|
||||
await expect(service.create('user-id', dtoZero)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
const dtoFloat = { items: [{ productId: 'prod-1', quantity: 1.5 }] };
|
||||
await expect(service.create('user-id', dtoFloat)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if any product is not found in batched query', async () => {
|
||||
mockPrisma.product.findMany.mockResolvedValue([
|
||||
{ id: 'prod-1', priceValue: new Prisma.Decimal('1000') },
|
||||
]);
|
||||
const dto = {
|
||||
items: [
|
||||
{ productId: 'prod-1', quantity: 1 },
|
||||
{ productId: 'prod-missing', quantity: 2 },
|
||||
],
|
||||
};
|
||||
await expect(service.create('user-id', dto)).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should successfully create order using batched findMany and precise Decimal calculations', async () => {
|
||||
const products = [
|
||||
{ id: 'prod-1', priceValue: new Prisma.Decimal('32.49') },
|
||||
{ id: 'prod-2', priceValue: new Prisma.Decimal('15.50') },
|
||||
];
|
||||
mockPrisma.product.findMany.mockResolvedValue(products);
|
||||
mockPrisma.order.create.mockResolvedValue({
|
||||
id: 'order-1',
|
||||
totalAmount: 2000,
|
||||
totalAmount: new Prisma.Decimal('80.48'),
|
||||
});
|
||||
|
||||
const dto = { items: [{ productId: 'prod-1', quantity: 2 }] };
|
||||
const dto = {
|
||||
items: [
|
||||
{ productId: 'prod-1', quantity: 2 }, // 32.49 * 2 = 64.98
|
||||
{ productId: 'prod-2', quantity: 1 }, // 15.50 * 1 = 15.50 Total = 80.48
|
||||
],
|
||||
};
|
||||
const result = await service.create('user-id', dto);
|
||||
|
||||
expect(prisma.product.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'prod-1' },
|
||||
});
|
||||
expect(prisma.order.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: 'user-id',
|
||||
totalAmount: 2000,
|
||||
status: 'processing',
|
||||
orderItems: {
|
||||
create: [{ productId: 'prod-1', quantity: 2 }],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
orderItems: {
|
||||
include: { product: true },
|
||||
},
|
||||
},
|
||||
expect(prisma.product.findMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: ['prod-1', 'prod-2'] } },
|
||||
});
|
||||
expect(prisma.order.create).toHaveBeenCalled();
|
||||
expect(result.id).toBe('order-1');
|
||||
});
|
||||
});
|
||||
|
||||
@ -7,6 +7,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||
import { CreateOrderDto } from './dto/create-order.dto';
|
||||
import { SmsService } from '../common/services/sms.service';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
@ -22,7 +23,11 @@ export class OrdersService {
|
||||
return `CN-${dateStr}-${random}`;
|
||||
}
|
||||
|
||||
async validateCoupon(code: string, cartTotal: number, userId: string) {
|
||||
async validateCoupon(
|
||||
code: string,
|
||||
cartTotal: Prisma.Decimal,
|
||||
userId: string,
|
||||
) {
|
||||
const coupon = await this.prisma.coupon.findUnique({
|
||||
where: { code: code.toUpperCase().trim() },
|
||||
include: { targets: true },
|
||||
@ -49,7 +54,7 @@ export class OrdersService {
|
||||
});
|
||||
}
|
||||
|
||||
if (coupon.minCartValue && cartTotal < Number(coupon.minCartValue)) {
|
||||
if (coupon.minCartValue && cartTotal.lessThan(coupon.minCartValue)) {
|
||||
throw new BadRequestException({
|
||||
message: `حداقل مبلغ سبد خرید برای استفاده از این کد ${Number(coupon.minCartValue).toLocaleString('fa-IR')} تومان است`,
|
||||
error: 'COUPON_MIN_CART',
|
||||
@ -68,20 +73,27 @@ export class OrdersService {
|
||||
});
|
||||
}
|
||||
|
||||
let discountAmount = 0;
|
||||
let discountAmount = new Prisma.Decimal(0);
|
||||
if (coupon.type === 'percent' || coupon.type === 'PERCENTAGE') {
|
||||
discountAmount = (cartTotal * Number(coupon.value)) / 100;
|
||||
if (coupon.maxCartValue && discountAmount > Number(coupon.maxCartValue)) {
|
||||
discountAmount = Number(coupon.maxCartValue);
|
||||
discountAmount = cartTotal.mul(coupon.value).div(100);
|
||||
if (
|
||||
coupon.maxCartValue &&
|
||||
discountAmount.greaterThan(coupon.maxCartValue)
|
||||
) {
|
||||
discountAmount = new Prisma.Decimal(coupon.maxCartValue);
|
||||
}
|
||||
} else {
|
||||
discountAmount = Number(coupon.value);
|
||||
discountAmount = new Prisma.Decimal(coupon.value);
|
||||
}
|
||||
|
||||
const finalDiscount = discountAmount.greaterThan(cartTotal)
|
||||
? cartTotal
|
||||
: discountAmount;
|
||||
|
||||
return {
|
||||
couponId: coupon.id,
|
||||
code: coupon.code,
|
||||
discountAmount: Math.min(discountAmount, cartTotal),
|
||||
discountAmount: finalDiscount,
|
||||
};
|
||||
}
|
||||
|
||||
@ -90,9 +102,36 @@ export class OrdersService {
|
||||
throw new BadRequestException('سبد خرید خالی است');
|
||||
}
|
||||
|
||||
// Verify stock and build items
|
||||
let cartTotal = 0;
|
||||
const orderItems: Array<{
|
||||
// Validate quantities and duplicate product IDs
|
||||
const productIds = createOrderDto.items.map((item) => item.productId);
|
||||
const uniqueProductIds = new Set(productIds);
|
||||
if (uniqueProductIds.size !== productIds.length) {
|
||||
throw new BadRequestException('تکرار محصول در سبد خرید مجاز نیست');
|
||||
}
|
||||
|
||||
for (const item of createOrderDto.items) {
|
||||
if (item.quantity <= 0 || !Number.isInteger(item.quantity)) {
|
||||
throw new BadRequestException(
|
||||
`تعداد محصول ${item.productId} نامعتبر است`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Batch fetch products using single findMany query
|
||||
const products = await this.prisma.product.findMany({
|
||||
where: { id: { in: productIds } },
|
||||
});
|
||||
|
||||
const productMap = new Map(products.map((p) => [p.id, p]));
|
||||
|
||||
for (const productId of productIds) {
|
||||
if (!productMap.has(productId)) {
|
||||
throw new NotFoundException(`محصول یافت نشد`);
|
||||
}
|
||||
}
|
||||
|
||||
let cartTotal = new Prisma.Decimal(0);
|
||||
const orderItemsData: Array<{
|
||||
productId: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
@ -100,24 +139,20 @@ export class OrdersService {
|
||||
}> = [];
|
||||
|
||||
for (const item of createOrderDto.items) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id: item.productId },
|
||||
});
|
||||
if (!product) {
|
||||
throw new NotFoundException(`محصول یافت نشد`);
|
||||
}
|
||||
const itemPrice = Number(product.priceValue);
|
||||
const totalItemPrice = itemPrice * item.quantity;
|
||||
cartTotal += totalItemPrice;
|
||||
orderItems.push({
|
||||
const product = productMap.get(item.productId)!;
|
||||
const unitPrice = new Prisma.Decimal(product.priceValue);
|
||||
const totalItemPrice = unitPrice.mul(item.quantity);
|
||||
cartTotal = cartTotal.add(totalItemPrice);
|
||||
|
||||
orderItemsData.push({
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
unitPrice: itemPrice,
|
||||
totalPrice: totalItemPrice,
|
||||
unitPrice: Number(unitPrice),
|
||||
totalPrice: Number(totalItemPrice),
|
||||
});
|
||||
}
|
||||
|
||||
let discountAmount = 0;
|
||||
let discountAmount = new Prisma.Decimal(0);
|
||||
let couponId: string | undefined = undefined;
|
||||
|
||||
if (createOrderDto.couponCode) {
|
||||
@ -130,54 +165,95 @@ export class OrdersService {
|
||||
couponId = couponResult.couponId;
|
||||
}
|
||||
|
||||
const charityAmount = createOrderDto.charityDonation || 0;
|
||||
const finalAmount = Math.max(0, cartTotal - discountAmount) + charityAmount;
|
||||
const charityAmount = new Prisma.Decimal(
|
||||
createOrderDto.charityDonation || 0,
|
||||
);
|
||||
const totalAfterDiscount = cartTotal.sub(discountAmount);
|
||||
const finalAmount = (
|
||||
totalAfterDiscount.lessThan(0)
|
||||
? new Prisma.Decimal(0)
|
||||
: totalAfterDiscount
|
||||
).add(charityAmount);
|
||||
|
||||
const trackingNumber = this.generateTrackingNumber();
|
||||
|
||||
// Deduct user wallet balance if payment method is wallet
|
||||
// Deduct user wallet balance and create order atomically
|
||||
if (createOrderDto.paymentMethod === 'wallet' && userId) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('کاربر یافت نشد');
|
||||
}
|
||||
const userBalance = Number(user.walletBalance || 0);
|
||||
if (userBalance < finalAmount) {
|
||||
const userBalance = new Prisma.Decimal(user.walletBalance || 0);
|
||||
if (userBalance.lessThan(finalAmount)) {
|
||||
throw new BadRequestException(
|
||||
'موجودی کیف پول برای پرداخت این سفارش کافی نیست',
|
||||
);
|
||||
}
|
||||
await this.prisma.user.update({
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
walletBalance: { decrement: finalAmount },
|
||||
walletBalance: { decrement: Number(finalAmount) },
|
||||
},
|
||||
});
|
||||
await this.prisma.walletTransaction.create({
|
||||
|
||||
await tx.walletTransaction.create({
|
||||
data: {
|
||||
userId,
|
||||
amount: finalAmount,
|
||||
amount: Number(finalAmount),
|
||||
type: 'withdrawal',
|
||||
status: 'completed',
|
||||
description: `پرداخت سفارش ${trackingNumber}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (charityAmount.greaterThan(0)) {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
charityDonationTotal: { increment: Number(charityAmount) },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return tx.order.create({
|
||||
data: {
|
||||
userId,
|
||||
couponId,
|
||||
totalAmount: Number(finalAmount),
|
||||
charityDonation: Number(charityAmount),
|
||||
isRefill: Boolean(createOrderDto.isRefill),
|
||||
refillIntervalDays: createOrderDto.refillIntervalDays || 60,
|
||||
trackingNumber,
|
||||
status: 'processing',
|
||||
orderItems: {
|
||||
create: orderItemsData,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
orderItems: {
|
||||
include: { product: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const createdOrder = await this.prisma.order.create({
|
||||
data: {
|
||||
userId,
|
||||
couponId,
|
||||
totalAmount: finalAmount,
|
||||
charityDonation: charityAmount,
|
||||
totalAmount: Number(finalAmount),
|
||||
charityDonation: Number(charityAmount),
|
||||
isRefill: Boolean(createOrderDto.isRefill),
|
||||
refillIntervalDays: createOrderDto.refillIntervalDays || 60,
|
||||
trackingNumber,
|
||||
status: 'processing',
|
||||
orderItems: {
|
||||
create: orderItems,
|
||||
create: orderItemsData,
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
include: {
|
||||
orderItems: {
|
||||
include: { product: true },
|
||||
@ -187,20 +263,18 @@ export class OrdersService {
|
||||
|
||||
// Send Order Confirmation SMS
|
||||
if (userId) {
|
||||
this.prisma.user
|
||||
.findUnique({ where: { id: userId } })
|
||||
.then((user) => {
|
||||
if (user && user.mobile) {
|
||||
this.smsService
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
if (user?.mobile) {
|
||||
await this.smsService
|
||||
.sendOrderConfirmation(
|
||||
user.mobile,
|
||||
trackingNumber,
|
||||
finalAmount.toLocaleString('fa-IR'),
|
||||
Number(finalAmount).toLocaleString('fa-IR'),
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
return createdOrder;
|
||||
@ -283,4 +357,31 @@ export class OrdersService {
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
async checkAndSendRefillReminders() {
|
||||
const fiftyEightDaysAgo = new Date(Date.now() - 58 * 24 * 60 * 60 * 1000);
|
||||
const fiftyNineDaysAgo = new Date(Date.now() - 59 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const dueRefillOrders = await this.prisma.order.findMany({
|
||||
where: {
|
||||
isRefill: true,
|
||||
createdAt: {
|
||||
gte: fiftyNineDaysAgo,
|
||||
lte: fiftyEightDaysAgo,
|
||||
},
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
for (const order of dueRefillOrders) {
|
||||
if (order.user?.mobile) {
|
||||
await this.smsService
|
||||
.sendSms(
|
||||
order.user.mobile,
|
||||
`کاربر گرامی، مکمل درمانی پت شما در حال اتمام است. جهت شارژ مجدد و استفاده از ۵٪ تخفیف دوره جدید به لینک زیر مراجعه کنید:\nhttps://canina-iran.com/shop`,
|
||||
)
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ describe('PetsController', () => {
|
||||
create: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy' }),
|
||||
findAllByUser: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'pet-id', name: 'Buddy' }]),
|
||||
.mockResolvedValue({ data: [{ id: 'pet-id', name: 'Buddy' }] }),
|
||||
findOne: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy' }),
|
||||
update: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy New' }),
|
||||
remove: jest.fn().mockResolvedValue({ success: true }),
|
||||
|
||||
@ -78,7 +78,10 @@ export class PetsController {
|
||||
},
|
||||
},
|
||||
})
|
||||
create(@Req() req: any, @Body() createPetDto: CreatePetDto) {
|
||||
create(
|
||||
@Req() req: { user: { id: string } },
|
||||
@Body() createPetDto: CreatePetDto,
|
||||
) {
|
||||
return this.petsService.create(req.user.id, createPetDto);
|
||||
}
|
||||
|
||||
@ -111,7 +114,7 @@ export class PetsController {
|
||||
},
|
||||
},
|
||||
})
|
||||
findAll(@Req() req: any, @Query() query: PaginationDto) {
|
||||
findAll(@Req() req: { user: { id: string } }, @Query() query: PaginationDto) {
|
||||
return this.petsService.findAllByUser(req.user.id, query);
|
||||
}
|
||||
|
||||
@ -148,7 +151,7 @@ export class PetsController {
|
||||
},
|
||||
},
|
||||
})
|
||||
findOne(@Req() req: any, @Param('id') id: string) {
|
||||
findOne(@Req() req: { user: { id: string } }, @Param('id') id: string) {
|
||||
return this.petsService.findOne(id, req.user.id);
|
||||
}
|
||||
|
||||
@ -180,7 +183,7 @@ export class PetsController {
|
||||
},
|
||||
})
|
||||
update(
|
||||
@Req() req: any,
|
||||
@Req() req: { user: { id: string } },
|
||||
@Param('id') id: string,
|
||||
@Body() updatePetDto: UpdatePetDto,
|
||||
) {
|
||||
@ -209,14 +212,14 @@ export class PetsController {
|
||||
},
|
||||
},
|
||||
})
|
||||
remove(@Req() req: any, @Param('id') id: string) {
|
||||
remove(@Req() req: { user: { id: string } }, @Param('id') id: string) {
|
||||
return this.petsService.remove(id, req.user.id);
|
||||
}
|
||||
|
||||
@Post(':petId/reminders')
|
||||
@ApiOperation({ summary: 'ثبت یادآور جدید برای پت' })
|
||||
addReminder(
|
||||
@Req() req: any,
|
||||
@Req() req: { user: { id: string } },
|
||||
@Param('petId') petId: string,
|
||||
@Body() createReminderDto: CreateReminderDto,
|
||||
) {
|
||||
@ -226,7 +229,7 @@ export class PetsController {
|
||||
@Post(':petId/reminders/:reminderId/toggle')
|
||||
@ApiOperation({ summary: 'تغییر وضعیت انجام یادآور در یک تاریخ خاص' })
|
||||
toggleReminder(
|
||||
@Req() req: any,
|
||||
@Req() req: { user: { id: string } },
|
||||
@Param('petId') petId: string,
|
||||
@Param('reminderId') reminderId: string,
|
||||
@Body('date') date: string,
|
||||
@ -242,7 +245,7 @@ export class PetsController {
|
||||
@Post(':petId/health-logs')
|
||||
@ApiOperation({ summary: 'ثبت لاگ سلامت جدید برای پت' })
|
||||
addHealthLog(
|
||||
@Req() req: any,
|
||||
@Req() req: { user: { id: string } },
|
||||
@Param('petId') petId: string,
|
||||
@Body() createHealthLogDto: CreateHealthLogDto,
|
||||
) {
|
||||
|
||||
@ -1,9 +1,24 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||
import { CreatePetDto } from './dto/create-pet.dto';
|
||||
import { UpdatePetDto } from './dto/update-pet.dto';
|
||||
|
||||
export interface ReminderInput {
|
||||
productId?: string | null;
|
||||
title: string;
|
||||
time: string;
|
||||
frequency: string;
|
||||
}
|
||||
|
||||
export interface HealthLogInput {
|
||||
appetite: string;
|
||||
energy: string;
|
||||
digestion: string;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PetsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
@ -31,6 +46,66 @@ export class PetsService {
|
||||
});
|
||||
}
|
||||
|
||||
async findAllAdmin(filters: PaginationDto) {
|
||||
const {
|
||||
search,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
sortBy = 'createdAt',
|
||||
sortOrder = 'desc',
|
||||
} = filters;
|
||||
|
||||
const whereClause: Prisma.PetWhereInput = {};
|
||||
if (search) {
|
||||
whereClause.OR = [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ breed: { contains: search, mode: 'insensitive' } },
|
||||
{ user: { firstName: { contains: search, mode: 'insensitive' } } },
|
||||
{ user: { lastName: { contains: search, mode: 'insensitive' } } },
|
||||
{ user: { mobile: { contains: search } } },
|
||||
];
|
||||
}
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.pet.findMany({
|
||||
where: whereClause,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { [sortBy]: sortOrder },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
mobile: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
medicalConditions: true,
|
||||
reminders: true,
|
||||
healthLogs: {
|
||||
orderBy: { loggedDate: 'desc' },
|
||||
take: 10,
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.pet.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
lastPage: Math.ceil(total / limit),
|
||||
limit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async findAllByUser(userId: string, filters: PaginationDto) {
|
||||
const {
|
||||
search,
|
||||
@ -40,7 +115,7 @@ export class PetsService {
|
||||
sortOrder = 'desc',
|
||||
} = filters;
|
||||
|
||||
const whereClause: any = { userId };
|
||||
const whereClause: Prisma.PetWhereInput = { userId };
|
||||
if (search) {
|
||||
whereClause.OR = [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
@ -56,6 +131,7 @@ export class PetsService {
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { [sortBy]: sortOrder },
|
||||
include: { medicalConditions: true, reminders: true, healthLogs: true },
|
||||
}),
|
||||
this.prisma.pet.count({ where: whereClause }),
|
||||
]);
|
||||
@ -83,7 +159,7 @@ export class PetsService {
|
||||
}
|
||||
|
||||
async update(id: string, userId: string, updatePetDto: UpdatePetDto) {
|
||||
await this.findOne(id, userId); // Ensure it exists and belongs to user
|
||||
await this.findOne(id, userId);
|
||||
|
||||
if (updatePetDto.medicalConditions) {
|
||||
await this.prisma.petMedicalCondition.deleteMany({
|
||||
@ -119,7 +195,7 @@ export class PetsService {
|
||||
});
|
||||
}
|
||||
|
||||
async addReminder(userId: string, petId: string, data: any) {
|
||||
async addReminder(userId: string, petId: string, data: ReminderInput) {
|
||||
await this.findOne(petId, userId);
|
||||
return this.prisma.reminder.create({
|
||||
data: {
|
||||
@ -181,7 +257,7 @@ export class PetsService {
|
||||
}
|
||||
}
|
||||
|
||||
async addHealthLog(userId: string, petId: string, data: any) {
|
||||
async addHealthLog(userId: string, petId: string, data: HealthLogInput) {
|
||||
await this.findOne(petId, userId);
|
||||
return this.prisma.healthLog.create({
|
||||
data: {
|
||||
|
||||
78
backend/src/prescriptions/prescriptions.controller.ts
Normal file
78
backend/src/prescriptions/prescriptions.controller.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { PrescriptionsService } from './prescriptions.service';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
export interface UserReqPayload {
|
||||
user: {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
role?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@ApiTags('Prescriptions - نسخه و تاییدیه دارویی')
|
||||
@Controller('prescriptions')
|
||||
export class PrescriptionsController {
|
||||
constructor(private readonly prescriptionsService: PrescriptionsService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'بارگذاری نسخه جدید توسط کاربر' })
|
||||
create(
|
||||
@Req() req: UserReqPayload,
|
||||
@Body()
|
||||
body: {
|
||||
petId?: string;
|
||||
petName?: string;
|
||||
phone?: string;
|
||||
fileUrl: string;
|
||||
notes?: string;
|
||||
},
|
||||
) {
|
||||
const userId = req.user?.id || req.user?.userId || null;
|
||||
return this.prescriptionsService.create(userId, body);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'دریافت لیست نسخهها (کاربر یا ادمین)' })
|
||||
findAll(@Req() req: UserReqPayload) {
|
||||
const userId = req.user.id || req.user.userId || '';
|
||||
const isAdmin = req.user.role === 'Admin' || req.user.role === 'ADMIN';
|
||||
return this.prescriptionsService.findAll(userId, isAdmin);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'دریافت جزئیات یک نسخه' })
|
||||
findOne(@Req() req: UserReqPayload, @Param('id') id: string) {
|
||||
const userId = req.user.id || req.user.userId || '';
|
||||
const isAdmin = req.user.role === 'Admin' || req.user.role === 'ADMIN';
|
||||
return this.prescriptionsService.findOne(id, userId, isAdmin);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin', 'ADMIN')
|
||||
@Patch(':id/review')
|
||||
@ApiOperation({ summary: 'بررسی و تایید/رد نسخه دارویی (نیازمند ادمین)' })
|
||||
review(
|
||||
@Param('id') id: string,
|
||||
@Body()
|
||||
body: { status: 'APPROVED' | 'REJECTED' | 'PENDING'; adminNotes?: string },
|
||||
) {
|
||||
return this.prescriptionsService.review(id, body);
|
||||
}
|
||||
}
|
||||
12
backend/src/prescriptions/prescriptions.module.ts
Normal file
12
backend/src/prescriptions/prescriptions.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrescriptionsController } from './prescriptions.controller';
|
||||
import { PrescriptionsService } from './prescriptions.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [PrescriptionsController],
|
||||
providers: [PrescriptionsService],
|
||||
exports: [PrescriptionsService],
|
||||
})
|
||||
export class PrescriptionsModule {}
|
||||
135
backend/src/prescriptions/prescriptions.service.ts
Normal file
135
backend/src/prescriptions/prescriptions.service.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class PrescriptionsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async create(
|
||||
userId: string | null,
|
||||
data: {
|
||||
petId?: string;
|
||||
petName?: string;
|
||||
phone?: string;
|
||||
fileUrl: string;
|
||||
notes?: string;
|
||||
},
|
||||
) {
|
||||
let finalUserId = userId;
|
||||
let finalPetId = data.petId || null;
|
||||
|
||||
// 1. If phone is provided, find or create user automatically
|
||||
if (data.phone) {
|
||||
const cleanPhone = data.phone.trim();
|
||||
let user = await this.prisma.user.findFirst({
|
||||
where: { mobile: cleanPhone },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
// Create user with default role Customer
|
||||
user = await this.prisma.user.create({
|
||||
data: {
|
||||
mobile: cleanPhone,
|
||||
role: 'Customer',
|
||||
firstName: data.petName ? `سرپرست ${data.petName}` : 'کاربر',
|
||||
lastName: 'کانینا',
|
||||
},
|
||||
});
|
||||
}
|
||||
finalUserId = user.id;
|
||||
|
||||
// 2. If petName is provided and user exists, find or create pet
|
||||
if (data.petName && !finalPetId) {
|
||||
const cleanPetName = data.petName.trim();
|
||||
let pet = await this.prisma.pet.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
name: cleanPetName,
|
||||
},
|
||||
});
|
||||
|
||||
if (!pet) {
|
||||
pet = await this.prisma.pet.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: cleanPetName,
|
||||
type: 'سگ', // Default pet type for prescription consultation
|
||||
breed: 'نامشخص',
|
||||
age: 1,
|
||||
weight: 5.0,
|
||||
activityLevel: 'متوسط',
|
||||
},
|
||||
});
|
||||
}
|
||||
finalPetId = pet.id;
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.prescription.create({
|
||||
data: {
|
||||
userId: finalUserId || undefined,
|
||||
petId: finalPetId || undefined,
|
||||
fileUrl: data.fileUrl,
|
||||
notes: data.notes,
|
||||
status: 'PENDING',
|
||||
},
|
||||
include: { user: true, pet: true },
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(userId: string, isAdmin: boolean) {
|
||||
if (isAdmin) {
|
||||
return this.prisma.prescription.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { user: true, pet: true },
|
||||
});
|
||||
}
|
||||
return this.prisma.prescription.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { pet: true },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string, userId: string, isAdmin: boolean) {
|
||||
const item = await this.prisma.prescription.findUnique({
|
||||
where: { id },
|
||||
include: { user: true, pet: true },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
throw new NotFoundException(`Prescription with ID ${id} not found`);
|
||||
}
|
||||
|
||||
if (!isAdmin && item.userId !== userId) {
|
||||
throw new ForbiddenException('Access denied to this prescription');
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
async review(
|
||||
id: string,
|
||||
reviewData: {
|
||||
status: 'APPROVED' | 'REJECTED' | 'PENDING';
|
||||
adminNotes?: string;
|
||||
},
|
||||
) {
|
||||
const item = await this.prisma.prescription.findUnique({ where: { id } });
|
||||
if (!item) {
|
||||
throw new NotFoundException(`Prescription with ID ${id} not found`);
|
||||
}
|
||||
return this.prisma.prescription.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: reviewData.status,
|
||||
adminNotes: reviewData.adminNotes,
|
||||
},
|
||||
include: { user: true, pet: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,11 @@ export class GetProductsDto extends PaginationDto {
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'فیلتر بر اساس آیدی دستهبندی' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'فیلتر بر اساس نوع حیوان',
|
||||
enum: ['سگ', 'گربه', 'all'],
|
||||
@ -25,4 +30,14 @@ export class GetProductsDto extends PaginationDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
requiresRx?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'حداقل قیمت (تومان)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
minPrice?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'حداکثر قیمت (تومان)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
maxPrice?: string;
|
||||
}
|
||||
|
||||
@ -1,76 +1,33 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Patch,
|
||||
Body,
|
||||
Query,
|
||||
Param,
|
||||
NotFoundException,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { ProductsService } from './products.service';
|
||||
import { GetProductsDto } from './dto/get-products.dto';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiOkResponse,
|
||||
ApiNotFoundResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@ApiTags('Products - مدیریت محصولات دارویی')
|
||||
@Controller('products')
|
||||
@ApiResponse({
|
||||
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
description: 'خطای داخلی سرور',
|
||||
schema: {
|
||||
example: {
|
||||
success: false,
|
||||
message: 'خطای داخلی سرور',
|
||||
code: 'SERVER_ERROR',
|
||||
details: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
export class ProductsController {
|
||||
constructor(private readonly productsService: ProductsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'لیست و فیلتر محصولات' })
|
||||
@ApiOkResponse({
|
||||
description: 'لیست محصولات متناسب با فیلترها (دسته، پت و جستجو)',
|
||||
schema: {
|
||||
example: {
|
||||
data: [
|
||||
{
|
||||
id: 'a1b2c3d4-1234-5678-abcd-ef1234567890',
|
||||
artNo: 'canhydrox-gag',
|
||||
name: 'Canhydrox GAG (کنهیدروکس)',
|
||||
scientificTagline: 'برای تقویت مفاصل و استخوانها',
|
||||
description:
|
||||
'کنهیدروکس محصولی بینظیر برای مفاصل و سیستم حرکتی سگها...',
|
||||
shortDescription: 'تقویت مفاصل و غضروفها',
|
||||
category: 'سیستم حرکتی و مفاصل',
|
||||
categorySlug: 'joints',
|
||||
priceValue: '1750000.00',
|
||||
priceDisplay: '۱,۷۵۰,۰۰۰ تومان',
|
||||
unit: 'عدد قرص',
|
||||
packageSize: '120.00',
|
||||
dosageLogic: 'یک قرص به ازای هر ۱۰ کیلوگرم وزن بدن سگ',
|
||||
suitableFor: 'سگ',
|
||||
imageUrl: 'https://example.com/canhydrox.png',
|
||||
createdAt: '2026-05-26T18:10:00.000Z',
|
||||
ingredients: [],
|
||||
symptoms: [],
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
total: 1,
|
||||
page: 1,
|
||||
lastPage: 1,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
findAll(@Query() query: GetProductsDto) {
|
||||
return this.productsService.findAll(query);
|
||||
}
|
||||
@ -79,16 +36,6 @@ export class ProductsController {
|
||||
@ApiOperation({
|
||||
summary: 'دریافت فیلترهای فعال محصولات (دسته، علائم، نوع پت)',
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: 'لیست فیلترهای پویا استخراج شده از دیتابیس',
|
||||
schema: {
|
||||
example: {
|
||||
categories: [{ id: '1', name: 'مفاصل و استخوان', slug: 'joints' }],
|
||||
symptoms: ['لنگش', 'ریزش مو'],
|
||||
petTypes: ['سگ', 'گربه', 'هر دو'],
|
||||
},
|
||||
},
|
||||
})
|
||||
getActiveFilters() {
|
||||
return this.productsService.getActiveFilters();
|
||||
}
|
||||
@ -97,76 +44,35 @@ export class ProductsController {
|
||||
@ApiOperation({
|
||||
summary: 'دریافت فیلترها و راهکارهای ناوبری (دسته با علائم مربوطه)',
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description: 'ساختار درختی فیلترها و تگهای درمانی واقعی متصل به محصولات',
|
||||
schema: {
|
||||
example: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'مفاصل و استخوان',
|
||||
slug: 'joints',
|
||||
symptoms: ['درد مفاصل', 'لنگش'],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
getNavigationFilters() {
|
||||
return this.productsService.getNavigationFilters();
|
||||
}
|
||||
|
||||
@Get(':id/dosage-config')
|
||||
@ApiOperation({ summary: 'دریافت پیکربندی دوز مصرف محصول' })
|
||||
getDosageConfig(@Param('id') id: string) {
|
||||
return this.productsService.getDosageConfig(id);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@Patch(':id/dosage-config')
|
||||
@ApiOperation({
|
||||
summary: 'ویرایش پیکربندی دوز مصرف محصول (نیازمند دسترسی ادمین)',
|
||||
})
|
||||
updateDosageConfig(
|
||||
@Param('id') id: string,
|
||||
@Body() body: Prisma.InputJsonValue,
|
||||
) {
|
||||
return this.productsService.updateDosageConfig(id, body);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'دریافت اطلاعات محصول با شناسه یکتا (ID)' })
|
||||
@ApiOkResponse({
|
||||
description: 'اطلاعات کامل محصول شامل مواد تشکیلدهنده و علائم مرتبط',
|
||||
schema: {
|
||||
example: {
|
||||
id: 'a1b2c3d4-1234-5678-abcd-ef1234567890',
|
||||
artNo: 'canhydrox-gag',
|
||||
name: 'Canhydrox GAG (کنهیدروکس)',
|
||||
scientificTagline: 'برای تقویت مفاصل و استخوانها',
|
||||
description: 'کنهیدروکس محصولی بینظیر برای مفاصل...',
|
||||
shortDescription: 'تقویت مفاصل و غضروفها',
|
||||
category: 'سیستم حرکتی و مفاصل',
|
||||
categorySlug: 'joints',
|
||||
priceValue: '1750000.00',
|
||||
priceDisplay: '۱,۷۵۰,۰۰۰ تومان',
|
||||
unit: 'عدد قرص',
|
||||
packageSize: '120.00',
|
||||
dosageLogic: 'یک قرص به ازای هر ۱۰ کیلوگرم وزن بدن سگ',
|
||||
suitableFor: 'سگ',
|
||||
imageUrl: 'https://example.com/canhydrox.png',
|
||||
createdAt: '2026-05-26T18:10:00.000Z',
|
||||
ingredients: [
|
||||
{
|
||||
productId: 'a1b2c3d4-1234-5678-abcd-ef1234567890',
|
||||
ingredient: 'صدف لبسبز',
|
||||
},
|
||||
],
|
||||
symptoms: [
|
||||
{
|
||||
productId: 'a1b2c3d4-1234-5678-abcd-ef1234567890',
|
||||
symptom: 'لنگیدن',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiNotFoundResponse({
|
||||
description: 'محصول با شناسه ارسال شده پیدا نشد',
|
||||
schema: {
|
||||
example: {
|
||||
success: false,
|
||||
message:
|
||||
'Product with ID a1b2c3d4-1234-5678-abcd-ef1234567890 not found',
|
||||
code: 'NOT_FOUND',
|
||||
details: {},
|
||||
},
|
||||
},
|
||||
})
|
||||
async findOne(@Param('id') id: string) {
|
||||
const product = await this.productsService.findOne(id);
|
||||
if (!product) {
|
||||
throw new NotFoundException(`Product with ID ${id} not found`);
|
||||
throw new NotFoundException('محصول یافت نشد');
|
||||
}
|
||||
return product;
|
||||
}
|
||||
|
||||
@ -10,6 +10,8 @@ describe('ProductsService', () => {
|
||||
product: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
count: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
@ -36,39 +38,19 @@ describe('ProductsService', () => {
|
||||
describe('findAll', () => {
|
||||
it('should query products with correct filters', async () => {
|
||||
mockPrisma.product.findMany.mockResolvedValue([{ id: 'prod-1' }]);
|
||||
mockPrisma.product.count.mockResolvedValue(1);
|
||||
const filters = { category: 'joints', petType: 'سگ', query: 'can' };
|
||||
const result = await service.findAll(filters);
|
||||
|
||||
expect(prisma.product.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
categorySlug: 'joints',
|
||||
suitableFor: { in: ['سگ', 'هر دو'] },
|
||||
OR: [
|
||||
{ name: { contains: 'can', mode: 'insensitive' } },
|
||||
{ description: { contains: 'can', mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
ingredients: true,
|
||||
symptoms: true,
|
||||
},
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result.data).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should find product by id', async () => {
|
||||
const prod = { id: 'prod-1' };
|
||||
mockPrisma.product.findUnique.mockResolvedValue(prod);
|
||||
mockPrisma.product.findFirst.mockResolvedValue(prod);
|
||||
const result = await service.findOne('prod-1');
|
||||
expect(prisma.product.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 'prod-1' },
|
||||
include: {
|
||||
ingredients: true,
|
||||
symptoms: true,
|
||||
},
|
||||
});
|
||||
expect(result).toEqual(prod);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { GetProductsDto } from './dto/get-products.dto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
@ -13,39 +14,51 @@ export class ProductsService {
|
||||
search,
|
||||
symptom,
|
||||
requiresRx,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
page = 1,
|
||||
limit = 10,
|
||||
sortBy = 'createdAt',
|
||||
sortOrder = 'desc',
|
||||
} = filters;
|
||||
|
||||
const whereClause: any = {};
|
||||
const andConditions: Prisma.ProductWhereInput[] = [];
|
||||
|
||||
if (requiresRx !== undefined && requiresRx !== null && requiresRx !== '') {
|
||||
whereClause.requiresRx = requiresRx === 'true' || requiresRx === '1';
|
||||
andConditions.push({
|
||||
requiresRx: requiresRx === 'true' || requiresRx === '1',
|
||||
});
|
||||
}
|
||||
|
||||
if (category) {
|
||||
whereClause.categorySlug = category;
|
||||
andConditions.push({ categorySlug: category });
|
||||
}
|
||||
|
||||
if (petType && petType !== 'all') {
|
||||
whereClause.suitableFor = { in: [petType, 'هر دو'] };
|
||||
andConditions.push({ suitableFor: { in: [petType, 'هر دو'] } });
|
||||
}
|
||||
|
||||
// Filter by a specific symptom (from URL param ?symptom=...)
|
||||
if (symptom) {
|
||||
whereClause.symptoms = {
|
||||
andConditions.push({
|
||||
symptoms: {
|
||||
some: {
|
||||
symptom: { contains: symptom, mode: 'insensitive' },
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (minPrice) {
|
||||
andConditions.push({ priceValue: { gte: Number(minPrice) } });
|
||||
}
|
||||
|
||||
if (maxPrice) {
|
||||
andConditions.push({ priceValue: { lte: Number(maxPrice) } });
|
||||
}
|
||||
|
||||
if (search) {
|
||||
// If symptom filter is already applied, extend via AND to also search names/desc
|
||||
// If not, use OR across names, description AND symptoms
|
||||
const searchConditions = [
|
||||
andConditions.push({
|
||||
OR: [
|
||||
{ artNo: { contains: search, mode: 'insensitive' } },
|
||||
{ barcode: { contains: search, mode: 'insensitive' } },
|
||||
{ nameFa: { contains: search, mode: 'insensitive' } },
|
||||
@ -59,19 +72,12 @@ export class ProductsService {
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (symptom) {
|
||||
// Already have a symptom filter; combine with AND
|
||||
whereClause.AND = [
|
||||
{ symptoms: whereClause.symptoms },
|
||||
{ OR: searchConditions.filter((c) => !('symptoms' in c)) },
|
||||
];
|
||||
delete whereClause.symptoms;
|
||||
} else {
|
||||
whereClause.OR = searchConditions;
|
||||
}
|
||||
}
|
||||
const whereClause: Prisma.ProductWhereInput =
|
||||
andConditions.length > 0 ? { AND: andConditions } : {};
|
||||
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
@ -90,13 +96,22 @@ export class ProductsService {
|
||||
]);
|
||||
|
||||
const isWholesaleOrAdmin =
|
||||
userRole === 'User_Wholesale' || userRole === 'ADMIN';
|
||||
userRole === 'User_Wholesale' ||
|
||||
userRole === 'User_Partner' ||
|
||||
userRole === 'ADMIN' ||
|
||||
userRole === 'SuperAdmin';
|
||||
const isAdmin = userRole === 'ADMIN' || userRole === 'SuperAdmin';
|
||||
|
||||
const data = rawProducts.map((p) => {
|
||||
const { buyPrice, wholesalePrice, ...publicProduct } = p;
|
||||
void buyPrice;
|
||||
void wholesalePrice;
|
||||
if (!isWholesaleOrAdmin) {
|
||||
const { wholesalePrice, ...rest } = p;
|
||||
return rest;
|
||||
return publicProduct;
|
||||
}
|
||||
return p;
|
||||
return isAdmin
|
||||
? p
|
||||
: { ...publicProduct, wholesalePrice: p.wholesalePrice };
|
||||
});
|
||||
|
||||
return {
|
||||
@ -125,12 +140,21 @@ export class ProductsService {
|
||||
|
||||
if (!product) return null;
|
||||
const isWholesaleOrAdmin =
|
||||
userRole === 'User_Wholesale' || userRole === 'ADMIN';
|
||||
userRole === 'User_Wholesale' ||
|
||||
userRole === 'User_Partner' ||
|
||||
userRole === 'ADMIN' ||
|
||||
userRole === 'SuperAdmin';
|
||||
const isAdmin = userRole === 'ADMIN' || userRole === 'SuperAdmin';
|
||||
|
||||
const { buyPrice, wholesalePrice, ...publicProduct } = product;
|
||||
void buyPrice;
|
||||
void wholesalePrice;
|
||||
if (!isWholesaleOrAdmin) {
|
||||
const { wholesalePrice, ...rest } = product;
|
||||
return rest;
|
||||
return publicProduct;
|
||||
}
|
||||
return product;
|
||||
return isAdmin
|
||||
? product
|
||||
: { ...publicProduct, wholesalePrice: product.wholesalePrice };
|
||||
}
|
||||
|
||||
async getActiveFilters() {
|
||||
@ -202,4 +226,26 @@ export class ProductsService {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getDosageConfig(id: string) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id },
|
||||
select: { dosageConfig: true, dosageLogic: true },
|
||||
});
|
||||
if (!product) {
|
||||
throw new NotFoundException(`Product with ID ${id} not found`);
|
||||
}
|
||||
return product.dosageConfig || {};
|
||||
}
|
||||
|
||||
async updateDosageConfig(id: string, dosageConfig: Prisma.InputJsonValue) {
|
||||
const product = await this.prisma.product.findUnique({ where: { id } });
|
||||
if (!product) {
|
||||
throw new NotFoundException(`Product with ID ${id} not found`);
|
||||
}
|
||||
return this.prisma.product.update({
|
||||
where: { id },
|
||||
data: { dosageConfig },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
this.client.disconnect();
|
||||
this.client?.disconnect();
|
||||
}
|
||||
|
||||
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SettingsController } from './settings.controller';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { GUARDS_METADATA } from '@nestjs/common/constants';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { ROLES_KEY } from '../common/decorators/roles.decorator';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
|
||||
describe('SettingsController', () => {
|
||||
let controller: SettingsController;
|
||||
@ -32,6 +37,16 @@ describe('SettingsController', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have JwtAuthGuard, RolesGuard and Admin role applied at controller level', () => {
|
||||
const guards = Reflect.getMetadata(GUARDS_METADATA, SettingsController);
|
||||
expect(guards).toBeDefined();
|
||||
expect(guards).toContain(JwtAuthGuard);
|
||||
expect(guards).toContain(RolesGuard);
|
||||
|
||||
const roles = Reflect.getMetadata(ROLES_KEY, SettingsController);
|
||||
expect(roles).toEqual(['Admin']);
|
||||
});
|
||||
|
||||
it('should getUiTexts', async () => {
|
||||
const result = await controller.getUiTexts();
|
||||
expect(service.getUiTexts).toHaveBeenCalled();
|
||||
|
||||
@ -7,20 +7,20 @@ import {
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { SettingsService, ScientificTermData } from './settings.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiBearerAuth,
|
||||
ApiResponse,
|
||||
ApiOkResponse,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
@ApiTags('Settings - تنظیمات متون پویا و واژهنامه علمی')
|
||||
@ApiTags('Settings - تنظیمات متون پویا، تنظیمات سئو، مالی و سیستم')
|
||||
@Controller('settings')
|
||||
export class SettingsController {
|
||||
constructor(private readonly settingsService: SettingsService) {}
|
||||
@ -30,117 +30,99 @@ export class SettingsController {
|
||||
@ApiOkResponse({
|
||||
description:
|
||||
'یک آبجکت کلید-مقدار حاوی تمامی متون، شعارها و برچسبهای دکمهها',
|
||||
schema: {
|
||||
example: {
|
||||
hero_badge: 'تخصص دارویی از آلمان',
|
||||
hero_title: 'تخصص آلمانی در خدمت سلامت پتهای خانگی',
|
||||
hero_desc: 'بیش از ۴۰ سال تجربه نوآورانه...',
|
||||
},
|
||||
},
|
||||
})
|
||||
getUiTexts() {
|
||||
return this.settingsService.getUiTexts();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Patch('ui-texts/:key')
|
||||
@ApiOperation({ summary: 'ویرایش متن یک کلید در رابط کاربری (نیازمند توکن)' })
|
||||
@ApiOkResponse({
|
||||
description: 'متن با موفقیت بهروزرسانی شد',
|
||||
schema: {
|
||||
example: {
|
||||
key: 'hero_badge',
|
||||
value: 'تخصص دارویی ممتاز از آلمان',
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiUnauthorizedResponse({
|
||||
description: 'عدم دسترسی به دلیل عدم احراز هویت',
|
||||
schema: {
|
||||
example: {
|
||||
success: false,
|
||||
message: 'Unauthorized',
|
||||
code: 'UNAUTHORIZED',
|
||||
},
|
||||
},
|
||||
})
|
||||
@Put('ui-texts/:key')
|
||||
@ApiOperation({ summary: 'ویرایش یا ثبت متن یک کلید در رابط کاربری' })
|
||||
updateUiText(@Param('key') key: string, @Body('value') value: string) {
|
||||
return this.settingsService.updateUiText(key, value);
|
||||
}
|
||||
|
||||
@Get('scientific-terms')
|
||||
@ApiOperation({ summary: 'دریافت تمامی اصطلاحات واژهنامه علمی' })
|
||||
@ApiOkResponse({
|
||||
description: 'لیست کامل اصطلاحات علمی به همراه تعاریف و شناسهها',
|
||||
schema: {
|
||||
example: [
|
||||
{
|
||||
key: 'green-mussel',
|
||||
term: 'صدف لبسبز (Perna Canaliculus)',
|
||||
definition: 'این صدف بومی سواحل بکر نیوزیلند است...',
|
||||
wikiId: 'general',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
getScientificTerms() {
|
||||
return this.settingsService.getScientificTerms();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Put('scientific-terms/:key')
|
||||
@ApiOperation({ summary: 'ثبت یا ویرایش یک اصطلاح علمی (نیازمند توکن)' })
|
||||
@ApiOkResponse({
|
||||
description: 'اصطلاح علمی ثبت یا ویرایش شد',
|
||||
schema: {
|
||||
example: {
|
||||
key: 'green-mussel',
|
||||
term: 'صدف لبسبز اصل نیوزیلند',
|
||||
definition: 'این صدف بومی سواحل بکر نیوزیلند است...',
|
||||
wikiId: 'general',
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiUnauthorizedResponse({
|
||||
description: 'عدم دسترسی',
|
||||
schema: {
|
||||
example: {
|
||||
success: false,
|
||||
message: 'Unauthorized',
|
||||
code: 'UNAUTHORIZED',
|
||||
},
|
||||
},
|
||||
})
|
||||
upsertScientificTerm(@Param('key') key: string, @Body() data: any) {
|
||||
@ApiOperation({ summary: 'ثبت یا ویرایش یک اصطلاح علمی' })
|
||||
upsertScientificTerm(
|
||||
@Param('key') key: string,
|
||||
@Body() data: ScientificTermData,
|
||||
) {
|
||||
return this.settingsService.upsertScientificTerm(key, data);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Delete('scientific-terms/:key')
|
||||
@ApiOperation({ summary: 'حذف یک اصطلاح علمی (نیازمند توکن)' })
|
||||
@ApiOkResponse({
|
||||
description: 'اصطلاح علمی حذف شد',
|
||||
schema: {
|
||||
example: {
|
||||
success: true,
|
||||
message: 'Scientific term successfully deleted',
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiUnauthorizedResponse({
|
||||
description: 'عدم دسترسی',
|
||||
schema: {
|
||||
example: {
|
||||
success: false,
|
||||
message: 'Unauthorized',
|
||||
code: 'UNAUTHORIZED',
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiOperation({ summary: 'حذف یک اصطلاح علمی' })
|
||||
deleteScientificTerm(@Param('key') key: string) {
|
||||
return this.settingsService.deleteScientificTerm(key);
|
||||
}
|
||||
|
||||
// SEO Settings
|
||||
@Get('seo')
|
||||
@ApiOperation({ summary: 'دریافت تنظیمات سئو' })
|
||||
getSeoSettings() {
|
||||
return this.settingsService.getCategorySetting('seo');
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Patch('seo')
|
||||
@ApiOperation({ summary: 'بهروزرسانی تنظیمات سئو' })
|
||||
updateSeoSettings(@Body() body: Prisma.InputJsonValue) {
|
||||
return this.settingsService.updateCategorySetting('seo', body);
|
||||
}
|
||||
|
||||
// Financial Settings
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Get('financial')
|
||||
@ApiOperation({ summary: 'دریافت تنظیمات مالی' })
|
||||
getFinancialSettings() {
|
||||
return this.settingsService.getCategorySetting('financial');
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Patch('financial')
|
||||
@ApiOperation({ summary: 'بهروزرسانی تنظیمات مالی' })
|
||||
updateFinancialSettings(@Body() body: Prisma.InputJsonValue) {
|
||||
return this.settingsService.updateCategorySetting('financial', body);
|
||||
}
|
||||
|
||||
// System Settings
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Get('system')
|
||||
@ApiOperation({ summary: 'دریافت تنظیمات سیستم' })
|
||||
getSystemSettings() {
|
||||
return this.settingsService.getCategorySetting('system');
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Patch('system')
|
||||
@ApiOperation({ summary: 'بهروزرسانی تنظیمات سیستم' })
|
||||
updateSystemSettings(@Body() body: Prisma.InputJsonValue) {
|
||||
return this.settingsService.updateCategorySetting('system', body);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export class ScientificTermData {
|
||||
term?: string;
|
||||
definition?: string;
|
||||
wikiId?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SettingsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getUiTexts() {
|
||||
return this.prisma.uiText.findMany();
|
||||
const [uiTexts, settings] = await Promise.all([
|
||||
this.prisma.uiText.findMany(),
|
||||
this.prisma.setting.findMany(),
|
||||
]);
|
||||
|
||||
const merged = [...uiTexts];
|
||||
settings.forEach((s) => {
|
||||
const valStr =
|
||||
typeof s.value === 'string' ? s.value : JSON.stringify(s.value);
|
||||
merged.push({ key: s.key, value: valStr });
|
||||
});
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
async updateUiText(key: string, value: string) {
|
||||
@ -21,19 +40,23 @@ export class SettingsService {
|
||||
return this.prisma.scientificTerm.findMany();
|
||||
}
|
||||
|
||||
async upsertScientificTerm(key: string, data: any) {
|
||||
async upsertScientificTerm(key: string, data: ScientificTermData) {
|
||||
const term = String(data?.term || '');
|
||||
const definition = String(data?.definition || '');
|
||||
const wikiId = String(data?.wikiId || 'general');
|
||||
|
||||
return this.prisma.scientificTerm.upsert({
|
||||
where: { key },
|
||||
update: {
|
||||
term: data.term,
|
||||
definition: data.definition,
|
||||
wikiId: data.wikiId || 'general',
|
||||
term,
|
||||
definition,
|
||||
wikiId,
|
||||
},
|
||||
create: {
|
||||
key,
|
||||
term: data.term,
|
||||
definition: data.definition,
|
||||
wikiId: data.wikiId || 'general',
|
||||
term,
|
||||
definition,
|
||||
wikiId,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -43,4 +66,20 @@ export class SettingsService {
|
||||
where: { key },
|
||||
});
|
||||
}
|
||||
|
||||
async getCategorySetting(category: string) {
|
||||
const setting = await this.prisma.setting.findFirst({
|
||||
where: { category },
|
||||
});
|
||||
return setting ? setting.value : {};
|
||||
}
|
||||
|
||||
async updateCategorySetting(category: string, value: Prisma.InputJsonValue) {
|
||||
const key = `${category}_config`;
|
||||
return this.prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: { category, value },
|
||||
create: { key, category, value },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
58
backend/src/smart-advisor/smart-advisor.controller.ts
Normal file
58
backend/src/smart-advisor/smart-advisor.controller.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { SmartAdvisorService } from './smart-advisor.service';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@ApiTags('Smart Advisor - دستیار هوشمند توصیه دارویی')
|
||||
@Controller('smart-advisor/rules')
|
||||
export class SmartAdvisorController {
|
||||
constructor(private readonly smartAdvisorService: SmartAdvisorService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'لیست قانونهای دستیار هوشمند' })
|
||||
findAll() {
|
||||
return this.smartAdvisorService.findAll();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'ایجاد قانون جدید دستیار هوشمند (نیازمند ادمین)' })
|
||||
create(@Body() body: Prisma.SmartAdvisorRuleCreateInput) {
|
||||
return this.smartAdvisorService.create(body);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'ویرایش قانون دستیار هوشمند (نیازمند ادمین)' })
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() body: Prisma.SmartAdvisorRuleUpdateInput,
|
||||
) {
|
||||
return this.smartAdvisorService.update(id, body);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'حذف قانون دستیار هوشمند (نیازمند ادمین)' })
|
||||
remove(@Param('id') id: string) {
|
||||
return this.smartAdvisorService.remove(id);
|
||||
}
|
||||
}
|
||||
12
backend/src/smart-advisor/smart-advisor.module.ts
Normal file
12
backend/src/smart-advisor/smart-advisor.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SmartAdvisorController } from './smart-advisor.controller';
|
||||
import { SmartAdvisorService } from './smart-advisor.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [SmartAdvisorController],
|
||||
providers: [SmartAdvisorService],
|
||||
exports: [SmartAdvisorService],
|
||||
})
|
||||
export class SmartAdvisorModule {}
|
||||
45
backend/src/smart-advisor/smart-advisor.service.ts
Normal file
45
backend/src/smart-advisor/smart-advisor.service.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class SmartAdvisorService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async findAll() {
|
||||
return this.prisma.smartAdvisorRule.findMany({
|
||||
include: { product: true },
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Prisma.SmartAdvisorRuleCreateInput) {
|
||||
return this.prisma.smartAdvisorRule.create({
|
||||
data,
|
||||
include: { product: true },
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: Prisma.SmartAdvisorRuleUpdateInput) {
|
||||
const rule = await this.prisma.smartAdvisorRule.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!rule) {
|
||||
throw new NotFoundException(`SmartAdvisorRule with ID ${id} not found`);
|
||||
}
|
||||
return this.prisma.smartAdvisorRule.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: { product: true },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
const rule = await this.prisma.smartAdvisorRule.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!rule) {
|
||||
throw new NotFoundException(`SmartAdvisorRule with ID ${id} not found`);
|
||||
}
|
||||
return this.prisma.smartAdvisorRule.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
55
backend/src/testimonials/testimonials.controller.ts
Normal file
55
backend/src/testimonials/testimonials.controller.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { TestimonialsService } from './testimonials.service';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
|
||||
@ApiTags('Testimonials - نظرات و رضایتنامهها')
|
||||
@Controller('testimonials')
|
||||
export class TestimonialsController {
|
||||
constructor(private readonly testimonialsService: TestimonialsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'دریافت لیست نظرات و رضایتنامهها' })
|
||||
findAll() {
|
||||
return this.testimonialsService.findAll();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'ایجاد نظر جدید (نیازمند ادمین)' })
|
||||
create(@Body() body: Prisma.TestimonialCreateInput) {
|
||||
return this.testimonialsService.create(body);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'ویرایش نظر (نیازمند ادمین)' })
|
||||
update(@Param('id') id: string, @Body() body: Prisma.TestimonialUpdateInput) {
|
||||
return this.testimonialsService.update(id, body);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'حذف نظر (نیازمند ادمین)' })
|
||||
remove(@Param('id') id: string) {
|
||||
return this.testimonialsService.remove(id);
|
||||
}
|
||||
}
|
||||
12
backend/src/testimonials/testimonials.module.ts
Normal file
12
backend/src/testimonials/testimonials.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TestimonialsController } from './testimonials.controller';
|
||||
import { TestimonialsService } from './testimonials.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [TestimonialsController],
|
||||
providers: [TestimonialsService],
|
||||
exports: [TestimonialsService],
|
||||
})
|
||||
export class TestimonialsModule {}
|
||||
39
backend/src/testimonials/testimonials.service.ts
Normal file
39
backend/src/testimonials/testimonials.service.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class TestimonialsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async findAll() {
|
||||
return this.prisma.testimonial.findMany({
|
||||
orderBy: { order: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: Prisma.TestimonialCreateInput) {
|
||||
return this.prisma.testimonial.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: Prisma.TestimonialUpdateInput) {
|
||||
const item = await this.prisma.testimonial.findUnique({ where: { id } });
|
||||
if (!item) {
|
||||
throw new NotFoundException(`Testimonial with ID ${id} not found`);
|
||||
}
|
||||
return this.prisma.testimonial.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
const item = await this.prisma.testimonial.findUnique({ where: { id } });
|
||||
if (!item) {
|
||||
throw new NotFoundException(`Testimonial with ID ${id} not found`);
|
||||
}
|
||||
return this.prisma.testimonial.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@ -65,7 +65,7 @@ export class UsersController {
|
||||
},
|
||||
},
|
||||
})
|
||||
getProfile(@Req() req: any) {
|
||||
getProfile(@Req() req: { user: { id: string } }) {
|
||||
return this.usersService.findById(req.user.id);
|
||||
}
|
||||
|
||||
@ -100,7 +100,10 @@ export class UsersController {
|
||||
},
|
||||
},
|
||||
})
|
||||
updateProfile(@Req() req: any, @Body() updateProfileDto: UpdateProfileDto) {
|
||||
updateProfile(
|
||||
@Req() req: { user: { id: string } },
|
||||
@Body() updateProfileDto: UpdateProfileDto,
|
||||
) {
|
||||
return this.usersService.update(req.user.id, updateProfileDto);
|
||||
}
|
||||
|
||||
@ -125,7 +128,10 @@ export class UsersController {
|
||||
},
|
||||
},
|
||||
})
|
||||
addAddress(@Req() req: any, @Body() addressDto: AddressDto) {
|
||||
addAddress(
|
||||
@Req() req: { user: { id: string } },
|
||||
@Body() addressDto: AddressDto,
|
||||
) {
|
||||
return this.usersService.addAddress(req.user.id, addressDto);
|
||||
}
|
||||
|
||||
@ -151,7 +157,7 @@ export class UsersController {
|
||||
},
|
||||
})
|
||||
updateAddress(
|
||||
@Req() req: any,
|
||||
@Req() req: { user: { id: string } },
|
||||
@Param('addressId') addressId: string,
|
||||
@Body() addressDto: AddressDto,
|
||||
) {
|
||||
@ -170,7 +176,10 @@ export class UsersController {
|
||||
},
|
||||
},
|
||||
})
|
||||
deleteAddress(@Req() req: any, @Param('addressId') addressId: string) {
|
||||
deleteAddress(
|
||||
@Req() req: { user: { id: string } },
|
||||
@Param('addressId') addressId: string,
|
||||
) {
|
||||
return this.usersService.deleteAddress(req.user.id, addressId);
|
||||
}
|
||||
|
||||
@ -186,7 +195,10 @@ export class UsersController {
|
||||
},
|
||||
},
|
||||
})
|
||||
setDefaultAddress(@Req() req: any, @Param('addressId') addressId: string) {
|
||||
setDefaultAddress(
|
||||
@Req() req: { user: { id: string } },
|
||||
@Param('addressId') addressId: string,
|
||||
) {
|
||||
return this.usersService.setDefaultAddress(req.user.id, addressId);
|
||||
}
|
||||
|
||||
@ -208,7 +220,10 @@ export class UsersController {
|
||||
},
|
||||
})
|
||||
@ApiBadRequestResponse({ description: 'مبلغ نامعتبر است' })
|
||||
async topUpWallet(@Req() req: any, @Body() body: { amount: number }) {
|
||||
async topUpWallet(
|
||||
@Req() req: { user: { id: string } },
|
||||
@Body() body: { amount: number },
|
||||
) {
|
||||
const amount = Number(body.amount);
|
||||
if (!amount || amount <= 0) {
|
||||
throw new BadRequestException('مبلغ شارژ باید بزرگتر از صفر باشد');
|
||||
|
||||
@ -56,7 +56,16 @@ describe('UsersService', () => {
|
||||
});
|
||||
|
||||
it('should addAddress', async () => {
|
||||
const addressData = { title: 'Home', isDefault: true };
|
||||
const addressData = {
|
||||
title: 'Home',
|
||||
receptorName: 'Ali',
|
||||
phone: '09123456789',
|
||||
province: 'Tehran',
|
||||
city: 'Tehran',
|
||||
detail: 'Street 1',
|
||||
zipCode: '1234567890',
|
||||
isDefault: true,
|
||||
};
|
||||
mockPrisma.userAddress.create.mockResolvedValue({
|
||||
id: 'addr-id',
|
||||
...addressData,
|
||||
@ -72,7 +81,16 @@ describe('UsersService', () => {
|
||||
});
|
||||
|
||||
it('should updateAddress', async () => {
|
||||
const addressData = { title: 'Work', isDefault: true };
|
||||
const addressData = {
|
||||
title: 'Work',
|
||||
receptorName: 'Ali',
|
||||
phone: '09123456789',
|
||||
province: 'Tehran',
|
||||
city: 'Tehran',
|
||||
detail: 'Street 2',
|
||||
zipCode: '1234567890',
|
||||
isDefault: true,
|
||||
};
|
||||
mockPrisma.userAddress.update.mockResolvedValue({
|
||||
id: 'addr-id',
|
||||
...addressData,
|
||||
|
||||
@ -1,6 +1,18 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface UserAddressInput {
|
||||
title: string;
|
||||
receptorName: string;
|
||||
phone: string;
|
||||
province: string;
|
||||
city: string;
|
||||
detail: string;
|
||||
zipCode: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
@ -37,7 +49,7 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: any) {
|
||||
async update(id: string, data: Prisma.UserUpdateInput) {
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
@ -56,7 +68,7 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
async addAddress(userId: string, data: any) {
|
||||
async addAddress(userId: string, data: UserAddressInput) {
|
||||
if (data.isDefault) {
|
||||
await this.prisma.userAddress.updateMany({
|
||||
where: { userId },
|
||||
@ -78,7 +90,11 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateAddress(userId: string, addressId: string, data: any) {
|
||||
async updateAddress(
|
||||
userId: string,
|
||||
addressId: string,
|
||||
data: UserAddressInput,
|
||||
) {
|
||||
if (data.isDefault) {
|
||||
await this.prisma.userAddress.updateMany({
|
||||
where: { userId, NOT: { id: addressId } },
|
||||
|
||||
18
backend/src/videos/dto/get-videos-query.dto.ts
Normal file
18
backend/src/videos/dto/get-videos-query.dto.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsBoolean } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { PaginationDto } from '../../common/dto/pagination.dto';
|
||||
|
||||
export class GetVideosQueryDto extends PaginationDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'فیلتر بر اساس ویدئوهای ویژه (true/false)',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => {
|
||||
if (value === 'true' || value === true) return true;
|
||||
if (value === 'false' || value === false) return false;
|
||||
return undefined;
|
||||
})
|
||||
@IsBoolean()
|
||||
featured?: boolean;
|
||||
}
|
||||
@ -11,6 +11,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { VideosService } from './videos.service';
|
||||
import { CreateVideoDto } from './dto/create-video.dto';
|
||||
import { GetVideosQueryDto } from './dto/get-videos-query.dto';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import {
|
||||
ApiTags,
|
||||
@ -30,7 +31,7 @@ export class VideosController {
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'featured', required: false })
|
||||
findAll(@Query() query: any) {
|
||||
findAll(@Query() query: GetVideosQueryDto) {
|
||||
return this.videosService.findAll(query);
|
||||
}
|
||||
|
||||
|
||||
@ -1,21 +1,26 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateVideoDto } from './dto/create-video.dto';
|
||||
|
||||
export class VideoQuery {
|
||||
page?: number | string;
|
||||
limit?: number | string;
|
||||
search?: string;
|
||||
featured?: boolean | string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VideosService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private get video() {
|
||||
return (this.prisma as any).video;
|
||||
}
|
||||
|
||||
async findAll(query: any) {
|
||||
async findAll(query: VideoQuery = {}) {
|
||||
const page = Number(query.page) || 1;
|
||||
const limit = Number(query.limit) || 20;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: any = {};
|
||||
const where: Prisma.VideoWhereInput = {};
|
||||
|
||||
if (query.search) {
|
||||
where.OR = [
|
||||
{ title: { contains: query.search, mode: 'insensitive' } },
|
||||
@ -23,82 +28,71 @@ export class VideosService {
|
||||
{ description: { contains: query.search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
if (query.featured !== undefined) {
|
||||
where.isFeatured = query.featured === 'true' || query.featured === true;
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.video.findMany({
|
||||
const [videos, total] = await Promise.all([
|
||||
this.prisma.video.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.video.count({ where }),
|
||||
this.prisma.video.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
data,
|
||||
videos,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
lastPage: Math.ceil(total / limit),
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string, incrementView = false) {
|
||||
const video = await this.video.findUnique({
|
||||
async findOne(id: string) {
|
||||
const video = await this.prisma.video.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!video) {
|
||||
throw new NotFoundException('ویدئوی مورد نظر یافت نشد');
|
||||
}
|
||||
if (incrementView) {
|
||||
await this.video
|
||||
.update({
|
||||
where: { id },
|
||||
data: { viewsCount: { increment: 1 } },
|
||||
})
|
||||
.catch(() => {});
|
||||
throw new NotFoundException(`Video with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return video;
|
||||
}
|
||||
|
||||
async create(dto: CreateVideoDto) {
|
||||
return this.video.create({
|
||||
async create(createVideoDto: CreateVideoDto) {
|
||||
return this.prisma.video.create({
|
||||
data: {
|
||||
title: dto.title,
|
||||
doctor: dto.doctor,
|
||||
duration: dto.duration || '۰۲:۰۰',
|
||||
thumbnail: dto.thumbnail,
|
||||
videoUrl: dto.videoUrl,
|
||||
description: dto.description,
|
||||
isFeatured: dto.isFeatured ?? false,
|
||||
title: createVideoDto.title,
|
||||
doctor: createVideoDto.doctor,
|
||||
duration: createVideoDto.duration || '00:00',
|
||||
videoUrl: createVideoDto.videoUrl,
|
||||
thumbnail: createVideoDto.thumbnail || '',
|
||||
description: createVideoDto.description || null,
|
||||
isFeatured: createVideoDto.isFeatured || false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: Partial<CreateVideoDto>) {
|
||||
async update(id: string, updateVideoDto: Partial<CreateVideoDto>) {
|
||||
await this.findOne(id);
|
||||
return this.video.update({
|
||||
|
||||
return this.prisma.video.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: dto.title,
|
||||
doctor: dto.doctor,
|
||||
duration: dto.duration,
|
||||
thumbnail: dto.thumbnail,
|
||||
videoUrl: dto.videoUrl,
|
||||
description: dto.description,
|
||||
isFeatured: dto.isFeatured,
|
||||
},
|
||||
data: updateVideoDto,
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.findOne(id);
|
||||
return this.video.delete({
|
||||
|
||||
return this.prisma.video.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
@ -26,7 +26,10 @@ export class WholesaleController {
|
||||
@ApiOperation({
|
||||
summary: 'ثبت درخواست همکاری عمدهفروشی (ارسال پروانه کلینیک/داروخانه)',
|
||||
})
|
||||
applyForWholesale(@Request() req: any, @Body() dto: WholesaleApplyDto) {
|
||||
applyForWholesale(
|
||||
@Request() req: { user: { id: string } },
|
||||
@Body() dto: WholesaleApplyDto,
|
||||
) {
|
||||
return this.wholesaleService.applyForWholesale(req.user.id, dto);
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||
|
||||
@ -17,7 +18,7 @@ export class WikiService {
|
||||
const allowedSortFields = ['key', 'term', 'wikiId'];
|
||||
const sortBy = allowedSortFields.includes(rawSortBy) ? rawSortBy : 'term';
|
||||
|
||||
const whereClause: any = {};
|
||||
const whereClause: Prisma.ScientificTermWhereInput = {};
|
||||
if (search) {
|
||||
whereClause.OR = [
|
||||
{ term: { contains: search, mode: 'insensitive' } },
|
||||
|
||||
177
backend/test/app-audit-verification.e2e-spec.ts
Normal file
177
backend/test/app-audit-verification.e2e-spec.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { CustomHttpExceptionFilter } from '../src/common/filters/http-exception.filter';
|
||||
import { PrismaExceptionFilter } from '../src/common/filters/prisma-exception.filter';
|
||||
import { DecimalInterceptor } from '../src/common/interceptors/decimal.interceptor';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('Phase 4 Master Backlog E2E Verification Matrix (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let jwtService: JwtService;
|
||||
let adminToken: string;
|
||||
let userToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.JWT_ACCESS_SECRET =
|
||||
'test_access_secret_32_characters_minimum_entropy';
|
||||
process.env.JWT_REFRESH_SECRET =
|
||||
'test_refresh_secret_32_characters_minimum_entropy';
|
||||
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.setGlobalPrefix('api');
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ transform: true, whitelist: true }),
|
||||
);
|
||||
app.useGlobalFilters(
|
||||
new CustomHttpExceptionFilter(),
|
||||
new PrismaExceptionFilter(),
|
||||
);
|
||||
app.useGlobalInterceptors(new DecimalInterceptor());
|
||||
|
||||
await app.init();
|
||||
|
||||
jwtService = new JwtService();
|
||||
|
||||
adminToken = jwtService.sign(
|
||||
{
|
||||
sub: '12345678-1234-1234-1234-123456789012',
|
||||
email: 'admin@canino.ir',
|
||||
role: 'Admin',
|
||||
},
|
||||
{ secret: process.env.JWT_ACCESS_SECRET },
|
||||
);
|
||||
|
||||
userToken = jwtService.sign(
|
||||
{
|
||||
sub: '12345678-1234-1234-1234-123456789012',
|
||||
email: 'user@canino.ir',
|
||||
role: 'User_PetOwner',
|
||||
},
|
||||
{ secret: process.env.JWT_ACCESS_SECRET },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe('TASK-SEC-001: Mandatory Dual-Secret Startup Enforcement', () => {
|
||||
it('should reject startup logic if JWT_ACCESS_SECRET is missing or under 32 characters', () => {
|
||||
const validateStartup = (accessSec?: string, refreshSec?: string) => {
|
||||
if (!accessSec || accessSec.trim().length < 32) {
|
||||
throw new Error('FATAL: JWT_ACCESS_SECRET missing or short');
|
||||
}
|
||||
if (!refreshSec || refreshSec.trim().length < 32) {
|
||||
throw new Error('FATAL: JWT_REFRESH_SECRET missing or short');
|
||||
}
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
validateStartup('short', process.env.JWT_REFRESH_SECRET),
|
||||
).toThrow('FATAL: JWT_ACCESS_SECRET missing or short');
|
||||
expect(() =>
|
||||
validateStartup(process.env.JWT_ACCESS_SECRET, 'short'),
|
||||
).toThrow('FATAL: JWT_REFRESH_SECRET missing or short');
|
||||
expect(() =>
|
||||
validateStartup(
|
||||
process.env.JWT_ACCESS_SECRET,
|
||||
process.env.JWT_REFRESH_SECRET,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TASK-SEC-002: Cryptographically Secure OTP Generation & Response Payload Hardening', () => {
|
||||
it('should NOT disclose OTP plaintext code in POST /api/auth/send-otp response', async () => {
|
||||
const httpServer = app.getHttpServer() as unknown as App;
|
||||
const response = await request(httpServer)
|
||||
.post('/api/auth/send-otp')
|
||||
.send({ phoneNumber: '09123456789' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
expect(response.body).toHaveProperty('message');
|
||||
expect(response.body).not.toHaveProperty('code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TASK-SEC-003: Role-Based Access Control (RBAC) Enforcement on Settings API', () => {
|
||||
it('should forbid non-admin users (User_PetOwner) with HTTP 403 when accessing /api/settings/ui-texts', async () => {
|
||||
const httpServer = app.getHttpServer() as unknown as App;
|
||||
const response = await request(httpServer)
|
||||
.get('/api/settings/ui-texts')
|
||||
.set('Authorization', `Bearer ${userToken}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should allow admin users (Admin) with HTTP 200 when accessing /api/settings/ui-texts', async () => {
|
||||
const httpServer = app.getHttpServer() as unknown as App;
|
||||
const response = await request(httpServer)
|
||||
.get('/api/settings/ui-texts')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TASK-FIN-001: Arbitrary-Precision Decimal Accounting & Prisma.Decimal Serialization', () => {
|
||||
it('should perform exact arbitrary-precision arithmetic (19.99 * 3 + 5.01 = 64.98)', () => {
|
||||
const item1Price = new Prisma.Decimal('19.99');
|
||||
const item1Qty = 3;
|
||||
const item2Price = new Prisma.Decimal('5.01');
|
||||
const item2Qty = 1;
|
||||
|
||||
const subtotal1 = item1Price.mul(item1Qty);
|
||||
const subtotal2 = item2Price.mul(item2Qty);
|
||||
const total = subtotal1.add(subtotal2);
|
||||
|
||||
expect(total.toString()).toBe('64.98');
|
||||
expect(Number(total)).toBe(64.98);
|
||||
});
|
||||
|
||||
it('should transform Prisma.Decimal instances into formatted strings via DecimalInterceptor', () => {
|
||||
const interceptor = new DecimalInterceptor();
|
||||
const mockDecimal = new Prisma.Decimal('149.50');
|
||||
const testPayload = {
|
||||
id: 'ord-1',
|
||||
totalAmount: mockDecimal,
|
||||
items: [{ price: new Prisma.Decimal('49.99') }],
|
||||
};
|
||||
|
||||
const decimalTransform = interceptor as unknown as {
|
||||
transform: (data: unknown) => {
|
||||
totalAmount: string;
|
||||
items: { price: string }[];
|
||||
};
|
||||
};
|
||||
const transformed = decimalTransform.transform(testPayload);
|
||||
expect(transformed.totalAmount).toBe('149.5');
|
||||
expect(transformed.items[0].price).toBe('49.99');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TASK-DOC-001: OpenAPI Documentation Synchronization', () => {
|
||||
it('should confirm root swagger.yml file exists and is synchronized', () => {
|
||||
const rootSwaggerPath = path.resolve(__dirname, '../../swagger.yml');
|
||||
expect(fs.existsSync(rootSwaggerPath)).toBe(true);
|
||||
|
||||
const content = fs.readFileSync(rootSwaggerPath, 'utf8');
|
||||
expect(content).toContain('openapi: 3.0.0');
|
||||
expect(content).toContain('/api/auth/send-otp');
|
||||
expect(content).toContain('/api/settings');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -4,6 +4,13 @@ import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
process.env.JWT_ACCESS_SECRET =
|
||||
process.env.JWT_ACCESS_SECRET ||
|
||||
'test_access_secret_32_characters_minimum_entropy';
|
||||
process.env.JWT_REFRESH_SECRET =
|
||||
process.env.JWT_REFRESH_SECRET ||
|
||||
'test_refresh_secret_32_characters_minimum_entropy';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
@ -13,14 +20,12 @@ describe('AppController (e2e)', () => {
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
app.setGlobalPrefix('api');
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
it('/api/metrics (GET)', () => {
|
||||
return request(app.getHttpServer()).get('/api/metrics').expect(200);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
62
docs/audit/00-repository-map.md
Normal file
62
docs/audit/00-repository-map.md
Normal file
@ -0,0 +1,62 @@
|
||||
# Repository Map
|
||||
|
||||
## Repository Structure & Overview
|
||||
This repository contains an active e-commerce application stack with nested standalone directories and placeholder artifacts.
|
||||
|
||||
- **Root Directory**: `c:\Users\parsa\Desktop\work\caninairan`
|
||||
- **Initial Git Branch**: `develop`
|
||||
- **Initial HEAD Commit**: `715873b2ecc3a72ba974bb2a2be87c5ba82bd4e7`
|
||||
- **Git Working Tree Status**: Contains untracked directories `docs/` and `frontend/`. No tracked files are modified.
|
||||
- **Monorepo / Multi-repository Classification**: Hybrid single-repository setup.
|
||||
- **Package Manager**: npm (`package-lock.json` present in root and `backend/`).
|
||||
|
||||
---
|
||||
|
||||
## Active Applications & Non-Auditable Scopes
|
||||
|
||||
### 1. Active Customer Storefront: React 19 + Vite Application
|
||||
- **Status**: ACTIVE APPLICATION
|
||||
- **Path**: `.` (`src/`, `index.html`, `vite.config.ts`, `package.json`)
|
||||
- **Technology**: React 19, Vite 6, TypeScript 5.8, Tailwind CSS v4, Zustand 5, Axios, Lucide React, Motion.
|
||||
- **Description**: Customer e-commerce interface for Canino Iran pet health & supplement products, including catalog browsing, cart management, dosage calculator, pet wallet, charity counter, and health journal UI.
|
||||
|
||||
### 2. Active Backend Service: NestJS Application
|
||||
- **Status**: ACTIVE APPLICATION
|
||||
- **Path**: `backend/` (`backend/src/`, `backend/package.json`, `backend/prisma/`)
|
||||
- **Technology**: NestJS 11, Prisma ORM 5.22, PostgreSQL, Redis (`ioredis`), JWT, Passport, Swagger, Class Validator, Throttler.
|
||||
- **Description**: RESTful API supporting authentication, users, pets, products, orders, wallet transactions, and administrative settings.
|
||||
|
||||
### 3. Excluded Placeholder / Non-Auditable Directories
|
||||
- **`frontend/application`**: NON-AUDITABLE / GENERATED PLACEHOLDER. Contains build/cache artifacts (`.next/`, `tsconfig.tsbuildinfo`, `next-env.d.ts`, `node_modules/`). Lacks root `package.json` or source code files (`src/` or `app/`). **EXCLUDED FROM SOURCE AUDIT.**
|
||||
- **`frontend/admin-panel`**: NON-AUDITABLE PLACEHOLDER. Contains only `node_modules/` directory without `package.json` or source files. **EXCLUDED FROM SOURCE AUDIT.**
|
||||
|
||||
---
|
||||
|
||||
## Mandatory Global Audit Exclusions
|
||||
All Phase 2 auditors must strictly ignore generated code, build output, dependencies, and temporary caches:
|
||||
- `**/node_modules/**`
|
||||
- `**/.next/**`
|
||||
- `**/dist/**`
|
||||
- `**/build/**`
|
||||
- `**/coverage/**`
|
||||
- `**/.turbo/**`
|
||||
- `**/.cache/**`
|
||||
- `**/*.tsbuildinfo`
|
||||
- Generated Prisma client (`node_modules/@prisma/client` & `.prisma/client`)
|
||||
- Generated API models and minified JavaScript bundles
|
||||
|
||||
---
|
||||
|
||||
## Important Configuration & Infrastructure Files
|
||||
|
||||
- **Docker & Containerization**: `Dockerfile`, `docker-compose.yml`, `backend/Dockerfile`, `.dockerignore`, `backend/.dockerignore`
|
||||
- **Proxy & Observability**: `nginx.conf`, `prometheus.yml`
|
||||
- **Database & Data Modeling**: `backend/prisma/schema.prisma`
|
||||
- **API Specs**: `swagger.yml`, NestJS Swagger (`@nestjs/swagger`)
|
||||
- **Repository Documentation**: `README.md`, `DATABASE_SCHEMA.md`, `BACKEND_INTEGRATION.md`
|
||||
- **Environment Templates**: `.env.example`, `backend/.env` (sensitive/uncommitted)
|
||||
|
||||
---
|
||||
|
||||
## Documentation Governance
|
||||
- **Audit Deliverable Directory**: `docs/audit/`
|
||||
49
docs/audit/01-system-discovery.md
Normal file
49
docs/audit/01-system-discovery.md
Normal file
@ -0,0 +1,49 @@
|
||||
# System Discovery
|
||||
|
||||
## Current Architecture
|
||||
The Canino Iran system is structured around an active customer storefront and a NestJS REST API backend.
|
||||
|
||||
### Active Core Applications
|
||||
1. **React / Vite Storefront Application** (`src/`): React 19 + Vite 6 Single Page Application for customer product discovery, supplement dosage recommendation, pet registration, health tracking, and wallet management.
|
||||
2. **NestJS Backend Service** (`backend/src/`): Modular NestJS 11 backend service providing business logic, authentication, pet data, catalog management, orders, and system settings.
|
||||
3. **Database Layer**: PostgreSQL database modeled via Prisma ORM 5.22 (`backend/prisma/schema.prisma`).
|
||||
4. **Caching & Session Layer**: Redis via `ioredis` for throttling, caching, and session storage.
|
||||
|
||||
### Excluded / Non-Auditable Artifacts
|
||||
1. **`frontend/application`**: Next.js build/cache placeholder containing only `.next/`, `tsconfig.tsbuildinfo`, `next-env.d.ts`, and `node_modules/`. Lacks an active `package.json` or source tree. Excluded from code audit.
|
||||
2. **`frontend/admin-panel`**: Placeholder directory containing only `node_modules/`. Excluded from code audit.
|
||||
|
||||
---
|
||||
|
||||
## Major Business Domains Discovered
|
||||
- **User & Wallet Domain**: User profile, authentication (JWT), multi-address support, wallet deposit/withdrawal ledger.
|
||||
- **Pet & Health Domain**: Pet registration, breed/weight profiling, medical conditions tracking, daily health logs, dosing reminders.
|
||||
- **Product & Dosage Domain**: Supplement catalog, dosage logic calculation based on pet weight and species, category listings, ingredient/symptom mapping.
|
||||
- **Order & Charity Domain**: Checkout workflow, coupon validation, tracking numbers, automatic charity donation allocations.
|
||||
- **Settings & Content Domain**: Administrative UI text updates and scientific glossary definitions.
|
||||
|
||||
---
|
||||
|
||||
## Technical Architecture Summary
|
||||
|
||||
- **Authentication / Authorization**: JWT tokens issued via NestJS Passport strategy (`backend/src/auth`). Supported roles in schema: `User_PetOwner` and `Admin`.
|
||||
- **API Architecture**: REST endpoints documented via NestJS Swagger OpenAPI (`backend/src/main.ts`) and root `swagger.yml`.
|
||||
- **Database & Migration**: PostgreSQL managed by Prisma ORM (`backend/prisma/schema.prisma`).
|
||||
- **Container Infrastructure**: Multi-stage Dockerfiles and `docker-compose.yml` for `frontend`, `backend`, `postgres`, `redis`, `nginx`, and `prometheus`.
|
||||
|
||||
---
|
||||
|
||||
## Evidence-Based Status Matrix
|
||||
|
||||
| Component / Finding | Classification | Supporting Evidence |
|
||||
| :--- | :--- | :--- |
|
||||
| Root Storefront App (React 19 + Vite 6) | Active Application | `package.json`, `vite.config.ts`, `src/App.tsx` |
|
||||
| NestJS Backend Service | Active Application | `backend/package.json`, `backend/prisma/schema.prisma`, `backend/src/main.ts` |
|
||||
| PostgreSQL & Prisma ORM Layer | Active Database Layer | `backend/prisma/schema.prisma`, `backend/package.json` |
|
||||
| Redis In-Memory Store | Active Cache/Throttle | `backend/src/redis/`, `backend/package.json` |
|
||||
| Docker Compose Environment | Active Infrastructure | `docker-compose.yml`, `Dockerfile`, `backend/Dockerfile` |
|
||||
| `frontend/application` | Excluded Placeholder | Lacks `package.json` and `src/`. Contains only build artifacts (`.next/`, `next-env.d.ts`). |
|
||||
| `frontend/admin-panel` | Excluded Placeholder | Lacks `package.json` and `src/`. Contains only `node_modules/`. |
|
||||
| Standalone Admin Panel App | Non-Existent | No source tree found in `frontend/admin-panel` or `src/components/Admin*`. Admin API capabilities exist in backend (`backend/src/settings`). |
|
||||
| Live Payment Gateway Integration | Inferred / Unknown | Frontend relies on mock checkout; schema supports transactions. |
|
||||
| Automated CI/CD Pipelines | Missing / Unknown | No `.github/workflows` or equivalent discovered. |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user