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
|
.env
|
||||||
.git
|
.git
|
||||||
.dockerignore
|
.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:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: canina
|
runs-on: canina
|
||||||
timeout-minutes: 20
|
|
||||||
steps:
|
steps:
|
||||||
- name: Setup SSH key
|
- name: Run deploy.sh from within the scripts folder
|
||||||
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
|
|
||||||
env:
|
env:
|
||||||
BRANCH: ${{ github.ref_name }}
|
BRANCH: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
echo "Deploying branch: ${BRANCH}"
|
# 1. رفع مشکل DNS
|
||||||
ssh -o StrictHostKeyChecking=no -p 2234 devops@172.17.0.1 \
|
echo "87.248.133.138 git.parsaaghayi.ir" >> /etc/hosts
|
||||||
"bash /opt/services/scripts/with-vpn.sh \"bash /opt/services/canina/deploy.sh ${BRANCH}\""
|
|
||||||
|
# 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
|
FROM node:20-alpine AS app-builder
|
||||||
|
USER root
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV NEXT_PUBLIC_API_URL=https://api.canina.ir/api
|
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 ./
|
COPY frontend/application/package*.json ./
|
||||||
RUN npm ci --include=dev --prefer-offline --no-audit
|
RUN npm ci --include=dev --prefer-offline --no-audit
|
||||||
COPY frontend/application ./
|
COPY frontend/application ./
|
||||||
@ -10,8 +19,17 @@ ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:20-alpine AS admin-builder
|
FROM node:20-alpine AS admin-builder
|
||||||
|
USER root
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV VITE_API_URL=https://api.canina.ir/api
|
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 ./
|
COPY frontend/admin-panel/package*.json ./
|
||||||
RUN npm ci --include=dev --prefer-offline --no-audit
|
RUN npm ci --include=dev --prefer-offline --no-audit
|
||||||
COPY frontend/admin-panel ./
|
COPY frontend/admin-panel ./
|
||||||
@ -20,6 +38,7 @@ ENV VITE_API_URL=$VITE_API_URL
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
USER root
|
||||||
RUN addgroup -S nginx 2>/dev/null || true && adduser -S nginx -G nginx 2>/dev/null || true
|
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
|
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
|
COPY --from=nginx:alpine /etc/nginx /etc/nginx
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
|
node_modules
|
||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
.git
|
.git
|
||||||
.dockerignore
|
.dockerignore
|
||||||
Dockerfile
|
Dockerfile
|
||||||
|
npm-debug.log
|
||||||
|
|||||||
@ -1,14 +1,25 @@
|
|||||||
FROM node:20-alpine AS builder
|
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
|
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 ./
|
COPY package*.json ./
|
||||||
RUN npm ci --prefer-offline --no-audit
|
RUN npm ci --prefer-offline --no-audit
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV PRISMA_CLI_BINARY_TARGETS=linux-musl-openssl-3.0.x
|
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
|
||||||
RUN npx prisma generate && npm run build
|
|
||||||
|
|
||||||
FROM node:20-alpine
|
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
|
RUN mkdir -p /app/uploads && chown node:node /app/uploads
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --chown=node:node --from=builder /app/package*.json ./
|
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 ./prisma
|
||||||
COPY --chown=node:node --from=builder /app/prisma/tsconfig.seed.json ./prisma/tsconfig.seed.json
|
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
|
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
|
EXPOSE 3000
|
||||||
USER node
|
USER node
|
||||||
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main"]
|
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main"]
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint';
|
|||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: ['eslint.config.mjs'],
|
ignores: ['eslint.config.mjs', 'dist/**'],
|
||||||
},
|
},
|
||||||
eslint.configs.recommended,
|
eslint.configs.recommended,
|
||||||
...tseslint.configs.recommendedTypeChecked,
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
@ -29,7 +29,18 @@ export default tseslint.config(
|
|||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'@typescript-eslint/no-floating-promises': 'warn',
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
'@typescript-eslint/no-unsafe-argument': '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",
|
"class-validator": "^0.15.1",
|
||||||
"helmet": "^8.2.0",
|
"helmet": "^8.2.0",
|
||||||
"ioredis": "^5.11.0",
|
"ioredis": "^5.11.0",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@ -39,6 +40,7 @@
|
|||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^24.12.4",
|
"@types/node": "^24.12.4",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
@ -2969,6 +2971,13 @@
|
|||||||
"pretty-format": "^30.0.0"
|
"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": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
|
|||||||
@ -17,7 +17,8 @@
|
|||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"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": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
@ -34,6 +35,7 @@
|
|||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"helmet": "^8.2.0",
|
"helmet": "^8.2.0",
|
||||||
"ioredis": "^5.11.0",
|
"ioredis": "^5.11.0",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@ -50,6 +52,7 @@
|
|||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/multer": "^2.1.0",
|
"@types/multer": "^2.1.0",
|
||||||
"@types/node": "^24.12.4",
|
"@types/node": "^24.12.4",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
|||||||
@ -9,23 +9,25 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
firstName String @map("first_name") @db.VarChar(100)
|
firstName String @map("first_name") @db.VarChar(100)
|
||||||
lastName String @map("last_name") @db.VarChar(100)
|
lastName String @map("last_name") @db.VarChar(100)
|
||||||
email String? @unique @db.VarChar(150)
|
email String? @unique @db.VarChar(150)
|
||||||
mobile String @unique @db.VarChar(15)
|
mobile String @unique @db.VarChar(15)
|
||||||
password String? @db.VarChar(255)
|
password String? @db.VarChar(255)
|
||||||
role String @default("User_PetOwner") @db.VarChar(30)
|
role String @default("User_PetOwner") @db.VarChar(30)
|
||||||
walletBalance Decimal @default(0.00) @map("wallet_balance") @db.Decimal(15, 2)
|
walletBalance Decimal @default(0.00) @map("wallet_balance") @db.Decimal(15, 2)
|
||||||
charityDonationTotal Decimal @default(0.00) @map("charity_donation_total") @db.Decimal(15, 2)
|
charityDonationTotal Decimal @default(0.00) @map("charity_donation_total") @db.Decimal(15, 2)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz()
|
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz()
|
||||||
|
|
||||||
addresses UserAddress[]
|
addresses UserAddress[]
|
||||||
walletTransactions WalletTransaction[]
|
walletTransactions WalletTransaction[]
|
||||||
pets Pet[]
|
pets Pet[]
|
||||||
orders Order[]
|
orders Order[]
|
||||||
blogs Blog[]
|
blogs Blog[]
|
||||||
|
prescriptions Prescription[]
|
||||||
|
partnerAccount PartnerAccount?
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@ -43,7 +45,7 @@ model UserAddress {
|
|||||||
isDefault Boolean @default(false) @map("is_default")
|
isDefault Boolean @default(false) @map("is_default")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@map("user_addresses")
|
@@map("user_addresses")
|
||||||
@ -59,18 +61,22 @@ model WalletTransaction {
|
|||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@map("wallet_transactions")
|
@@map("wallet_transactions")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Media {
|
model Media {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
filename String @db.VarChar(200)
|
filename String @db.VarChar(200)
|
||||||
url String @db.Text
|
url String @db.Text
|
||||||
mimetype String @db.VarChar(50)
|
mimetype String @db.VarChar(50)
|
||||||
size Int
|
size Int
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
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")
|
@@map("media")
|
||||||
}
|
}
|
||||||
@ -87,9 +93,9 @@ model Category {
|
|||||||
imageUrl String? @map("image_url") @db.Text
|
imageUrl String? @map("image_url") @db.Text
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||||
|
|
||||||
parent Category? @relation("SubCategories", fields: [parentId], references: [id])
|
parent Category? @relation("SubCategories", fields: [parentId], references: [id])
|
||||||
children Category[] @relation("SubCategories")
|
children Category[] @relation("SubCategories")
|
||||||
products Product[]
|
products Product[]
|
||||||
|
|
||||||
@@map("categories")
|
@@map("categories")
|
||||||
}
|
}
|
||||||
@ -108,7 +114,7 @@ model Blog {
|
|||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||||
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz()
|
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz()
|
||||||
|
|
||||||
author User @relation(fields: [authorId], references: [id])
|
author User @relation(fields: [authorId], references: [id])
|
||||||
|
|
||||||
@@map("blogs")
|
@@map("blogs")
|
||||||
}
|
}
|
||||||
@ -135,20 +141,25 @@ model Product {
|
|||||||
unit String @db.VarChar(50)
|
unit String @db.VarChar(50)
|
||||||
packageSize Decimal @map("package_size") @db.Decimal(10, 2)
|
packageSize Decimal @map("package_size") @db.Decimal(10, 2)
|
||||||
dosageLogic String? @map("dosage_logic") @db.Text
|
dosageLogic String? @map("dosage_logic") @db.Text
|
||||||
|
dosageConfig Json? @map("dosage_config")
|
||||||
suitableFor String @map("suitable_for") @db.VarChar(15) // سگ, گربه, هر دو
|
suitableFor String @map("suitable_for") @db.VarChar(15) // سگ, گربه, هر دو
|
||||||
imageUrl String @map("image_url") @db.Text
|
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)
|
metaTitle String? @map("meta_title") @db.VarChar(200)
|
||||||
metaDescription String? @map("meta_description") @db.Text
|
metaDescription String? @map("meta_description") @db.Text
|
||||||
canonicalUrl String? @map("canonical_url") @db.Text
|
canonicalUrl String? @map("canonical_url") @db.Text
|
||||||
keywords String? @db.Text
|
keywords String? @db.Text
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||||
|
|
||||||
category Category @relation(fields: [categoryId], references: [id])
|
category Category @relation(fields: [categoryId], references: [id])
|
||||||
ingredientList ProductIngredient[]
|
ingredientList ProductIngredient[]
|
||||||
symptoms ProductSymptom[]
|
symptoms ProductSymptom[]
|
||||||
reminders Reminder[]
|
reminders Reminder[]
|
||||||
orderItems OrderItem[]
|
orderItems OrderItem[]
|
||||||
advisorRules SmartAdvisorRule[]
|
advisorRules SmartAdvisorRule[]
|
||||||
|
|
||||||
@@index([categorySlug])
|
@@index([categorySlug])
|
||||||
@@index([suitableFor])
|
@@index([suitableFor])
|
||||||
@ -158,6 +169,18 @@ model Product {
|
|||||||
@@map("products")
|
@@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 {
|
model ProductIngredient {
|
||||||
productId String @map("product_id") @db.Uuid
|
productId String @map("product_id") @db.Uuid
|
||||||
ingredient String @db.VarChar(150)
|
ingredient String @db.VarChar(150)
|
||||||
@ -177,21 +200,22 @@ model ProductSymptom {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Pet {
|
model Pet {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
name String @db.VarChar(100)
|
name String @db.VarChar(100)
|
||||||
type String @db.VarChar(10) // سگ, گربه
|
type String @db.VarChar(10) // سگ, گربه
|
||||||
breed String @db.VarChar(100)
|
breed String @db.VarChar(100)
|
||||||
age Int
|
age Int
|
||||||
weight Decimal @db.Decimal(5, 2)
|
weight Decimal @db.Decimal(5, 2)
|
||||||
activityLevel String @map("activity_level") @db.VarChar(15) // کم, متوسط, زیاد
|
activityLevel String @map("activity_level") @db.VarChar(15) // کم, متوسط, زیاد
|
||||||
imageUrl String? @map("image_url") @db.Text
|
imageUrl String? @map("image_url") @db.Text
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
medicalConditions PetMedicalCondition[]
|
medicalConditions PetMedicalCondition[]
|
||||||
reminders Reminder[]
|
reminders Reminder[]
|
||||||
healthLogs HealthLog[]
|
healthLogs HealthLog[]
|
||||||
|
prescriptions Prescription[]
|
||||||
|
|
||||||
@@map("pets")
|
@@map("pets")
|
||||||
}
|
}
|
||||||
@ -206,13 +230,13 @@ model PetMedicalCondition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Reminder {
|
model Reminder {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
petId String @map("pet_id") @db.Uuid
|
petId String @map("pet_id") @db.Uuid
|
||||||
productId String? @map("product_id") @db.Uuid
|
productId String? @map("product_id") @db.Uuid
|
||||||
title String @db.VarChar(150)
|
title String @db.VarChar(150)
|
||||||
time String @db.VarChar(5) // 08:30
|
time String @db.VarChar(5) // 08:30
|
||||||
frequency String @db.VarChar(20) // روزانه, هفتگی
|
frequency String @db.VarChar(20) // روزانه, هفتگی
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||||
|
|
||||||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||||||
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
|
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
|
||||||
@ -228,7 +252,7 @@ model ReminderCompletion {
|
|||||||
completedDate DateTime @map("completed_date") @db.Date
|
completedDate DateTime @map("completed_date") @db.Date
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||||
|
|
||||||
reminder Reminder @relation(fields: [reminderId], references: [id], onDelete: Cascade)
|
reminder Reminder @relation(fields: [reminderId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([reminderId, completedDate])
|
@@unique([reminderId, completedDate])
|
||||||
@@index([reminderId, completedDate])
|
@@index([reminderId, completedDate])
|
||||||
@ -236,68 +260,68 @@ model ReminderCompletion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model HealthLog {
|
model HealthLog {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
petId String @map("pet_id") @db.Uuid
|
petId String @map("pet_id") @db.Uuid
|
||||||
appetite String @db.VarChar(15)
|
appetite String @db.VarChar(15)
|
||||||
energy String @db.VarChar(15)
|
energy String @db.VarChar(15)
|
||||||
digestion String @db.VarChar(15)
|
digestion String @db.VarChar(15)
|
||||||
note String? @db.Text
|
note String? @db.Text
|
||||||
loggedDate DateTime @default(now()) @map("logged_date") @db.Date
|
loggedDate DateTime @default(now()) @map("logged_date") @db.Date
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||||
|
|
||||||
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@map("health_logs")
|
@@map("health_logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Coupon {
|
model Coupon {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
code String @unique @db.VarChar(50)
|
code String @unique @db.VarChar(50)
|
||||||
type String @default("percent") @db.VarChar(20) // fixed, percent
|
type String @default("percent") @db.VarChar(20) // fixed, percent
|
||||||
value Decimal @db.Decimal(15, 2)
|
value Decimal @db.Decimal(15, 2)
|
||||||
minCartValue Decimal? @map("min_cart_value") @db.Decimal(15, 2)
|
minCartValue Decimal? @map("min_cart_value") @db.Decimal(15, 2)
|
||||||
maxCartValue Decimal? @map("max_cart_value") @db.Decimal(15, 2)
|
maxCartValue Decimal? @map("max_cart_value") @db.Decimal(15, 2)
|
||||||
maxUses Int? @map("max_uses")
|
maxUses Int? @map("max_uses")
|
||||||
usedCount Int @default(0) @map("used_count")
|
usedCount Int @default(0) @map("used_count")
|
||||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz()
|
expiresAt DateTime? @map("expires_at") @db.Timestamptz()
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||||
|
|
||||||
orders Order[]
|
orders Order[]
|
||||||
targets CouponTarget[]
|
targets CouponTarget[]
|
||||||
|
|
||||||
@@map("coupons")
|
@@map("coupons")
|
||||||
}
|
}
|
||||||
|
|
||||||
model CouponTarget {
|
model CouponTarget {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
couponId String @map("coupon_id") @db.Uuid
|
couponId String @map("coupon_id") @db.Uuid
|
||||||
targetType String @map("target_type") @db.VarChar(50) // USER, PET, ROLE, PRODUCT, CATEGORY
|
targetType String @map("target_type") @db.VarChar(50) // USER, PET, ROLE, PRODUCT, CATEGORY
|
||||||
targetId String @map("target_id") @db.VarChar(100) // UUID or role name
|
targetId String @map("target_id") @db.VarChar(100) // UUID or role name
|
||||||
modifierType String @default("override") @map("modifier_type") @db.VarChar(20) // override, add, subtract
|
modifierType String @default("override") @map("modifier_type") @db.VarChar(20) // override, add, subtract
|
||||||
modifierValue Decimal? @map("modifier_value") @db.Decimal(15, 2)
|
modifierValue Decimal? @map("modifier_value") @db.Decimal(15, 2)
|
||||||
|
|
||||||
coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Cascade)
|
coupon Coupon @relation(fields: [couponId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([targetType, targetId])
|
@@index([targetType, targetId])
|
||||||
@@map("coupon_targets")
|
@@map("coupon_targets")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Order {
|
model Order {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
userId String @map("user_id") @db.Uuid
|
userId String @map("user_id") @db.Uuid
|
||||||
couponId String? @map("coupon_id") @db.Uuid
|
couponId String? @map("coupon_id") @db.Uuid
|
||||||
totalAmount Decimal @map("total_amount") @db.Decimal(15, 2)
|
totalAmount Decimal @map("total_amount") @db.Decimal(15, 2)
|
||||||
charityDonation Decimal @default(0.00) @map("charity_donation") @db.Decimal(15, 2)
|
charityDonation Decimal @default(0.00) @map("charity_donation") @db.Decimal(15, 2)
|
||||||
isRefill Boolean @default(false) @map("is_refill")
|
isRefill Boolean @default(false) @map("is_refill")
|
||||||
refillIntervalDays Int? @map("refill_interval_days")
|
refillIntervalDays Int? @map("refill_interval_days")
|
||||||
status String @default("processing") @db.VarChar(30) // processing, shipped, delivered
|
status String @default("processing") @db.VarChar(30) // processing, shipped, delivered
|
||||||
trackingNumber String? @unique @map("tracking_number") @db.VarChar(100)
|
trackingNumber String? @unique @map("tracking_number") @db.VarChar(100)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
coupon Coupon? @relation(fields: [couponId], references: [id])
|
coupon Coupon? @relation(fields: [couponId], references: [id])
|
||||||
orderItems OrderItem[]
|
orderItems OrderItem[]
|
||||||
|
|
||||||
@@map("orders")
|
@@map("orders")
|
||||||
}
|
}
|
||||||
@ -310,8 +334,8 @@ model OrderItem {
|
|||||||
doseQty Decimal? @map("dose_qty") @db.Decimal(10, 2)
|
doseQty Decimal? @map("dose_qty") @db.Decimal(10, 2)
|
||||||
doseUnit String? @map("dose_unit") @db.VarChar(50)
|
doseUnit String? @map("dose_unit") @db.VarChar(50)
|
||||||
|
|
||||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
|
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@map("order_items")
|
@@map("order_items")
|
||||||
}
|
}
|
||||||
@ -324,8 +348,8 @@ model UiText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model ScientificTerm {
|
model ScientificTerm {
|
||||||
key String @id @db.VarChar(100)
|
key String @id @db.VarChar(100)
|
||||||
term String @db.VarChar(150)
|
term String @db.VarChar(150)
|
||||||
definition String @db.Text
|
definition String @db.Text
|
||||||
wikiId String @map("wiki_id") @db.VarChar(50)
|
wikiId String @map("wiki_id") @db.VarChar(50)
|
||||||
metaTitle String? @map("meta_title") @db.VarChar(200)
|
metaTitle String? @map("meta_title") @db.VarChar(200)
|
||||||
@ -336,42 +360,42 @@ model ScientificTerm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model HeroBanner {
|
model HeroBanner {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
title String @db.VarChar(200)
|
title String @db.VarChar(200)
|
||||||
subtitle String? @db.Text
|
subtitle String? @db.Text
|
||||||
imageUrl String @map("image_url") @db.Text
|
imageUrl String @map("image_url") @db.Text
|
||||||
buttonText String? @map("button_text") @db.VarChar(100)
|
buttonText String? @map("button_text") @db.VarChar(100)
|
||||||
buttonLink String? @map("button_link") @db.Text
|
buttonLink String? @map("button_link") @db.Text
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
order Int @default(0)
|
order Int @default(0)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||||
|
|
||||||
@@map("hero_banners")
|
@@map("hero_banners")
|
||||||
}
|
}
|
||||||
|
|
||||||
model VetTestimonial {
|
model VetTestimonial {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
vetName String @map("vet_name") @db.VarChar(150)
|
vetName String @map("vet_name") @db.VarChar(150)
|
||||||
clinicName String? @map("clinic_name") @db.VarChar(150)
|
clinicName String? @map("clinic_name") @db.VarChar(150)
|
||||||
imageUrl String? @map("image_url") @db.Text
|
imageUrl String? @map("image_url") @db.Text
|
||||||
quote String @db.Text
|
quote String @db.Text
|
||||||
rating Int @default(5)
|
rating Int @default(5)
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
order Int @default(0)
|
order Int @default(0)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||||
|
|
||||||
@@map("vet_testimonials")
|
@@map("vet_testimonials")
|
||||||
}
|
}
|
||||||
|
|
||||||
model SmartAdvisorRule {
|
model SmartAdvisorRule {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
condition String @db.VarChar(200)
|
condition String @db.VarChar(200)
|
||||||
targetPetType String? @map("target_pet_type") @db.VarChar(50)
|
targetPetType String? @map("target_pet_type") @db.VarChar(50)
|
||||||
recommendedProduct String @map("recommended_product_id") @db.Uuid
|
recommendedProduct String @map("recommended_product_id") @db.Uuid
|
||||||
reason String @db.Text
|
reason String @db.Text
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
|
||||||
|
|
||||||
product Product @relation(fields: [recommendedProduct], references: [id], onDelete: Cascade)
|
product Product @relation(fields: [recommendedProduct], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@map("smart_advisor_rules")
|
@@map("smart_advisor_rules")
|
||||||
}
|
}
|
||||||
@ -391,3 +415,138 @@ model Video {
|
|||||||
|
|
||||||
@@map("videos")
|
@@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({
|
const createdProduct = await prisma.product.upsert({
|
||||||
where: { artNo },
|
where: { artNo },
|
||||||
update: {
|
update: {
|
||||||
|
slug: artNo,
|
||||||
nameFa: name,
|
nameFa: name,
|
||||||
nameEn: name,
|
nameEn: name,
|
||||||
scientificTagline,
|
scientificTagline,
|
||||||
|
|||||||
@ -50,16 +50,24 @@ function getProductImageUrl(baseSlug: string): string {
|
|||||||
'https://www.canina.de/media/01/be/93/710003_Flexan_Canina-Pharma_1280x1280.png',
|
'https://www.canina.de/media/01/be/93/710003_Flexan_Canina-Pharma_1280x1280.png',
|
||||||
'canina-herz-vital':
|
'canina-herz-vital':
|
||||||
'https://www.canina.de/media/b9/8b/4c/112036_Herz_Vital_Canina-Pharma_1280x1280.png',
|
'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':
|
'canina-immun-booster-paste':
|
||||||
'https://www.canina.de/media/8c/fb/8e/311000_Canino_Immun_Booster_Paste_Abwehrkraefte_1280x1280.png',
|
'https://www.canina.de/media/8c/fb/8e/311000_Canino_Immun_Booster_Paste_Abwehrkraefte_1280x1280.png',
|
||||||
'canina-katzenmilch':
|
'canina-katzenmilch':
|
||||||
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
|
'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':
|
'canina-lachs-l':
|
||||||
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
|
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
|
||||||
'canina-lachs-ol':
|
'canina-lachs-ol':
|
||||||
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
|
'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':
|
'canina-marine-lmischung-premium':
|
||||||
'https://www.canina.de/media/ad/28/71/153008_Marine_Oelmischung_Premium_Canina-Pharma_1280x1280.png',
|
'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':
|
'canina-moortrnke':
|
||||||
'https://www.canina.de/media/9d/b2/87/112708_Moortraenke_Canina-Pharma_1280x1280.png',
|
'https://www.canina.de/media/9d/b2/87/112708_Moortraenke_Canina-Pharma_1280x1280.png',
|
||||||
'canina-petvital-arthro-tabletten':
|
'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',
|
'https://www.canina.de/media/14/d0/0d/792016_791514_Rinderblut_Pulver_Canina-Pharma_1280x1280.png',
|
||||||
'canina-rinderfett-pulver':
|
'canina-rinderfett-pulver':
|
||||||
'https://www.canina.de/media/9a/31/59/131235_Rinderfett_Pulver_Canina-Pharma_1280x1280.png',
|
'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':
|
'canina-schwarz-kmmel-samen':
|
||||||
'https://www.canina.de/media/a9/c8/aa/131105_Schwarzkuemmelsamen_Canina-Pharma_1280x1280.png',
|
'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':
|
'canina-seealgen-bio-seealgenmehl':
|
||||||
'https://www.canina.de/media/e4/c4/b2/130504_130511_130412_Seealgen_Bio-Seealgenmehl_Canina-Pharma_1280x1280.png',
|
'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':
|
'canina-taurin-fr-katzen':
|
||||||
'https://www.canina.de/media/c6/aa/62/229505_Taurin_fuer_Katzen_Canina-Pharma_1280x1280.png',
|
'https://www.canina.de/media/c6/aa/62/229505_Taurin_fuer_Katzen_Canina-Pharma_1280x1280.png',
|
||||||
'canina-velox-gelenkenergie':
|
'canina-velox-gelenkenergie':
|
||||||
@ -101,7 +119,10 @@ function getProductImageUrl(baseSlug: string): string {
|
|||||||
'canina-novagard-green-pfotenpflege':
|
'canina-novagard-green-pfotenpflege':
|
||||||
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
|
'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(
|
async function findOrCreateCategory(
|
||||||
|
|||||||
@ -3,6 +3,13 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
const UI_TEXTS: Record<string, string> = {
|
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 ===
|
// === Header / Navigation ===
|
||||||
"shipping_notice": "ارسال رایگان برای سفارشهای بالای ۱۵۰ هزار تومان",
|
"shipping_notice": "ارسال رایگان برای سفارشهای بالای ۱۵۰ هزار تومان",
|
||||||
"brand_name_fa": "ایران",
|
"brand_name_fa": "ایران",
|
||||||
@ -131,10 +138,11 @@ async function main() {
|
|||||||
const entries = Object.entries(UI_TEXTS);
|
const entries = Object.entries(UI_TEXTS);
|
||||||
|
|
||||||
for (const [key, value] of entries) {
|
for (const [key, value] of entries) {
|
||||||
const existing = await prisma.uiText.findUnique({ where: { key } });
|
await prisma.uiText.upsert({
|
||||||
if (!existing) {
|
where: { key },
|
||||||
await prisma.uiText.create({ data: { key, value } });
|
update: { value },
|
||||||
}
|
create: { key, value },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Seeded ${entries.length} UI texts.`);
|
console.log(`Seeded ${entries.length} UI texts.`);
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as ts from 'typescript';
|
import * as ts from 'typescript';
|
||||||
import * as vm from 'vm';
|
import * as vm from 'vm';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@ -29,16 +30,21 @@ async function main() {
|
|||||||
console.log('Database successfully wiped.');
|
console.log('Database successfully wiped.');
|
||||||
|
|
||||||
console.log('Creating Admin User...');
|
console.log('Creating Admin User...');
|
||||||
|
const adminHashedPassword = await bcrypt.hash('admin123', 10);
|
||||||
await prisma.user.upsert({
|
await prisma.user.upsert({
|
||||||
where: { id: '12345678-1234-1234-1234-123456789012' },
|
where: { email: 'admin@canino-iran.com' },
|
||||||
update: {},
|
update: {
|
||||||
|
password: adminHashedPassword,
|
||||||
|
role: 'Admin',
|
||||||
|
},
|
||||||
create: {
|
create: {
|
||||||
id: '12345678-1234-1234-1234-123456789012',
|
id: '12345678-1234-1234-1234-123456789012',
|
||||||
firstName: 'Admin',
|
firstName: 'مدیر',
|
||||||
lastName: 'System',
|
lastName: 'سیستم',
|
||||||
email: 'admin@canino.ir',
|
email: 'admin@canino-iran.com',
|
||||||
role: 'ADMIN',
|
password: adminHashedPassword,
|
||||||
mobile: '09000000000'
|
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([
|
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...');
|
console.log('Seeding Products & Categories...');
|
||||||
try {
|
try {
|
||||||
const seedProducts = require('./seed-products');
|
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 { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { AdminController } from './admin.controller';
|
import { AdminController } from './admin.controller';
|
||||||
|
import { AdminService } from './admin.service';
|
||||||
|
|
||||||
describe('AdminController', () => {
|
describe('AdminController', () => {
|
||||||
let controller: AdminController;
|
let controller: AdminController;
|
||||||
@ -7,6 +8,7 @@ describe('AdminController', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [AdminController],
|
controllers: [AdminController],
|
||||||
|
providers: [{ provide: AdminService, useValue: {} }],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
controller = module.get<AdminController>(AdminController);
|
controller = module.get<AdminController>(AdminController);
|
||||||
|
|||||||
@ -1,15 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
UseGuards,
|
|
||||||
Param,
|
|
||||||
Put,
|
|
||||||
Post,
|
Post,
|
||||||
Body,
|
Put,
|
||||||
Delete,
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
Query,
|
Query,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} 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 { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
@ -18,102 +19,85 @@ import {
|
|||||||
ApiQuery,
|
ApiQuery,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
@ApiTags('Admin - پنل مدیریت')
|
@ApiTags('Admin - مدیریت سیستم')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
constructor(private readonly adminService: AdminService) {}
|
constructor(private readonly adminService: AdminService) {}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Get('dashboard/stats')
|
@Get('dashboard/stats')
|
||||||
@ApiOperation({ summary: 'دریافت آمار کلی داشبورد' })
|
@ApiOperation({ summary: 'داشبورد مدیریت و آمار کلیدی سیستم' })
|
||||||
async getDashboardStats() {
|
async getDashboardStats() {
|
||||||
const stats = await this.adminService.getDashboardStats();
|
const data = await this.adminService.getDashboardStats();
|
||||||
return {
|
return { success: true, data };
|
||||||
success: true,
|
|
||||||
data: stats,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Get('users')
|
@Get('users')
|
||||||
@ApiOperation({ summary: 'لیست کاربران' })
|
@ApiOperation({ summary: 'لیست کاربران سیستم (با صفحهبندی)' })
|
||||||
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
||||||
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
||||||
@ApiQuery({
|
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
|
||||||
name: 'search',
|
async getUsers(@Query() query: AdminQueryDto) {
|
||||||
required: false,
|
const result = await this.adminService.getUsers(query);
|
||||||
description: 'جستجو در نام یا ایمیل',
|
return { success: true, ...result };
|
||||||
})
|
}
|
||||||
@ApiQuery({
|
|
||||||
name: 'role',
|
@Get('users/:id')
|
||||||
required: false,
|
@ApiOperation({ summary: 'جزئیات کاربر' })
|
||||||
description: 'فیلتر بر اساس نقش کاربر',
|
async getUserDetails(@Param('id') id: string) {
|
||||||
})
|
const data = await this.adminService.getUserDetails(id);
|
||||||
async getUsers(
|
return { success: true, data };
|
||||||
@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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Put('users/:id/role')
|
@Put('users/:id/role')
|
||||||
@ApiOperation({ summary: 'تغییر نقش کاربر' })
|
@ApiOperation({ summary: 'تغییر نقش کاربر' })
|
||||||
async updateUserRole(@Param('id') id: string, @Body('role') role: string) {
|
async updateUserRole(@Param('id') id: string, @Body('role') role: string) {
|
||||||
const user = await this.adminService.updateUserRole(id, role);
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: user,
|
message: 'موجود کیف پول با موفقیت بروزرسانی شد',
|
||||||
|
data: result,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Get('products')
|
@Get('products')
|
||||||
@ApiOperation({ summary: 'لیست محصولات (مدیریت)' })
|
@ApiOperation({ summary: 'لیست محصولات (مدیریت)' })
|
||||||
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
||||||
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
||||||
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
|
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
|
||||||
@ApiQuery({
|
async getProducts(@Query() query: AdminQueryDto) {
|
||||||
name: 'categoryId',
|
const result = await this.adminService.getProducts(query);
|
||||||
required: false,
|
return { success: true, ...result };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Post('products')
|
@Post('products')
|
||||||
@ApiOperation({ summary: 'ایجاد محصول جدید' })
|
@ApiOperation({ summary: 'ایجاد محصول جدید' })
|
||||||
async createProduct(@Body() data: any) {
|
async createProduct(@Body() data: ProductInput) {
|
||||||
const product = await this.adminService.createProduct(data);
|
const product = await this.adminService.createProduct(data);
|
||||||
return { success: true, data: product };
|
return { success: true, data: product };
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Put('products/:id')
|
@Put('products/:id')
|
||||||
@ApiOperation({ summary: 'ویرایش محصول' })
|
@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);
|
const product = await this.adminService.updateProduct(id, data);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@ -121,87 +105,62 @@ export class AdminController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Delete('products/:id')
|
@Delete('products/:id')
|
||||||
@ApiOperation({ summary: 'حذف محصول' })
|
@ApiOperation({ summary: 'حذف محصول' })
|
||||||
async deleteProduct(@Param('id') id: string) {
|
async deleteProduct(@Param('id') id: string) {
|
||||||
await this.adminService.deleteProduct(id);
|
await this.adminService.deleteProduct(id);
|
||||||
return {
|
return { success: true };
|
||||||
success: true,
|
|
||||||
message: 'Product deleted',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Get('orders')
|
@Get('orders')
|
||||||
@ApiOperation({ summary: 'لیست سفارشها' })
|
@ApiOperation({ summary: 'لیست سفارشها (مدیریت)' })
|
||||||
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
||||||
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
||||||
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
|
@ApiQuery({ name: 'status', required: false, description: 'فیلتر وضعیت' })
|
||||||
@ApiQuery({ name: 'status', required: false, description: 'وضعیت سفارش' })
|
async getOrders(@Query() query: AdminQueryDto) {
|
||||||
async getOrders(
|
const result = await this.adminService.getOrders(query);
|
||||||
@Query('page') page?: string,
|
return { success: true, ...result };
|
||||||
@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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Put('orders/:id/status')
|
@Put('orders/:id/status')
|
||||||
@ApiOperation({ summary: 'تغییر وضعیت و کد رهگیری سفارش' })
|
@ApiOperation({ summary: 'بروزرسانی وضعیت سفارش' })
|
||||||
async updateOrderStatus(
|
async updateOrderStatus(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body('status') status: string,
|
@Body('status') status: string,
|
||||||
@Body('trackingNumber') trackingNumber?: string,
|
@Body('trackingCode') trackingCode?: string,
|
||||||
) {
|
) {
|
||||||
const order = await this.adminService.updateOrderStatus(
|
const order = await this.adminService.updateOrderStatus(
|
||||||
id,
|
id,
|
||||||
status,
|
status,
|
||||||
trackingNumber,
|
trackingCode,
|
||||||
);
|
);
|
||||||
return {
|
return { success: true, data: order };
|
||||||
success: true,
|
|
||||||
data: order,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Coupons ---
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Get('coupons')
|
@Get('coupons')
|
||||||
@ApiOperation({ summary: 'لیست کد تخفیفها' })
|
@ApiOperation({ summary: 'لیست کد تخفیفها' })
|
||||||
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
||||||
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
||||||
@ApiQuery({ name: 'search', 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);
|
const coupons = await this.adminService.getCoupons(query);
|
||||||
return { success: true, ...coupons };
|
return { success: true, ...coupons };
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Post('coupons')
|
@Post('coupons')
|
||||||
@ApiOperation({ summary: 'ایجاد کد تخفیف جدید' })
|
@ApiOperation({ summary: 'ایجاد کد تخفیف جدید' })
|
||||||
async createCoupon(@Body() data: any) {
|
async createCoupon(@Body() data: CouponInput) {
|
||||||
const coupon = await this.adminService.createCoupon(data);
|
const coupon = await this.adminService.createCoupon(data);
|
||||||
return { success: true, data: coupon };
|
return { success: true, data: coupon };
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Put('coupons/:id')
|
@Put('coupons/:id')
|
||||||
@ApiOperation({ summary: 'ویرایش کد تخفیف' })
|
@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);
|
const coupon = await this.adminService.updateCoupon(id, data);
|
||||||
return { success: true, data: coupon };
|
return { success: true, data: coupon };
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Put('coupons/:id/toggle')
|
@Put('coupons/:id/toggle')
|
||||||
@ApiOperation({ summary: 'فعال/غیرفعال کردن کد تخفیف' })
|
@ApiOperation({ summary: 'فعال/غیرفعال کردن کد تخفیف' })
|
||||||
async toggleCoupon(
|
async toggleCoupon(
|
||||||
@ -212,7 +171,6 @@ export class AdminController {
|
|||||||
return { success: true, data: coupon };
|
return { success: true, data: coupon };
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Delete('coupons/:id')
|
@Delete('coupons/:id')
|
||||||
@ApiOperation({ summary: 'حذف کد تخفیف' })
|
@ApiOperation({ summary: 'حذف کد تخفیف' })
|
||||||
async deleteCoupon(@Param('id') id: string) {
|
async deleteCoupon(@Param('id') id: string) {
|
||||||
@ -220,20 +178,64 @@ export class AdminController {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Settings ---
|
@Get('doctors')
|
||||||
@UseGuards(JwtAuthGuard)
|
@ApiOperation({ summary: 'لیست پزشکان و متخصصین' })
|
||||||
@Get('settings')
|
async getDoctors() {
|
||||||
@ApiOperation({ summary: 'دریافت تنظیمات' })
|
const data = await this.adminService.getDoctors();
|
||||||
async getSettings() {
|
return { success: true, data };
|
||||||
const settings = await this.adminService.getSettings();
|
}
|
||||||
return { success: true, data: settings };
|
|
||||||
|
@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')
|
@Put('settings')
|
||||||
@ApiOperation({ summary: 'ذخیره تنظیمات' })
|
@ApiOperation({ summary: 'بروزرسانی تنظیمات کلی سیستم' })
|
||||||
async updateSettings(@Body() data: Record<string, string>) {
|
async updateSettings(@Body() body: Record<string, string>) {
|
||||||
const settings = await this.adminService.updateSettings(data);
|
const data = await this.adminService.updateSettings(body);
|
||||||
return { success: true, data: settings };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,18 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { RedisService } from '../redis/redis.service';
|
||||||
|
|
||||||
describe('AdminService', () => {
|
describe('AdminService', () => {
|
||||||
let service: AdminService;
|
let service: AdminService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [AdminService],
|
providers: [
|
||||||
|
AdminService,
|
||||||
|
{ provide: PrismaService, useValue: {} },
|
||||||
|
{ provide: RedisService, useValue: {} },
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<AdminService>(AdminService);
|
service = module.get<AdminService>(AdminService);
|
||||||
|
|||||||
@ -1,7 +1,64 @@
|
|||||||
import { Injectable, HttpException, NotFoundException } from '@nestjs/common';
|
import { Injectable, HttpException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { RedisService } from '../redis/redis.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()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -10,7 +67,6 @@ export class AdminService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getDashboardStats() {
|
async getDashboardStats() {
|
||||||
// total revenue
|
|
||||||
const orders = await this.prisma.order.findMany({
|
const orders = await this.prisma.order.findMany({
|
||||||
where: { status: { not: 'failed' } },
|
where: { status: { not: 'failed' } },
|
||||||
select: { totalAmount: true },
|
select: { totalAmount: true },
|
||||||
@ -18,15 +74,12 @@ export class AdminService {
|
|||||||
|
|
||||||
const revenue = orders.reduce((sum, o) => sum + Number(o.totalAmount), 0);
|
const revenue = orders.reduce((sum, o) => sum + Number(o.totalAmount), 0);
|
||||||
|
|
||||||
// new orders count (processing status)
|
|
||||||
const newOrders = await this.prisma.order.count({
|
const newOrders = await this.prisma.order.count({
|
||||||
where: { status: 'processing' },
|
where: { status: 'processing' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// active users count
|
|
||||||
const users = await this.prisma.user.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]}`;
|
const todayKey = `visits:${new Date().toISOString().split('T')[0]}`;
|
||||||
let todayVisits = 0;
|
let todayVisits = 0;
|
||||||
try {
|
try {
|
||||||
@ -36,20 +89,33 @@ export class AdminService {
|
|||||||
todayVisits = 0;
|
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 {
|
return {
|
||||||
revenue,
|
revenue,
|
||||||
newOrders,
|
newOrders,
|
||||||
users,
|
users,
|
||||||
todayVisits,
|
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 page = Number(query.page) || 1;
|
||||||
const limit = Number(query.limit) || 10;
|
const limit = Number(query.limit) || 10;
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const where: any = {};
|
const where: Prisma.UserWhereInput = {};
|
||||||
if (query.search) {
|
if (query.search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ firstName: { contains: query.search, mode: 'insensitive' } },
|
{ firstName: { contains: query.search, mode: 'insensitive' } },
|
||||||
@ -72,6 +138,13 @@ export class AdminService {
|
|||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
addresses: true,
|
||||||
|
walletTransactions: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.user.count({ where }),
|
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) {
|
async updateUserRole(id: string, role: string) {
|
||||||
return this.prisma.user.update({
|
return this.prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@ -89,16 +176,16 @@ export class AdminService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProducts(query: any) {
|
async getProducts(query: PaginationQuery = {}) {
|
||||||
try {
|
try {
|
||||||
const page = Number(query.page) || 1;
|
const page = Number(query.page) || 1;
|
||||||
const limit = Number(query.limit) || 10;
|
const limit = Number(query.limit) || 10;
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const where: any = {};
|
const where: Prisma.ProductWhereInput = {};
|
||||||
if (query.search) {
|
if (query.search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ name: { contains: query.search, mode: 'insensitive' } },
|
{ nameFa: { contains: query.search, mode: 'insensitive' } },
|
||||||
{ artNo: { contains: query.search } },
|
{ artNo: { contains: query.search } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -122,34 +209,43 @@ export class AdminService {
|
|||||||
meta: { total, page, limit, lastPage: Math.ceil(total / limit) },
|
meta: { total, page, limit, lastPage: Math.ceil(total / limit) },
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AdminService] getProducts error:', error);
|
const err = error as { message?: string };
|
||||||
throw new HttpException(error.message || 'Error fetching products', 500);
|
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({
|
const product = await this.prisma.product.create({
|
||||||
data: {
|
data: {
|
||||||
artNo: data.artNo,
|
artNo: data.artNo || `ART-${Date.now()}`,
|
||||||
nameFa: data.nameFa,
|
nameFa: data.nameFa || '',
|
||||||
nameEn: data.nameEn,
|
nameEn: data.nameEn || '',
|
||||||
scientificTagline: data.scientificTagline,
|
scientificTagline: data.scientificTagline || '',
|
||||||
description: data.description,
|
description: data.description || '',
|
||||||
shortDescription: data.shortDescription,
|
shortDescription: data.shortDescription || '',
|
||||||
categoryId: data.categoryId,
|
categoryId: data.categoryId || '',
|
||||||
categorySlug: data.categorySlug || 'general',
|
categorySlug: data.categorySlug || 'general',
|
||||||
priceValue: data.priceValue,
|
priceValue: data.priceValue || 0,
|
||||||
priceDisplay: data.priceDisplay,
|
priceDisplay: data.priceDisplay || '',
|
||||||
unit: data.unit,
|
unit: data.unit || 'عدد',
|
||||||
packageSize: data.packageSize,
|
packageSize: data.packageSize || 100,
|
||||||
dosageLogic: data.dosageLogic,
|
dosageLogic: data.dosageLogic || '',
|
||||||
suitableFor: data.suitableFor,
|
suitableFor: data.suitableFor || 'سگ',
|
||||||
imageUrl: data.imageUrl,
|
imageUrl: data.imageUrl || '',
|
||||||
metaTitle: data.metaTitle,
|
images: Array.isArray(data.images)
|
||||||
metaDescription: data.metaDescription,
|
? data.images
|
||||||
keywords: data.keywords,
|
: data.images
|
||||||
canonicalUrl: data.canonicalUrl,
|
? [data.images]
|
||||||
slug: data.slug || data.artNo,
|
: [],
|
||||||
|
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,
|
productId: product.id,
|
||||||
symptom: s.trim(),
|
symptom: s.trim(),
|
||||||
}))
|
}))
|
||||||
.filter((s: any) => s.symptom.length > 0),
|
.filter((s: { symptom: string }) => s.symptom.length > 0),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return product;
|
return product;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateProduct(id: string, data: any) {
|
async updateProduct(id: string, data: ProductInput) {
|
||||||
const existing = await this.prisma.product.findUnique({ where: { id } });
|
const existing = await this.prisma.product.findUnique({ where: { id } });
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw new NotFoundException(`محصولی با شناسه ${id} یافت نشد.`);
|
throw new NotFoundException(`محصولی با شناسه ${id} یافت نشد.`);
|
||||||
@ -191,6 +287,14 @@ export class AdminService {
|
|||||||
dosageLogic: data.dosageLogic,
|
dosageLogic: data.dosageLogic,
|
||||||
suitableFor: data.suitableFor,
|
suitableFor: data.suitableFor,
|
||||||
imageUrl: data.imageUrl,
|
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,
|
metaTitle: data.metaTitle,
|
||||||
metaDescription: data.metaDescription,
|
metaDescription: data.metaDescription,
|
||||||
keywords: data.keywords,
|
keywords: data.keywords,
|
||||||
@ -208,7 +312,7 @@ export class AdminService {
|
|||||||
productId: id,
|
productId: id,
|
||||||
symptom: s.trim(),
|
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 page = Number(query.page) || 1;
|
||||||
const limit = Number(query.limit) || 10;
|
const limit = Number(query.limit) || 10;
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const where: any = {};
|
const where: Prisma.OrderWhereInput = {};
|
||||||
if (query.search) {
|
if (query.search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ id: { contains: query.search } },
|
|
||||||
{ trackingNumber: { contains: query.search, mode: 'insensitive' } },
|
{ trackingNumber: { contains: query.search, mode: 'insensitive' } },
|
||||||
{
|
{
|
||||||
user: { firstName: { contains: query.search, mode: 'insensitive' } },
|
user: { firstName: { contains: query.search, mode: 'insensitive' } },
|
||||||
},
|
},
|
||||||
{ user: { lastName: { 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) {
|
if (query.status) {
|
||||||
@ -269,7 +372,7 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateOrderStatus(id: string, status: string, trackingNumber?: string) {
|
async updateOrderStatus(id: string, status: string, trackingNumber?: string) {
|
||||||
const dataToUpdate: any = { status };
|
const dataToUpdate: Prisma.OrderUpdateInput = { status };
|
||||||
if (trackingNumber !== undefined) {
|
if (trackingNumber !== undefined) {
|
||||||
dataToUpdate.trackingNumber = trackingNumber;
|
dataToUpdate.trackingNumber = trackingNumber;
|
||||||
}
|
}
|
||||||
@ -287,22 +390,22 @@ export class AdminService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Coupons Engine ---
|
async getCoupons(query: PaginationQuery = {}) {
|
||||||
async getCoupons(query: any) {
|
const page = Number(query.page) || 1;
|
||||||
const { page = 1, limit = 10, search = '' } = query;
|
const limit = Number(query.limit) || 10;
|
||||||
const skip = (Number(page) - 1) * Number(limit);
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const where = search
|
const where: Prisma.CouponWhereInput = query.search
|
||||||
? { code: { contains: search, mode: 'insensitive' as any } }
|
? { code: { contains: query.search, mode: 'insensitive' } }
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
const [data, total] = await Promise.all([
|
const [data, total] = await Promise.all([
|
||||||
this.prisma.coupon.findMany({
|
this.prisma.coupon.findMany({
|
||||||
where,
|
where,
|
||||||
skip,
|
skip,
|
||||||
take: Number(limit),
|
take: limit,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: { targets: true }, // Include polymorphic targets
|
include: { targets: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.coupon.count({ where }),
|
this.prisma.coupon.count({ where }),
|
||||||
]);
|
]);
|
||||||
@ -311,14 +414,14 @@ export class AdminService {
|
|||||||
data,
|
data,
|
||||||
meta: {
|
meta: {
|
||||||
total,
|
total,
|
||||||
page: Number(page),
|
page,
|
||||||
limit: Number(limit),
|
limit,
|
||||||
lastPage: Math.ceil(total / Number(limit)),
|
lastPage: Math.ceil(total / limit),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCoupon(data: any) {
|
async createCoupon(data: CouponInput) {
|
||||||
return this.prisma.coupon.create({
|
return this.prisma.coupon.create({
|
||||||
data: {
|
data: {
|
||||||
code: data.code,
|
code: data.code,
|
||||||
@ -332,7 +435,7 @@ export class AdminService {
|
|||||||
targets:
|
targets:
|
||||||
data.targets && data.targets.length > 0
|
data.targets && data.targets.length > 0
|
||||||
? {
|
? {
|
||||||
create: data.targets.map((t: any) => ({
|
create: data.targets.map((t) => ({
|
||||||
targetType: t.targetType,
|
targetType: t.targetType,
|
||||||
targetId: t.targetId,
|
targetId: t.targetId,
|
||||||
modifierType: t.modifierType || 'override',
|
modifierType: t.modifierType || 'override',
|
||||||
@ -345,8 +448,7 @@ export class AdminService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateCoupon(id: string, data: any) {
|
async updateCoupon(id: string, data: CouponInput) {
|
||||||
// Delete old targets and recreate them
|
|
||||||
await this.prisma.couponTarget.deleteMany({ where: { couponId: id } });
|
await this.prisma.couponTarget.deleteMany({ where: { couponId: id } });
|
||||||
|
|
||||||
return this.prisma.coupon.update({
|
return this.prisma.coupon.update({
|
||||||
@ -363,7 +465,7 @@ export class AdminService {
|
|||||||
targets:
|
targets:
|
||||||
data.targets && data.targets.length > 0
|
data.targets && data.targets.length > 0
|
||||||
? {
|
? {
|
||||||
create: data.targets.map((t: any) => ({
|
create: data.targets.map((t) => ({
|
||||||
targetType: t.targetType,
|
targetType: t.targetType,
|
||||||
targetId: t.targetId,
|
targetId: t.targetId,
|
||||||
modifierType: t.modifierType || 'override',
|
modifierType: t.modifierType || 'override',
|
||||||
@ -389,27 +491,15 @@ export class AdminService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Settings ---
|
|
||||||
async getSettings() {
|
async getSettings() {
|
||||||
const keys = [
|
const settings = await this.prisma.uiText.findMany();
|
||||||
'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', ... }
|
|
||||||
return settings.reduce(
|
return settings.reduce(
|
||||||
(acc, curr) => ({ ...acc, [curr.key]: curr.value }),
|
(acc, curr) => ({ ...acc, [curr.key]: curr.value }),
|
||||||
{},
|
{} as Record<string, string>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSettings(data: Record<string, string>) {
|
async updateSettings(data: Record<string, string>) {
|
||||||
// Upsert all keys
|
|
||||||
const operations = Object.entries(data).map(([key, value]) => {
|
const operations = Object.entries(data).map(([key, value]) => {
|
||||||
return this.prisma.uiText.upsert({
|
return this.prisma.uiText.upsert({
|
||||||
where: { key },
|
where: { key },
|
||||||
@ -421,4 +511,121 @@ export class AdminService {
|
|||||||
await this.prisma.$transaction(operations);
|
await this.prisma.$transaction(operations);
|
||||||
return this.getSettings();
|
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,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { BlogsService } from './blogs.service';
|
import { BlogsService } from './blogs.service';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import {
|
import {
|
||||||
@ -19,6 +20,8 @@ import {
|
|||||||
ApiQuery,
|
ApiQuery,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||||
|
|
||||||
@ApiTags('Admin - مدیریت مقالات (بلاگ)')
|
@ApiTags('Admin - مدیریت مقالات (بلاگ)')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ -31,20 +34,26 @@ export class BlogsController {
|
|||||||
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
||||||
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
||||||
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
|
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
|
||||||
async getBlogs(@Query() query: any) {
|
async getBlogs(@Query() query: PaginationDto) {
|
||||||
return this.blogsService.getBlogs(query);
|
return this.blogsService.getBlogs(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'ایجاد مقاله جدید' })
|
@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);
|
const data = await this.blogsService.createBlog(body, req.user.id);
|
||||||
return { success: true, data };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@ApiOperation({ summary: 'ویرایش مقاله' })
|
@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);
|
const data = await this.blogsService.updateBlog(id, body);
|
||||||
return { success: true, data };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,31 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
export class BlogQuery {
|
||||||
|
page?: number | string;
|
||||||
|
limit?: number | string;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BlogsService {
|
export class BlogsService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
async getBlogs(query: any) {
|
async getBlogs(query: BlogQuery = {}) {
|
||||||
const { page = 1, limit = 10, search = '' } = query;
|
const page = Number(query.page) || 1;
|
||||||
const skip = (Number(page) - 1) * Number(limit);
|
const limit = Number(query.limit) || 10;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const where = search
|
const where: Prisma.BlogWhereInput = query.search
|
||||||
? { title: { contains: search, mode: 'insensitive' as any } }
|
? { title: { contains: query.search, mode: 'insensitive' } }
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
const [data, total] = await Promise.all([
|
const [data, total] = await Promise.all([
|
||||||
this.prisma.blog.findMany({
|
this.prisma.blog.findMany({
|
||||||
where,
|
where,
|
||||||
skip,
|
skip,
|
||||||
take: Number(limit),
|
take: limit,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: { author: { select: { firstName: true, lastName: true } } },
|
include: { author: { select: { firstName: true, lastName: true } } },
|
||||||
}),
|
}),
|
||||||
@ -28,20 +36,23 @@ export class BlogsService {
|
|||||||
data,
|
data,
|
||||||
meta: {
|
meta: {
|
||||||
total,
|
total,
|
||||||
page: Number(page),
|
page,
|
||||||
limit: Number(limit),
|
limit,
|
||||||
lastPage: Math.ceil(total / Number(limit)),
|
lastPage: Math.ceil(total / limit),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async createBlog(data: any, authorId: string) {
|
async createBlog(
|
||||||
|
data: Prisma.BlogCreateWithoutAuthorInput,
|
||||||
|
authorId: string,
|
||||||
|
) {
|
||||||
return this.prisma.blog.create({
|
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 } });
|
const blog = await this.prisma.blog.findUnique({ where: { id } });
|
||||||
if (!blog) throw new NotFoundException('Blog not found');
|
if (!blog) throw new NotFoundException('Blog not found');
|
||||||
return this.prisma.blog.update({ where: { id }, data });
|
return this.prisma.blog.update({ where: { id }, data });
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { CategoriesService } from './categories.service';
|
import { CategoriesService } from './categories.service';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import {
|
import {
|
||||||
@ -18,6 +19,8 @@ import {
|
|||||||
ApiQuery,
|
ApiQuery,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||||
|
|
||||||
@ApiTags('Admin - مدیریت دستهبندیها')
|
@ApiTags('Admin - مدیریت دستهبندیها')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ -30,7 +33,7 @@ export class CategoriesController {
|
|||||||
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
||||||
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
||||||
@ApiQuery({ name: 'search', required: false, description: 'جستجو در نام' })
|
@ApiQuery({ name: 'search', required: false, description: 'جستجو در نام' })
|
||||||
async getCategories(@Query() query: any) {
|
async getCategories(@Query() query: PaginationDto) {
|
||||||
return this.categoriesService.getCategories(query);
|
return this.categoriesService.getCategories(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,14 +46,17 @@ export class CategoriesController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'ایجاد دستهبندی جدید' })
|
@ApiOperation({ summary: 'ایجاد دستهبندی جدید' })
|
||||||
async createCategory(@Body() body: any) {
|
async createCategory(@Body() body: Prisma.CategoryCreateInput) {
|
||||||
const data = await this.categoriesService.createCategory(body);
|
const data = await this.categoriesService.createCategory(body);
|
||||||
return { success: true, data };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@ApiOperation({ summary: 'ویرایش دستهبندی' })
|
@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);
|
const data = await this.categoriesService.updateCategory(id, body);
|
||||||
return { success: true, data };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,31 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
export class CategoryQuery {
|
||||||
|
page?: number | string;
|
||||||
|
limit?: number | string;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CategoriesService {
|
export class CategoriesService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
async getCategories(query: any) {
|
async getCategories(query: CategoryQuery = {}) {
|
||||||
const { page = 1, limit = 10, search = '' } = query;
|
const page = Number(query.page) || 1;
|
||||||
const skip = (Number(page) - 1) * Number(limit);
|
const limit = Number(query.limit) || 10;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const where = search
|
const where: Prisma.CategoryWhereInput = query.search
|
||||||
? { name: { contains: search, mode: 'insensitive' as any } }
|
? { name: { contains: query.search, mode: 'insensitive' } }
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
const [data, total] = await Promise.all([
|
const [data, total] = await Promise.all([
|
||||||
this.prisma.category.findMany({
|
this.prisma.category.findMany({
|
||||||
where,
|
where,
|
||||||
skip,
|
skip,
|
||||||
take: Number(limit),
|
take: limit,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
}),
|
}),
|
||||||
this.prisma.category.count({ where }),
|
this.prisma.category.count({ where }),
|
||||||
@ -27,9 +35,9 @@ export class CategoriesService {
|
|||||||
data,
|
data,
|
||||||
meta: {
|
meta: {
|
||||||
total,
|
total,
|
||||||
page: Number(page),
|
page,
|
||||||
limit: Number(limit),
|
limit,
|
||||||
lastPage: Math.ceil(total / Number(limit)),
|
lastPage: Math.ceil(total / limit),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -38,28 +46,23 @@ export class CategoriesService {
|
|||||||
return this.prisma.category.findMany({ orderBy: { name: 'asc' } });
|
return this.prisma.category.findMany({ orderBy: { name: 'asc' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async createCategory(data: any) {
|
async createCategory(data: Prisma.CategoryCreateInput) {
|
||||||
const cleanData = { ...data };
|
const nameStr = data.name || '';
|
||||||
Object.keys(cleanData).forEach((k) => {
|
const slugStr = data.slug || nameStr.replace(/\s+/g, '-').toLowerCase();
|
||||||
if (cleanData[k] === '') cleanData[k] = null;
|
|
||||||
});
|
|
||||||
// Ensure required fields
|
|
||||||
if (!cleanData.slug)
|
|
||||||
cleanData.slug = cleanData.name.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 } });
|
const category = await this.prisma.category.findUnique({ where: { id } });
|
||||||
if (!category) throw new NotFoundException('Category not found');
|
if (!category) throw new NotFoundException('Category not found');
|
||||||
|
|
||||||
const cleanData = { ...data };
|
return this.prisma.category.update({ where: { id }, data });
|
||||||
Object.keys(cleanData).forEach((k) => {
|
|
||||||
if (cleanData[k] === '') cleanData[k] = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.prisma.category.update({ where: { id }, data: cleanData });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteCategory(id: string) {
|
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,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
|
Put,
|
||||||
|
Body,
|
||||||
Delete,
|
Delete,
|
||||||
Param,
|
Param,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@ -28,9 +30,8 @@ export class MediaController {
|
|||||||
return { success: true, data };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Post('upload')
|
@Post('upload')
|
||||||
@ApiOperation({ summary: 'آپلود فایل جدید' })
|
@ApiOperation({ summary: 'آپلود فایل جدید (عمومی/ادمین)' })
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
async uploadFile(@UploadedFile() file: Express.Multer.File) {
|
async uploadFile(@UploadedFile() file: Express.Multer.File) {
|
||||||
if (!file) throw new BadRequestException('File is missing');
|
if (!file) throw new BadRequestException('File is missing');
|
||||||
@ -45,4 +46,23 @@ export class MediaController {
|
|||||||
const data = await this.mediaService.deleteMedia(id);
|
const data = await this.mediaService.deleteMedia(id);
|
||||||
return { success: true, data };
|
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 } });
|
await this.prisma.media.delete({ where: { id } });
|
||||||
return { success: true };
|
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,
|
ApiQuery,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||||
|
|
||||||
@ApiTags('Admin - مدیریت حیوانات خانگی')
|
@ApiTags('Admin - مدیریت حیوانات خانگی')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ -27,7 +29,7 @@ export class PetsController {
|
|||||||
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
||||||
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
||||||
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
|
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
|
||||||
async getPets(@Query() query: any) {
|
async getPets(@Query() query: PaginationDto) {
|
||||||
return this.petsService.getPets(query);
|
return this.petsService.getPets(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,23 +1,31 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
export class PetQuery {
|
||||||
|
page?: number | string;
|
||||||
|
limit?: number | string;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PetsService {
|
export class PetsService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
async getPets(query: any) {
|
async getPets(query: PetQuery = {}) {
|
||||||
const { page = 1, limit = 10, search = '' } = query;
|
const page = Number(query.page) || 1;
|
||||||
const skip = (Number(page) - 1) * Number(limit);
|
const limit = Number(query.limit) || 10;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const where = search
|
const where: Prisma.PetWhereInput = query.search
|
||||||
? { name: { contains: search, mode: 'insensitive' as any } }
|
? { name: { contains: query.search, mode: 'insensitive' } }
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
const [data, total] = await Promise.all([
|
const [data, total] = await Promise.all([
|
||||||
this.prisma.pet.findMany({
|
this.prisma.pet.findMany({
|
||||||
where,
|
where,
|
||||||
skip,
|
skip,
|
||||||
take: Number(limit),
|
take: limit,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
@ -37,9 +45,9 @@ export class PetsService {
|
|||||||
data,
|
data,
|
||||||
meta: {
|
meta: {
|
||||||
total,
|
total,
|
||||||
page: Number(page),
|
page,
|
||||||
limit: Number(limit),
|
limit,
|
||||||
lastPage: Math.ceil(total / Number(limit)),
|
lastPage: Math.ceil(total / limit),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,12 +76,6 @@ export class ReportsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 3. Category Distribution
|
// 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({
|
const categories = await this.prisma.category.findMany({
|
||||||
include: { products: { select: { id: true } } },
|
include: { products: { select: { id: true } } },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { WikiService } from './wiki.service';
|
import { WikiService } from './wiki.service';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import {
|
import {
|
||||||
@ -18,6 +19,8 @@ import {
|
|||||||
ApiQuery,
|
ApiQuery,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||||
|
|
||||||
@ApiTags('Admin - مدیریت دانشنامه (اصطلاحات علمی)')
|
@ApiTags('Admin - مدیریت دانشنامه (اصطلاحات علمی)')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ -30,20 +33,23 @@ export class WikiController {
|
|||||||
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
|
||||||
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتمها' })
|
||||||
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
|
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
|
||||||
async getTerms(@Query() query: any) {
|
async getTerms(@Query() query: PaginationDto) {
|
||||||
return this.wikiService.getTerms(query);
|
return this.wikiService.getTerms(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'ایجاد اصطلاح جدید' })
|
@ApiOperation({ summary: 'ایجاد اصطلاح جدید' })
|
||||||
async createTerm(@Body() body: any) {
|
async createTerm(@Body() body: Prisma.ScientificTermCreateInput) {
|
||||||
const data = await this.wikiService.createTerm(body);
|
const data = await this.wikiService.createTerm(body);
|
||||||
return { success: true, data };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':key')
|
@Put(':key')
|
||||||
@ApiOperation({ summary: 'ویرایش اصطلاح' })
|
@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);
|
const data = await this.wikiService.updateTerm(key, body);
|
||||||
return { success: true, data };
|
return { success: true, data };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +1,31 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
export class WikiQuery {
|
||||||
|
page?: number | string;
|
||||||
|
limit?: number | string;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WikiService {
|
export class WikiService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
async getTerms(query: any) {
|
async getTerms(query: WikiQuery = {}) {
|
||||||
const { page = 1, limit = 10, search = '' } = query;
|
const page = Number(query.page) || 1;
|
||||||
const skip = (Number(page) - 1) * Number(limit);
|
const limit = Number(query.limit) || 10;
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const where = search
|
const where: Prisma.ScientificTermWhereInput = query.search
|
||||||
? { term: { contains: search, mode: 'insensitive' as any } }
|
? { term: { contains: query.search, mode: 'insensitive' } }
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
const [data, total] = await Promise.all([
|
const [data, total] = await Promise.all([
|
||||||
this.prisma.scientificTerm.findMany({
|
this.prisma.scientificTerm.findMany({
|
||||||
where,
|
where,
|
||||||
skip,
|
skip,
|
||||||
take: Number(limit),
|
take: limit,
|
||||||
orderBy: { term: 'asc' },
|
orderBy: { term: 'asc' },
|
||||||
}),
|
}),
|
||||||
this.prisma.scientificTerm.count({ where }),
|
this.prisma.scientificTerm.count({ where }),
|
||||||
@ -27,18 +35,18 @@ export class WikiService {
|
|||||||
data,
|
data,
|
||||||
meta: {
|
meta: {
|
||||||
total,
|
total,
|
||||||
page: Number(page),
|
page,
|
||||||
limit: Number(limit),
|
limit,
|
||||||
lastPage: Math.ceil(total / Number(limit)),
|
lastPage: Math.ceil(total / limit),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTerm(data: any) {
|
async createTerm(data: Prisma.ScientificTermCreateInput) {
|
||||||
return this.prisma.scientificTerm.create({ data });
|
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({
|
const term = await this.prisma.scientificTerm.findUnique({
|
||||||
where: { key },
|
where: { key },
|
||||||
});
|
});
|
||||||
|
|||||||
@ -20,10 +20,18 @@ import { CmsModule } from './cms/cms.module';
|
|||||||
import { WholesaleModule } from './wholesale/wholesale.module';
|
import { WholesaleModule } from './wholesale/wholesale.module';
|
||||||
import { VideosModule } from './videos/videos.module';
|
import { VideosModule } from './videos/videos.module';
|
||||||
import { SmsModule } from './common/sms.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
SmsModule,
|
SmsModule,
|
||||||
|
ContactModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
RedisModule,
|
RedisModule,
|
||||||
ProductsModule,
|
ProductsModule,
|
||||||
@ -46,6 +54,12 @@ import { SmsModule } from './common/sms.module';
|
|||||||
CmsModule,
|
CmsModule,
|
||||||
WholesaleModule,
|
WholesaleModule,
|
||||||
VideosModule,
|
VideosModule,
|
||||||
|
BannersModule,
|
||||||
|
SmartAdvisorModule,
|
||||||
|
TestimonialsModule,
|
||||||
|
IngredientsModule,
|
||||||
|
PrescriptionsModule,
|
||||||
|
B2BModule,
|
||||||
],
|
],
|
||||||
controllers: [MetricsController],
|
controllers: [MetricsController],
|
||||||
providers: [
|
providers: [
|
||||||
@ -63,9 +77,19 @@ export class AppModule implements NestModule {
|
|||||||
consumer
|
consumer
|
||||||
.apply((req: any, res: any, next: () => void) => {
|
.apply((req: any, res: any, next: () => void) => {
|
||||||
MetricsController.incrementRequestCount();
|
MetricsController.incrementRequestCount();
|
||||||
// Increment daily visits counter in Redis (fire-and-forget)
|
|
||||||
const todayKey = `visits:${new Date().toISOString().split('T')[0]}`;
|
const url: string = req.originalUrl || req.url || '';
|
||||||
this.redisService.incr(todayKey, 86400).catch(() => {}); // TTL = 24h
|
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(() => {});
|
||||||
|
}
|
||||||
next();
|
next();
|
||||||
})
|
})
|
||||||
.exclude('metrics')
|
.exclude('metrics')
|
||||||
|
|||||||
@ -12,6 +12,8 @@ import {
|
|||||||
ApiBadRequestResponse,
|
ApiBadRequestResponse,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { AdminLoginDto } from './dto/admin-login.dto';
|
||||||
|
|
||||||
@ApiTags('Auth - احراز هویت')
|
@ApiTags('Auth - احراز هویت')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
@ -33,12 +35,11 @@ export class AuthController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'ارسال کد تایید پیامکی (OTP)' })
|
@ApiOperation({ summary: 'ارسال کد تایید پیامکی (OTP)' })
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
description: 'کد با موفقیت ارسال شد (کد تایید در پاسخ بازگردانده میشود)',
|
description: 'کد با موفقیت به شماره تلفن همراه پیامک شد',
|
||||||
schema: {
|
schema: {
|
||||||
example: {
|
example: {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'کد تایید ارسال شد',
|
message: 'کد تایید با موفقیت به شماره شما پیامک شد.',
|
||||||
code: '12345',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -125,7 +126,7 @@ export class AuthController {
|
|||||||
@ApiOperation({ summary: 'ورود ادمین به پنل مدیریت' })
|
@ApiOperation({ summary: 'ورود ادمین به پنل مدیریت' })
|
||||||
@ApiOkResponse({ description: 'ورود موفق ادمین به همراه توکن' })
|
@ApiOkResponse({ description: 'ورود موفق ادمین به همراه توکن' })
|
||||||
@ApiBadRequestResponse({ description: 'اطلاعات ورود ادمین اشتباه است' })
|
@ApiBadRequestResponse({ description: 'اطلاعات ورود ادمین اشتباه است' })
|
||||||
adminLogin(@Body() body: any) {
|
adminLogin(@Body() body: AdminLoginDto) {
|
||||||
return this.authService.adminLogin(body);
|
return this.authService.adminLogin(body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import { UsersModule } from '../users/users.module';
|
|||||||
UsersModule,
|
UsersModule,
|
||||||
PassportModule,
|
PassportModule,
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET || 'super-secret-key',
|
secret: process.env.JWT_ACCESS_SECRET,
|
||||||
signOptions: { expiresIn: '7d' },
|
signOptions: { expiresIn: '7d' },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { AuthService } from './auth.service';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { RedisService } from '../redis/redis.service';
|
import { RedisService } from '../redis/redis.service';
|
||||||
|
import { SmsService } from '../common/services/sms.service';
|
||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
@ -10,6 +11,7 @@ describe('AuthService', () => {
|
|||||||
let prisma: PrismaService;
|
let prisma: PrismaService;
|
||||||
let jwt: JwtService;
|
let jwt: JwtService;
|
||||||
let redis: RedisService;
|
let redis: RedisService;
|
||||||
|
let sms: SmsService;
|
||||||
|
|
||||||
const mockPrisma = {
|
const mockPrisma = {
|
||||||
user: {
|
user: {
|
||||||
@ -28,6 +30,10 @@ describe('AuthService', () => {
|
|||||||
del: jest.fn(),
|
del: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockSms = {
|
||||||
|
sendOtp: jest.fn().mockResolvedValue(true),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
@ -35,6 +41,7 @@ describe('AuthService', () => {
|
|||||||
{ provide: PrismaService, useValue: mockPrisma },
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
{ provide: JwtService, useValue: mockJwt },
|
{ provide: JwtService, useValue: mockJwt },
|
||||||
{ provide: RedisService, useValue: mockRedis },
|
{ provide: RedisService, useValue: mockRedis },
|
||||||
|
{ provide: SmsService, useValue: mockSms },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@ -42,6 +49,7 @@ describe('AuthService', () => {
|
|||||||
prisma = module.get<PrismaService>(PrismaService);
|
prisma = module.get<PrismaService>(PrismaService);
|
||||||
jwt = module.get<JwtService>(JwtService);
|
jwt = module.get<JwtService>(JwtService);
|
||||||
redis = module.get<RedisService>(RedisService);
|
redis = module.get<RedisService>(RedisService);
|
||||||
|
sms = module.get<SmsService>(SmsService);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
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 () => {
|
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' });
|
const result = await service.sendOtp({ phoneNumber: '09123456789' });
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toBe('کد تایید ارسال شد');
|
expect(result.message).toBe('کد تایید با موفقیت به شماره شما پیامک شد.');
|
||||||
// Code should NOT be in the response (security)
|
// Code should NOT be in the response (security)
|
||||||
expect((result as any).code).toBeUndefined();
|
expect((result as any).code).toBeUndefined();
|
||||||
// But it should have been stored in Redis
|
// 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 { VerifyOtpDto } from './dto/verify-otp.dto';
|
||||||
import { SmsService } from '../common/services/sms.service';
|
import { SmsService } from '../common/services/sms.service';
|
||||||
import * as bcrypt from 'bcryptjs';
|
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()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
@ -19,8 +38,7 @@ export class AuthService {
|
|||||||
async sendOtp(sendOtpDto: SendOtpDto) {
|
async sendOtp(sendOtpDto: SendOtpDto) {
|
||||||
const { phoneNumber } = sendOtpDto;
|
const { phoneNumber } = sendOtpDto;
|
||||||
|
|
||||||
const code = Math.floor(10000 + Math.random() * 90000).toString();
|
const code = crypto.randomInt(10000, 100000).toString();
|
||||||
console.log(`[SMS OTP] Code for ${phoneNumber}: ${code}`);
|
|
||||||
|
|
||||||
await this.redisService.set(`otp:${phoneNumber}`, code, 120);
|
await this.redisService.set(`otp:${phoneNumber}`, code, 120);
|
||||||
|
|
||||||
@ -38,17 +56,10 @@ export class AuthService {
|
|||||||
|
|
||||||
const savedCode = await this.redisService.get(`otp:${phoneNumber}`);
|
const savedCode = await this.redisService.get(`otp:${phoneNumber}`);
|
||||||
|
|
||||||
if (!savedCode) {
|
if (!savedCode || savedCode !== code) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
message: 'کد تایید منقضی شده است',
|
message: 'کد وارد شده اشتباه یا منقضی شده است',
|
||||||
error: 'OTP_EXPIRED',
|
error: 'INVALID_OTP',
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (savedCode !== code) {
|
|
||||||
throw new BadRequestException({
|
|
||||||
message: 'کد تایید اشتباه است',
|
|
||||||
error: 'OTP_INVALID',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,7 +75,6 @@ export class AuthService {
|
|||||||
mobile: phoneNumber,
|
mobile: phoneNumber,
|
||||||
firstName: 'کاربر',
|
firstName: 'کاربر',
|
||||||
lastName: 'جدید',
|
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 { firstName, lastName, email, mobile, password } = registerDto;
|
||||||
|
|
||||||
const existingUser = await this.prisma.user.findUnique({
|
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({
|
const user = await this.prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@ -130,7 +140,7 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(loginDto: any) {
|
async login(loginDto: LoginInput) {
|
||||||
const { mobile, password } = loginDto;
|
const { mobile, password } = loginDto;
|
||||||
|
|
||||||
const user = await this.prisma.user.findUnique({ where: { mobile } });
|
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) {
|
if (!isMatch) {
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
message: 'نام کاربری یا رمز عبور اشتباه است',
|
message: 'نام کاربری یا رمز عبور اشتباه است',
|
||||||
@ -168,11 +180,38 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async adminLogin(body: any) {
|
async adminLogin(body: AdminLoginInput) {
|
||||||
if (
|
const adminEmail = process.env.ADMIN_EMAIL || 'admin@canina-iran.com';
|
||||||
body.email === 'admin@canino-iran.com' &&
|
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
|
||||||
body.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 = {
|
const payload = {
|
||||||
sub: '12345678-1234-1234-1234-123456789012',
|
sub: '12345678-1234-1234-1234-123456789012',
|
||||||
email: body.email,
|
email: body.email,
|
||||||
@ -190,9 +229,10 @@ export class AuthService {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new BadRequestException({
|
throw new BadRequestException({
|
||||||
message: 'ایمیل یا رمز عبور اشتباه است',
|
message: 'ایمیل یا رمز عبور مدیریت اشتباه است',
|
||||||
error: 'INVALID_CREDENTIALS',
|
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()
|
@Injectable()
|
||||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
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) {
|
if (err || !user) {
|
||||||
throw (
|
throw (
|
||||||
err || new UnauthorizedException('لطفا ابتدا وارد حساب کاربری خود شوید')
|
(err as Error) ||
|
||||||
|
new UnauthorizedException('لطفا ابتدا وارد حساب کاربری خود شوید')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return user;
|
return user;
|
||||||
|
|||||||
@ -3,17 +3,24 @@ import { PassportStrategy } from '@nestjs/passport';
|
|||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
email?: string;
|
||||||
|
role?: string;
|
||||||
|
phoneNumber?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(private readonly usersService: UsersService) {
|
constructor(private readonly usersService: UsersService) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
ignoreExpiration: false,
|
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
|
// Bypass DB lookup for local admin user to prevent UUID casting errors
|
||||||
if (payload.sub === '12345678-1234-1234-1234-123456789012') {
|
if (payload.sub === '12345678-1234-1234-1234-123456789012') {
|
||||||
return { id: payload.sub, email: payload.email, role: payload.role };
|
return { id: payload.sub, email: payload.email, role: payload.role };
|
||||||
|
|||||||
@ -1,4 +1 @@
|
|||||||
import { SetMetadata } from '@nestjs/common';
|
export * from '../common/decorators/roles.decorator';
|
||||||
|
|
||||||
export const ROLES_KEY = 'roles';
|
|
||||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
|
||||||
|
|||||||
@ -1,36 +1 @@
|
|||||||
import {
|
export * from '../common/guards/roles.guard';
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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,
|
ApiResponse,
|
||||||
ApiOkResponse,
|
ApiOkResponse,
|
||||||
ApiNotFoundResponse,
|
ApiNotFoundResponse,
|
||||||
ApiQuery,
|
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||||
|
|
||||||
@ -15,7 +16,7 @@ export class BlogsService {
|
|||||||
sortOrder = 'desc',
|
sortOrder = 'desc',
|
||||||
} = filters;
|
} = filters;
|
||||||
|
|
||||||
const whereClause: any = { isPublished: true };
|
const whereClause: Prisma.BlogWhereInput = { isPublished: true };
|
||||||
if (search) {
|
if (search) {
|
||||||
whereClause.OR = [
|
whereClause.OR = [
|
||||||
{ title: { contains: search, mode: 'insensitive' } },
|
{ 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 {
|
} 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);
|
message = this.translateGenericMessage(rawMsg, status);
|
||||||
const rawCode = typeof resObj.code === 'string' ? resObj.code : undefined;
|
const rawCode =
|
||||||
const rawError = typeof resObj.error === 'string' ? resObj.error : undefined;
|
typeof resObj.code === 'string' ? resObj.code : undefined;
|
||||||
|
const rawError =
|
||||||
|
typeof resObj.error === 'string' ? resObj.error : undefined;
|
||||||
code = rawCode || this.deriveErrorCode(status, rawError);
|
code = rawCode || this.deriveErrorCode(status, rawError);
|
||||||
details = (resObj.details as Record<string, unknown>) || {};
|
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 { Controller, Get, Res } from '@nestjs/common';
|
||||||
import { ApiExcludeController } from '@nestjs/swagger';
|
import { ApiExcludeController } from '@nestjs/swagger';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import * as express from 'express';
|
import type { Response } from 'express';
|
||||||
|
|
||||||
@ApiExcludeController()
|
@ApiExcludeController()
|
||||||
@Controller('metrics')
|
@Controller('metrics')
|
||||||
@ -15,14 +15,14 @@ export class MetricsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async getMetrics(@Res() res: express.Response) {
|
async getMetrics(@Res() res: Response) {
|
||||||
const memory = process.memoryUsage();
|
const memory = process.memoryUsage();
|
||||||
const cpu = process.cpuUsage();
|
const cpu = process.cpuUsage();
|
||||||
|
|
||||||
let dbStatus = 1;
|
let dbStatus = 1;
|
||||||
try {
|
try {
|
||||||
await this.prisma.$queryRaw`SELECT 1`;
|
await this.prisma.$queryRaw`SELECT 1`;
|
||||||
} catch (e) {
|
} catch {
|
||||||
dbStatus = 0;
|
dbStatus = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export class PaginatedResponse<T> {
|
|||||||
meta: PaginationMeta;
|
meta: PaginationMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPaginatedSchema(dtoModel: any) {
|
export function createPaginatedSchema(dtoModel: { name: string }) {
|
||||||
return {
|
return {
|
||||||
schema: {
|
schema: {
|
||||||
allOf: [
|
allOf: [
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import * as http from 'http';
|
|
||||||
import * as https from 'https';
|
import * as https from 'https';
|
||||||
|
|
||||||
export interface SendPatternSmsOptions {
|
export interface SendPatternSmsOptions {
|
||||||
@ -8,10 +7,15 @@ export interface SendPatternSmsOptions {
|
|||||||
args: string[]; // Dynamic variables inside pattern
|
args: string[]; // Dynamic variables inside pattern
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MeliPayamakResponse {
|
||||||
|
Value?: number;
|
||||||
|
RetStatus?: number;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsService {
|
export class SmsService {
|
||||||
private readonly logger = new Logger(SmsService.name);
|
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 || '';
|
private readonly password = process.env.MELIPAYAMAK_PASSWORD || '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -20,7 +24,7 @@ export class SmsService {
|
|||||||
async sendPatternSms(options: SendPatternSmsOptions): Promise<boolean> {
|
async sendPatternSms(options: SendPatternSmsOptions): Promise<boolean> {
|
||||||
if (!this.username || !this.password) {
|
if (!this.username || !this.password) {
|
||||||
this.logger.warn(
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
@ -48,15 +52,16 @@ export class SmsService {
|
|||||||
res.on('data', (chunk) => (data += chunk));
|
res.on('data', (chunk) => (data += chunk));
|
||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(data);
|
const json = JSON.parse(data) as MeliPayamakResponse;
|
||||||
if (json && (json.Value > 15 || json.RetStatus === 1)) {
|
const val = json.Value ?? 0;
|
||||||
|
if (json && (val > 15 || json.RetStatus === 1)) {
|
||||||
this.logger.log(
|
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);
|
resolve(true);
|
||||||
} else {
|
} else {
|
||||||
this.logger.error(
|
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);
|
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> {
|
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({
|
return this.sendPatternSms({
|
||||||
to: phone,
|
to: phone,
|
||||||
bodyId,
|
bodyId,
|
||||||
@ -92,14 +100,17 @@ export class SmsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send Order Confirmation SMS
|
* Send Order Confirmation SMS (Pattern 508081)
|
||||||
*/
|
*/
|
||||||
async sendOrderConfirmation(
|
async sendOrderConfirmation(
|
||||||
phone: string,
|
phone: string,
|
||||||
orderNumber: string,
|
orderNumber: string,
|
||||||
amount: string,
|
amount: string,
|
||||||
): Promise<boolean> {
|
): 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({
|
return this.sendPatternSms({
|
||||||
to: phone,
|
to: phone,
|
||||||
bodyId,
|
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(
|
async sendShippingNotification(
|
||||||
phone: string,
|
phone: string,
|
||||||
@ -116,7 +127,7 @@ export class SmsService {
|
|||||||
trackingCode: string,
|
trackingCode: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const bodyId = parseInt(
|
const bodyId = parseInt(
|
||||||
process.env.MELIPAYAMAK_SHIPPING_BODY_ID || '0',
|
process.env.MELIPAYAMAK_SHIPPING_BODY_ID || '508082',
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
return this.sendPatternSms({
|
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
|
* Send Pet Care Vaccination / Deworming Reminder SMS
|
||||||
*/
|
*/
|
||||||
@ -144,4 +173,12 @@ export class SmsService {
|
|||||||
args: [petName, reminderType],
|
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 { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
import { CustomHttpExceptionFilter } from './common/filters/http-exception.filter';
|
import { CustomHttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
import { PrismaExceptionFilter } from './common/filters/prisma-exception.filter';
|
import { PrismaExceptionFilter } from './common/filters/prisma-exception.filter';
|
||||||
|
import { DecimalInterceptor } from './common/interceptors/decimal.interceptor';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
|
|
||||||
async function bootstrap() {
|
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);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
|
|
||||||
// Serve static uploads folder
|
// Serve static uploads folder
|
||||||
@ -63,18 +91,31 @@ async function bootstrap() {
|
|||||||
new PrismaExceptionFilter(),
|
new PrismaExceptionFilter(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const config = new DocumentBuilder()
|
app.useGlobalInterceptors(new DecimalInterceptor());
|
||||||
.setTitle('Canina Iran API')
|
|
||||||
.setDescription(
|
|
||||||
'API Documentation for Canina Iran Pet Health & Supplement Platform',
|
|
||||||
)
|
|
||||||
.setVersion('1.0.0')
|
|
||||||
.addBearerAuth()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
const document = SwaggerModule.createDocument(app, config);
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
SwaggerModule.setup('api/docs', app, document);
|
const enableSwagger = process.env.ENABLE_SWAGGER === 'true';
|
||||||
|
|
||||||
|
if (!isProduction || enableSwagger) {
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle('Canina Iran API')
|
||||||
|
.setDescription(
|
||||||
|
'API Documentation for Canina Iran Pet Health & Supplement Platform',
|
||||||
|
)
|
||||||
|
.setVersion('1.0.0')
|
||||||
|
.addBearerAuth()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
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);
|
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,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Post,
|
Post,
|
||||||
Patch,
|
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@ -13,6 +12,7 @@ import {
|
|||||||
import { OrdersService } from './orders.service';
|
import { OrdersService } from './orders.service';
|
||||||
import { CreateOrderDto } from './dto/create-order.dto';
|
import { CreateOrderDto } from './dto/create-order.dto';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
@ -22,7 +22,6 @@ import {
|
|||||||
ApiCreatedResponse,
|
ApiCreatedResponse,
|
||||||
ApiBadRequestResponse,
|
ApiBadRequestResponse,
|
||||||
ApiNotFoundResponse,
|
ApiNotFoundResponse,
|
||||||
ApiBody,
|
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||||
import { IsString, IsNumber, IsNotEmpty } from 'class-validator';
|
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);
|
return this.ordersService.create(req.user.id, createOrderDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,10 +107,13 @@ export class OrdersController {
|
|||||||
@ApiBadRequestResponse({
|
@ApiBadRequestResponse({
|
||||||
description: 'کد تخفیف نامعتبر، منقضی، یا شرایط آن برقرار نیست',
|
description: 'کد تخفیف نامعتبر، منقضی، یا شرایط آن برقرار نیست',
|
||||||
})
|
})
|
||||||
validateCoupon(@Req() req: any, @Body() body: ValidateCouponDto) {
|
validateCoupon(
|
||||||
|
@Req() req: { user: { id: string } },
|
||||||
|
@Body() body: ValidateCouponDto,
|
||||||
|
) {
|
||||||
return this.ordersService.validateCoupon(
|
return this.ordersService.validateCoupon(
|
||||||
body.code,
|
body.code,
|
||||||
body.cartTotal,
|
new Prisma.Decimal(body.cartTotal),
|
||||||
req.user.id,
|
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);
|
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);
|
return this.ordersService.findOne(id, req.user.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { OrdersService } from './orders.service';
|
import { OrdersService } from './orders.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { SmsService } from '../common/services/sms.service';
|
||||||
import { NotFoundException, BadRequestException } from '@nestjs/common';
|
import { NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
describe('OrdersService', () => {
|
describe('OrdersService', () => {
|
||||||
let service: OrdersService;
|
let service: OrdersService;
|
||||||
@ -10,6 +12,7 @@ describe('OrdersService', () => {
|
|||||||
const mockPrisma = {
|
const mockPrisma = {
|
||||||
product: {
|
product: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
},
|
},
|
||||||
order: {
|
order: {
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
@ -17,6 +20,17 @@ describe('OrdersService', () => {
|
|||||||
findFirst: jest.fn(),
|
findFirst: jest.fn(),
|
||||||
count: 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 () => {
|
beforeEach(async () => {
|
||||||
@ -24,6 +38,7 @@ describe('OrdersService', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
OrdersService,
|
OrdersService,
|
||||||
{ provide: PrismaService, useValue: mockPrisma },
|
{ provide: PrismaService, useValue: mockPrisma },
|
||||||
|
{ provide: SmsService, useValue: mockSmsService },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@ -40,14 +55,6 @@ describe('OrdersService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('create', () => {
|
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 () => {
|
it('should throw BadRequestException if items are empty', async () => {
|
||||||
const dto = { items: [] };
|
const dto = { items: [] };
|
||||||
await expect(service.create('user-id', dto)).rejects.toThrow(
|
await expect(service.create('user-id', dto)).rejects.toThrow(
|
||||||
@ -55,35 +62,68 @@ describe('OrdersService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should successfully create order and sum amounts', async () => {
|
it('should throw BadRequestException for duplicate product IDs in order items', async () => {
|
||||||
const prod = { id: 'prod-1', priceValue: 1000 };
|
const dto = {
|
||||||
mockPrisma.product.findUnique.mockResolvedValue(prod);
|
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({
|
mockPrisma.order.create.mockResolvedValue({
|
||||||
id: 'order-1',
|
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);
|
const result = await service.create('user-id', dto);
|
||||||
|
|
||||||
expect(prisma.product.findUnique).toHaveBeenCalledWith({
|
expect(prisma.product.findMany).toHaveBeenCalledWith({
|
||||||
where: { id: 'prod-1' },
|
where: { id: { in: ['prod-1', 'prod-2'] } },
|
||||||
});
|
|
||||||
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.order.create).toHaveBeenCalled();
|
||||||
expect(result.id).toBe('order-1');
|
expect(result.id).toBe('order-1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||||
import { CreateOrderDto } from './dto/create-order.dto';
|
import { CreateOrderDto } from './dto/create-order.dto';
|
||||||
import { SmsService } from '../common/services/sms.service';
|
import { SmsService } from '../common/services/sms.service';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrdersService {
|
export class OrdersService {
|
||||||
@ -22,7 +23,11 @@ export class OrdersService {
|
|||||||
return `CN-${dateStr}-${random}`;
|
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({
|
const coupon = await this.prisma.coupon.findUnique({
|
||||||
where: { code: code.toUpperCase().trim() },
|
where: { code: code.toUpperCase().trim() },
|
||||||
include: { targets: true },
|
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({
|
throw new BadRequestException({
|
||||||
message: `حداقل مبلغ سبد خرید برای استفاده از این کد ${Number(coupon.minCartValue).toLocaleString('fa-IR')} تومان است`,
|
message: `حداقل مبلغ سبد خرید برای استفاده از این کد ${Number(coupon.minCartValue).toLocaleString('fa-IR')} تومان است`,
|
||||||
error: 'COUPON_MIN_CART',
|
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') {
|
if (coupon.type === 'percent' || coupon.type === 'PERCENTAGE') {
|
||||||
discountAmount = (cartTotal * Number(coupon.value)) / 100;
|
discountAmount = cartTotal.mul(coupon.value).div(100);
|
||||||
if (coupon.maxCartValue && discountAmount > Number(coupon.maxCartValue)) {
|
if (
|
||||||
discountAmount = Number(coupon.maxCartValue);
|
coupon.maxCartValue &&
|
||||||
|
discountAmount.greaterThan(coupon.maxCartValue)
|
||||||
|
) {
|
||||||
|
discountAmount = new Prisma.Decimal(coupon.maxCartValue);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
discountAmount = Number(coupon.value);
|
discountAmount = new Prisma.Decimal(coupon.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const finalDiscount = discountAmount.greaterThan(cartTotal)
|
||||||
|
? cartTotal
|
||||||
|
: discountAmount;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
couponId: coupon.id,
|
couponId: coupon.id,
|
||||||
code: coupon.code,
|
code: coupon.code,
|
||||||
discountAmount: Math.min(discountAmount, cartTotal),
|
discountAmount: finalDiscount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,9 +102,36 @@ export class OrdersService {
|
|||||||
throw new BadRequestException('سبد خرید خالی است');
|
throw new BadRequestException('سبد خرید خالی است');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify stock and build items
|
// Validate quantities and duplicate product IDs
|
||||||
let cartTotal = 0;
|
const productIds = createOrderDto.items.map((item) => item.productId);
|
||||||
const orderItems: Array<{
|
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;
|
productId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
@ -100,24 +139,20 @@ export class OrdersService {
|
|||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
for (const item of createOrderDto.items) {
|
for (const item of createOrderDto.items) {
|
||||||
const product = await this.prisma.product.findUnique({
|
const product = productMap.get(item.productId)!;
|
||||||
where: { id: item.productId },
|
const unitPrice = new Prisma.Decimal(product.priceValue);
|
||||||
});
|
const totalItemPrice = unitPrice.mul(item.quantity);
|
||||||
if (!product) {
|
cartTotal = cartTotal.add(totalItemPrice);
|
||||||
throw new NotFoundException(`محصول یافت نشد`);
|
|
||||||
}
|
orderItemsData.push({
|
||||||
const itemPrice = Number(product.priceValue);
|
|
||||||
const totalItemPrice = itemPrice * item.quantity;
|
|
||||||
cartTotal += totalItemPrice;
|
|
||||||
orderItems.push({
|
|
||||||
productId: item.productId,
|
productId: item.productId,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
unitPrice: itemPrice,
|
unitPrice: Number(unitPrice),
|
||||||
totalPrice: totalItemPrice,
|
totalPrice: Number(totalItemPrice),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let discountAmount = 0;
|
let discountAmount = new Prisma.Decimal(0);
|
||||||
let couponId: string | undefined = undefined;
|
let couponId: string | undefined = undefined;
|
||||||
|
|
||||||
if (createOrderDto.couponCode) {
|
if (createOrderDto.couponCode) {
|
||||||
@ -130,37 +165,78 @@ export class OrdersService {
|
|||||||
couponId = couponResult.couponId;
|
couponId = couponResult.couponId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const charityAmount = createOrderDto.charityDonation || 0;
|
const charityAmount = new Prisma.Decimal(
|
||||||
const finalAmount = Math.max(0, cartTotal - discountAmount) + charityAmount;
|
createOrderDto.charityDonation || 0,
|
||||||
|
);
|
||||||
|
const totalAfterDiscount = cartTotal.sub(discountAmount);
|
||||||
|
const finalAmount = (
|
||||||
|
totalAfterDiscount.lessThan(0)
|
||||||
|
? new Prisma.Decimal(0)
|
||||||
|
: totalAfterDiscount
|
||||||
|
).add(charityAmount);
|
||||||
|
|
||||||
const trackingNumber = this.generateTrackingNumber();
|
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) {
|
if (createOrderDto.paymentMethod === 'wallet' && userId) {
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException('کاربر یافت نشد');
|
throw new NotFoundException('کاربر یافت نشد');
|
||||||
}
|
}
|
||||||
const userBalance = Number(user.walletBalance || 0);
|
const userBalance = new Prisma.Decimal(user.walletBalance || 0);
|
||||||
if (userBalance < finalAmount) {
|
if (userBalance.lessThan(finalAmount)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'موجودی کیف پول برای پرداخت این سفارش کافی نیست',
|
'موجودی کیف پول برای پرداخت این سفارش کافی نیست',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await this.prisma.user.update({
|
|
||||||
where: { id: userId },
|
return this.prisma.$transaction(async (tx) => {
|
||||||
data: {
|
await tx.user.update({
|
||||||
walletBalance: { decrement: finalAmount },
|
where: { id: userId },
|
||||||
},
|
data: {
|
||||||
});
|
walletBalance: { decrement: Number(finalAmount) },
|
||||||
await this.prisma.walletTransaction.create({
|
},
|
||||||
data: {
|
});
|
||||||
userId,
|
|
||||||
amount: finalAmount,
|
await tx.walletTransaction.create({
|
||||||
type: 'withdrawal',
|
data: {
|
||||||
status: 'completed',
|
userId,
|
||||||
description: `پرداخت سفارش ${trackingNumber}`,
|
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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,16 +244,16 @@ export class OrdersService {
|
|||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
couponId,
|
couponId,
|
||||||
totalAmount: finalAmount,
|
totalAmount: Number(finalAmount),
|
||||||
charityDonation: charityAmount,
|
charityDonation: Number(charityAmount),
|
||||||
isRefill: Boolean(createOrderDto.isRefill),
|
isRefill: Boolean(createOrderDto.isRefill),
|
||||||
refillIntervalDays: createOrderDto.refillIntervalDays || 60,
|
refillIntervalDays: createOrderDto.refillIntervalDays || 60,
|
||||||
trackingNumber,
|
trackingNumber,
|
||||||
status: 'processing',
|
status: 'processing',
|
||||||
orderItems: {
|
orderItems: {
|
||||||
create: orderItems,
|
create: orderItemsData,
|
||||||
},
|
},
|
||||||
} as any,
|
},
|
||||||
include: {
|
include: {
|
||||||
orderItems: {
|
orderItems: {
|
||||||
include: { product: true },
|
include: { product: true },
|
||||||
@ -187,20 +263,18 @@ export class OrdersService {
|
|||||||
|
|
||||||
// Send Order Confirmation SMS
|
// Send Order Confirmation SMS
|
||||||
if (userId) {
|
if (userId) {
|
||||||
this.prisma.user
|
const user = await this.prisma.user.findUnique({
|
||||||
.findUnique({ where: { id: userId } })
|
where: { id: userId },
|
||||||
.then((user) => {
|
});
|
||||||
if (user && user.mobile) {
|
if (user?.mobile) {
|
||||||
this.smsService
|
await this.smsService
|
||||||
.sendOrderConfirmation(
|
.sendOrderConfirmation(
|
||||||
user.mobile,
|
user.mobile,
|
||||||
trackingNumber,
|
trackingNumber,
|
||||||
finalAmount.toLocaleString('fa-IR'),
|
Number(finalAmount).toLocaleString('fa-IR'),
|
||||||
)
|
)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return createdOrder;
|
return createdOrder;
|
||||||
@ -283,4 +357,31 @@ export class OrdersService {
|
|||||||
}
|
}
|
||||||
return order;
|
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' }),
|
create: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy' }),
|
||||||
findAllByUser: jest
|
findAllByUser: jest
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValue([{ id: 'pet-id', name: 'Buddy' }]),
|
.mockResolvedValue({ data: [{ id: 'pet-id', name: 'Buddy' }] }),
|
||||||
findOne: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy' }),
|
findOne: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy' }),
|
||||||
update: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy New' }),
|
update: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy New' }),
|
||||||
remove: jest.fn().mockResolvedValue({ success: true }),
|
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);
|
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);
|
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);
|
return this.petsService.findOne(id, req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,7 +183,7 @@ export class PetsController {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
update(
|
update(
|
||||||
@Req() req: any,
|
@Req() req: { user: { id: string } },
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() updatePetDto: UpdatePetDto,
|
@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);
|
return this.petsService.remove(id, req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':petId/reminders')
|
@Post(':petId/reminders')
|
||||||
@ApiOperation({ summary: 'ثبت یادآور جدید برای پت' })
|
@ApiOperation({ summary: 'ثبت یادآور جدید برای پت' })
|
||||||
addReminder(
|
addReminder(
|
||||||
@Req() req: any,
|
@Req() req: { user: { id: string } },
|
||||||
@Param('petId') petId: string,
|
@Param('petId') petId: string,
|
||||||
@Body() createReminderDto: CreateReminderDto,
|
@Body() createReminderDto: CreateReminderDto,
|
||||||
) {
|
) {
|
||||||
@ -226,7 +229,7 @@ export class PetsController {
|
|||||||
@Post(':petId/reminders/:reminderId/toggle')
|
@Post(':petId/reminders/:reminderId/toggle')
|
||||||
@ApiOperation({ summary: 'تغییر وضعیت انجام یادآور در یک تاریخ خاص' })
|
@ApiOperation({ summary: 'تغییر وضعیت انجام یادآور در یک تاریخ خاص' })
|
||||||
toggleReminder(
|
toggleReminder(
|
||||||
@Req() req: any,
|
@Req() req: { user: { id: string } },
|
||||||
@Param('petId') petId: string,
|
@Param('petId') petId: string,
|
||||||
@Param('reminderId') reminderId: string,
|
@Param('reminderId') reminderId: string,
|
||||||
@Body('date') date: string,
|
@Body('date') date: string,
|
||||||
@ -242,7 +245,7 @@ export class PetsController {
|
|||||||
@Post(':petId/health-logs')
|
@Post(':petId/health-logs')
|
||||||
@ApiOperation({ summary: 'ثبت لاگ سلامت جدید برای پت' })
|
@ApiOperation({ summary: 'ثبت لاگ سلامت جدید برای پت' })
|
||||||
addHealthLog(
|
addHealthLog(
|
||||||
@Req() req: any,
|
@Req() req: { user: { id: string } },
|
||||||
@Param('petId') petId: string,
|
@Param('petId') petId: string,
|
||||||
@Body() createHealthLogDto: CreateHealthLogDto,
|
@Body() createHealthLogDto: CreateHealthLogDto,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -1,9 +1,24 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||||
import { CreatePetDto } from './dto/create-pet.dto';
|
import { CreatePetDto } from './dto/create-pet.dto';
|
||||||
import { UpdatePetDto } from './dto/update-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()
|
@Injectable()
|
||||||
export class PetsService {
|
export class PetsService {
|
||||||
constructor(private prisma: PrismaService) {}
|
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) {
|
async findAllByUser(userId: string, filters: PaginationDto) {
|
||||||
const {
|
const {
|
||||||
search,
|
search,
|
||||||
@ -40,7 +115,7 @@ export class PetsService {
|
|||||||
sortOrder = 'desc',
|
sortOrder = 'desc',
|
||||||
} = filters;
|
} = filters;
|
||||||
|
|
||||||
const whereClause: any = { userId };
|
const whereClause: Prisma.PetWhereInput = { userId };
|
||||||
if (search) {
|
if (search) {
|
||||||
whereClause.OR = [
|
whereClause.OR = [
|
||||||
{ name: { contains: search, mode: 'insensitive' } },
|
{ name: { contains: search, mode: 'insensitive' } },
|
||||||
@ -56,6 +131,7 @@ export class PetsService {
|
|||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
orderBy: { [sortBy]: sortOrder },
|
orderBy: { [sortBy]: sortOrder },
|
||||||
|
include: { medicalConditions: true, reminders: true, healthLogs: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.pet.count({ where: whereClause }),
|
this.prisma.pet.count({ where: whereClause }),
|
||||||
]);
|
]);
|
||||||
@ -83,7 +159,7 @@ export class PetsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, userId: string, updatePetDto: UpdatePetDto) {
|
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) {
|
if (updatePetDto.medicalConditions) {
|
||||||
await this.prisma.petMedicalCondition.deleteMany({
|
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);
|
await this.findOne(petId, userId);
|
||||||
return this.prisma.reminder.create({
|
return this.prisma.reminder.create({
|
||||||
data: {
|
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);
|
await this.findOne(petId, userId);
|
||||||
return this.prisma.healthLog.create({
|
return this.prisma.healthLog.create({
|
||||||
data: {
|
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()
|
@IsString()
|
||||||
category?: string;
|
category?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'فیلتر بر اساس آیدی دستهبندی' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
categoryId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: 'فیلتر بر اساس نوع حیوان',
|
description: 'فیلتر بر اساس نوع حیوان',
|
||||||
enum: ['سگ', 'گربه', 'all'],
|
enum: ['سگ', 'گربه', 'all'],
|
||||||
@ -25,4 +30,14 @@ export class GetProductsDto extends PaginationDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
requiresRx?: string;
|
requiresRx?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'حداقل قیمت (تومان)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
minPrice?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'حداکثر قیمت (تومان)' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
maxPrice?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,76 +1,33 @@
|
|||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
Patch,
|
||||||
|
Body,
|
||||||
Query,
|
Query,
|
||||||
Param,
|
Param,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { ProductsService } from './products.service';
|
import { ProductsService } from './products.service';
|
||||||
import { GetProductsDto } from './dto/get-products.dto';
|
import { GetProductsDto } from './dto/get-products.dto';
|
||||||
import {
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
ApiTags,
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
ApiOperation,
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
ApiResponse,
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
ApiOkResponse,
|
|
||||||
ApiNotFoundResponse,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
|
|
||||||
@ApiTags('Products - مدیریت محصولات دارویی')
|
@ApiTags('Products - مدیریت محصولات دارویی')
|
||||||
@Controller('products')
|
@Controller('products')
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
description: 'خطای داخلی سرور',
|
description: 'خطای داخلی سرور',
|
||||||
schema: {
|
|
||||||
example: {
|
|
||||||
success: false,
|
|
||||||
message: 'خطای داخلی سرور',
|
|
||||||
code: 'SERVER_ERROR',
|
|
||||||
details: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
export class ProductsController {
|
export class ProductsController {
|
||||||
constructor(private readonly productsService: ProductsService) {}
|
constructor(private readonly productsService: ProductsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'لیست و فیلتر محصولات' })
|
@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) {
|
findAll(@Query() query: GetProductsDto) {
|
||||||
return this.productsService.findAll(query);
|
return this.productsService.findAll(query);
|
||||||
}
|
}
|
||||||
@ -79,16 +36,6 @@ export class ProductsController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'دریافت فیلترهای فعال محصولات (دسته، علائم، نوع پت)',
|
summary: 'دریافت فیلترهای فعال محصولات (دسته، علائم، نوع پت)',
|
||||||
})
|
})
|
||||||
@ApiOkResponse({
|
|
||||||
description: 'لیست فیلترهای پویا استخراج شده از دیتابیس',
|
|
||||||
schema: {
|
|
||||||
example: {
|
|
||||||
categories: [{ id: '1', name: 'مفاصل و استخوان', slug: 'joints' }],
|
|
||||||
symptoms: ['لنگش', 'ریزش مو'],
|
|
||||||
petTypes: ['سگ', 'گربه', 'هر دو'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
getActiveFilters() {
|
getActiveFilters() {
|
||||||
return this.productsService.getActiveFilters();
|
return this.productsService.getActiveFilters();
|
||||||
}
|
}
|
||||||
@ -97,76 +44,35 @@ export class ProductsController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'دریافت فیلترها و راهکارهای ناوبری (دسته با علائم مربوطه)',
|
summary: 'دریافت فیلترها و راهکارهای ناوبری (دسته با علائم مربوطه)',
|
||||||
})
|
})
|
||||||
@ApiOkResponse({
|
|
||||||
description: 'ساختار درختی فیلترها و تگهای درمانی واقعی متصل به محصولات',
|
|
||||||
schema: {
|
|
||||||
example: [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
name: 'مفاصل و استخوان',
|
|
||||||
slug: 'joints',
|
|
||||||
symptoms: ['درد مفاصل', 'لنگش'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
getNavigationFilters() {
|
getNavigationFilters() {
|
||||||
return this.productsService.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')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'دریافت اطلاعات محصول با شناسه یکتا (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) {
|
async findOne(@Param('id') id: string) {
|
||||||
const product = await this.productsService.findOne(id);
|
const product = await this.productsService.findOne(id);
|
||||||
if (!product) {
|
if (!product) {
|
||||||
throw new NotFoundException(`Product with ID ${id} not found`);
|
throw new NotFoundException('محصول یافت نشد');
|
||||||
}
|
}
|
||||||
return product;
|
return product;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,8 @@ describe('ProductsService', () => {
|
|||||||
product: {
|
product: {
|
||||||
findMany: jest.fn(),
|
findMany: jest.fn(),
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
|
findFirst: jest.fn(),
|
||||||
|
count: jest.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -36,39 +38,19 @@ describe('ProductsService', () => {
|
|||||||
describe('findAll', () => {
|
describe('findAll', () => {
|
||||||
it('should query products with correct filters', async () => {
|
it('should query products with correct filters', async () => {
|
||||||
mockPrisma.product.findMany.mockResolvedValue([{ id: 'prod-1' }]);
|
mockPrisma.product.findMany.mockResolvedValue([{ id: 'prod-1' }]);
|
||||||
|
mockPrisma.product.count.mockResolvedValue(1);
|
||||||
const filters = { category: 'joints', petType: 'سگ', query: 'can' };
|
const filters = { category: 'joints', petType: 'سگ', query: 'can' };
|
||||||
const result = await service.findAll(filters);
|
const result = await service.findAll(filters);
|
||||||
|
|
||||||
expect(prisma.product.findMany).toHaveBeenCalledWith({
|
expect(result.data).toHaveLength(1);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('findOne', () => {
|
describe('findOne', () => {
|
||||||
it('should find product by id', async () => {
|
it('should find product by id', async () => {
|
||||||
const prod = { id: 'prod-1' };
|
const prod = { id: 'prod-1' };
|
||||||
mockPrisma.product.findUnique.mockResolvedValue(prod);
|
mockPrisma.product.findFirst.mockResolvedValue(prod);
|
||||||
const result = await service.findOne('prod-1');
|
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);
|
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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { GetProductsDto } from './dto/get-products.dto';
|
import { GetProductsDto } from './dto/get-products.dto';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProductsService {
|
export class ProductsService {
|
||||||
@ -13,66 +14,71 @@ export class ProductsService {
|
|||||||
search,
|
search,
|
||||||
symptom,
|
symptom,
|
||||||
requiresRx,
|
requiresRx,
|
||||||
|
minPrice,
|
||||||
|
maxPrice,
|
||||||
page = 1,
|
page = 1,
|
||||||
limit = 10,
|
limit = 10,
|
||||||
sortBy = 'createdAt',
|
sortBy = 'createdAt',
|
||||||
sortOrder = 'desc',
|
sortOrder = 'desc',
|
||||||
} = filters;
|
} = filters;
|
||||||
|
|
||||||
const whereClause: any = {};
|
const andConditions: Prisma.ProductWhereInput[] = [];
|
||||||
|
|
||||||
if (requiresRx !== undefined && requiresRx !== null && requiresRx !== '') {
|
if (requiresRx !== undefined && requiresRx !== null && requiresRx !== '') {
|
||||||
whereClause.requiresRx = requiresRx === 'true' || requiresRx === '1';
|
andConditions.push({
|
||||||
|
requiresRx: requiresRx === 'true' || requiresRx === '1',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (category) {
|
if (category) {
|
||||||
whereClause.categorySlug = category;
|
andConditions.push({ categorySlug: category });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (petType && petType !== 'all') {
|
if (petType && petType !== 'all') {
|
||||||
whereClause.suitableFor = { in: [petType, 'هر دو'] };
|
andConditions.push({ suitableFor: { in: [petType, 'هر دو'] } });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by a specific symptom (from URL param ?symptom=...)
|
|
||||||
if (symptom) {
|
if (symptom) {
|
||||||
whereClause.symptoms = {
|
andConditions.push({
|
||||||
some: {
|
symptoms: {
|
||||||
symptom: { contains: symptom, mode: 'insensitive' },
|
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 (search) {
|
||||||
// If symptom filter is already applied, extend via AND to also search names/desc
|
andConditions.push({
|
||||||
// If not, use OR across names, description AND symptoms
|
OR: [
|
||||||
const searchConditions = [
|
{ artNo: { contains: search, mode: 'insensitive' } },
|
||||||
{ artNo: { contains: search, mode: 'insensitive' } },
|
{ barcode: { contains: search, mode: 'insensitive' } },
|
||||||
{ barcode: { contains: search, mode: 'insensitive' } },
|
{ nameFa: { contains: search, mode: 'insensitive' } },
|
||||||
{ nameFa: { contains: search, mode: 'insensitive' } },
|
{ nameEn: { contains: search, mode: 'insensitive' } },
|
||||||
{ nameEn: { contains: search, mode: 'insensitive' } },
|
{ description: { contains: search, mode: 'insensitive' } },
|
||||||
{ description: { contains: search, mode: 'insensitive' } },
|
{ shortDescription: { contains: search, mode: 'insensitive' } },
|
||||||
{ shortDescription: { contains: search, mode: 'insensitive' } },
|
{
|
||||||
{
|
symptoms: {
|
||||||
symptoms: {
|
some: {
|
||||||
some: {
|
symptom: { contains: search, mode: 'insensitive' },
|
||||||
symptom: { contains: search, mode: 'insensitive' },
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
];
|
});
|
||||||
|
|
||||||
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;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const [rawProducts, total] = await Promise.all([
|
const [rawProducts, total] = await Promise.all([
|
||||||
@ -90,13 +96,22 @@ export class ProductsService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const isWholesaleOrAdmin =
|
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 data = rawProducts.map((p) => {
|
||||||
|
const { buyPrice, wholesalePrice, ...publicProduct } = p;
|
||||||
|
void buyPrice;
|
||||||
|
void wholesalePrice;
|
||||||
if (!isWholesaleOrAdmin) {
|
if (!isWholesaleOrAdmin) {
|
||||||
const { wholesalePrice, ...rest } = p;
|
return publicProduct;
|
||||||
return rest;
|
|
||||||
}
|
}
|
||||||
return p;
|
return isAdmin
|
||||||
|
? p
|
||||||
|
: { ...publicProduct, wholesalePrice: p.wholesalePrice };
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -125,12 +140,21 @@ export class ProductsService {
|
|||||||
|
|
||||||
if (!product) return null;
|
if (!product) return null;
|
||||||
const isWholesaleOrAdmin =
|
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) {
|
if (!isWholesaleOrAdmin) {
|
||||||
const { wholesalePrice, ...rest } = product;
|
return publicProduct;
|
||||||
return rest;
|
|
||||||
}
|
}
|
||||||
return product;
|
return isAdmin
|
||||||
|
? product
|
||||||
|
: { ...publicProduct, wholesalePrice: product.wholesalePrice };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getActiveFilters() {
|
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() {
|
onModuleDestroy() {
|
||||||
this.client.disconnect();
|
this.client?.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
|
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { SettingsController } from './settings.controller';
|
import { SettingsController } from './settings.controller';
|
||||||
import { SettingsService } from './settings.service';
|
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', () => {
|
describe('SettingsController', () => {
|
||||||
let controller: SettingsController;
|
let controller: SettingsController;
|
||||||
@ -32,6 +37,16 @@ describe('SettingsController', () => {
|
|||||||
expect(controller).toBeDefined();
|
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 () => {
|
it('should getUiTexts', async () => {
|
||||||
const result = await controller.getUiTexts();
|
const result = await controller.getUiTexts();
|
||||||
expect(service.getUiTexts).toHaveBeenCalled();
|
expect(service.getUiTexts).toHaveBeenCalled();
|
||||||
|
|||||||
@ -7,20 +7,20 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
HttpStatus,
|
|
||||||
} from '@nestjs/common';
|
} 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 { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiResponse,
|
|
||||||
ApiOkResponse,
|
ApiOkResponse,
|
||||||
ApiUnauthorizedResponse,
|
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
@ApiTags('Settings - تنظیمات متون پویا و واژهنامه علمی')
|
@ApiTags('Settings - تنظیمات متون پویا، تنظیمات سئو، مالی و سیستم')
|
||||||
@Controller('settings')
|
@Controller('settings')
|
||||||
export class SettingsController {
|
export class SettingsController {
|
||||||
constructor(private readonly settingsService: SettingsService) {}
|
constructor(private readonly settingsService: SettingsService) {}
|
||||||
@ -30,117 +30,99 @@ export class SettingsController {
|
|||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
description:
|
description:
|
||||||
'یک آبجکت کلید-مقدار حاوی تمامی متون، شعارها و برچسبهای دکمهها',
|
'یک آبجکت کلید-مقدار حاوی تمامی متون، شعارها و برچسبهای دکمهها',
|
||||||
schema: {
|
|
||||||
example: {
|
|
||||||
hero_badge: 'تخصص دارویی از آلمان',
|
|
||||||
hero_title: 'تخصص آلمانی در خدمت سلامت پتهای خانگی',
|
|
||||||
hero_desc: 'بیش از ۴۰ سال تجربه نوآورانه...',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
getUiTexts() {
|
getUiTexts() {
|
||||||
return this.settingsService.getUiTexts();
|
return this.settingsService.getUiTexts();
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('Admin')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Patch('ui-texts/:key')
|
@Patch('ui-texts/:key')
|
||||||
@ApiOperation({ summary: 'ویرایش متن یک کلید در رابط کاربری (نیازمند توکن)' })
|
@Put('ui-texts/:key')
|
||||||
@ApiOkResponse({
|
@ApiOperation({ summary: 'ویرایش یا ثبت متن یک کلید در رابط کاربری' })
|
||||||
description: 'متن با موفقیت بهروزرسانی شد',
|
|
||||||
schema: {
|
|
||||||
example: {
|
|
||||||
key: 'hero_badge',
|
|
||||||
value: 'تخصص دارویی ممتاز از آلمان',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ApiUnauthorizedResponse({
|
|
||||||
description: 'عدم دسترسی به دلیل عدم احراز هویت',
|
|
||||||
schema: {
|
|
||||||
example: {
|
|
||||||
success: false,
|
|
||||||
message: 'Unauthorized',
|
|
||||||
code: 'UNAUTHORIZED',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
updateUiText(@Param('key') key: string, @Body('value') value: string) {
|
updateUiText(@Param('key') key: string, @Body('value') value: string) {
|
||||||
return this.settingsService.updateUiText(key, value);
|
return this.settingsService.updateUiText(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('scientific-terms')
|
@Get('scientific-terms')
|
||||||
@ApiOperation({ summary: 'دریافت تمامی اصطلاحات واژهنامه علمی' })
|
@ApiOperation({ summary: 'دریافت تمامی اصطلاحات واژهنامه علمی' })
|
||||||
@ApiOkResponse({
|
|
||||||
description: 'لیست کامل اصطلاحات علمی به همراه تعاریف و شناسهها',
|
|
||||||
schema: {
|
|
||||||
example: [
|
|
||||||
{
|
|
||||||
key: 'green-mussel',
|
|
||||||
term: 'صدف لبسبز (Perna Canaliculus)',
|
|
||||||
definition: 'این صدف بومی سواحل بکر نیوزیلند است...',
|
|
||||||
wikiId: 'general',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
getScientificTerms() {
|
getScientificTerms() {
|
||||||
return this.settingsService.getScientificTerms();
|
return this.settingsService.getScientificTerms();
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('Admin')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Put('scientific-terms/:key')
|
@Put('scientific-terms/:key')
|
||||||
@ApiOperation({ summary: 'ثبت یا ویرایش یک اصطلاح علمی (نیازمند توکن)' })
|
@ApiOperation({ summary: 'ثبت یا ویرایش یک اصطلاح علمی' })
|
||||||
@ApiOkResponse({
|
upsertScientificTerm(
|
||||||
description: 'اصطلاح علمی ثبت یا ویرایش شد',
|
@Param('key') key: string,
|
||||||
schema: {
|
@Body() data: ScientificTermData,
|
||||||
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) {
|
|
||||||
return this.settingsService.upsertScientificTerm(key, data);
|
return this.settingsService.upsertScientificTerm(key, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles('Admin')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Delete('scientific-terms/:key')
|
@Delete('scientific-terms/:key')
|
||||||
@ApiOperation({ summary: 'حذف یک اصطلاح علمی (نیازمند توکن)' })
|
@ApiOperation({ summary: 'حذف یک اصطلاح علمی' })
|
||||||
@ApiOkResponse({
|
|
||||||
description: 'اصطلاح علمی حذف شد',
|
|
||||||
schema: {
|
|
||||||
example: {
|
|
||||||
success: true,
|
|
||||||
message: 'Scientific term successfully deleted',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@ApiUnauthorizedResponse({
|
|
||||||
description: 'عدم دسترسی',
|
|
||||||
schema: {
|
|
||||||
example: {
|
|
||||||
success: false,
|
|
||||||
message: 'Unauthorized',
|
|
||||||
code: 'UNAUTHORIZED',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
deleteScientificTerm(@Param('key') key: string) {
|
deleteScientificTerm(@Param('key') key: string) {
|
||||||
return this.settingsService.deleteScientificTerm(key);
|
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 { Injectable } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
export class ScientificTermData {
|
||||||
|
term?: string;
|
||||||
|
definition?: string;
|
||||||
|
wikiId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SettingsService {
|
export class SettingsService {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
async getUiTexts() {
|
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) {
|
async updateUiText(key: string, value: string) {
|
||||||
@ -21,19 +40,23 @@ export class SettingsService {
|
|||||||
return this.prisma.scientificTerm.findMany();
|
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({
|
return this.prisma.scientificTerm.upsert({
|
||||||
where: { key },
|
where: { key },
|
||||||
update: {
|
update: {
|
||||||
term: data.term,
|
term,
|
||||||
definition: data.definition,
|
definition,
|
||||||
wikiId: data.wikiId || 'general',
|
wikiId,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
key,
|
key,
|
||||||
term: data.term,
|
term,
|
||||||
definition: data.definition,
|
definition,
|
||||||
wikiId: data.wikiId || 'general',
|
wikiId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -43,4 +66,20 @@ export class SettingsService {
|
|||||||
where: { key },
|
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);
|
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);
|
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);
|
return this.usersService.addAddress(req.user.id, addressDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,7 +157,7 @@ export class UsersController {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
updateAddress(
|
updateAddress(
|
||||||
@Req() req: any,
|
@Req() req: { user: { id: string } },
|
||||||
@Param('addressId') addressId: string,
|
@Param('addressId') addressId: string,
|
||||||
@Body() addressDto: AddressDto,
|
@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);
|
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);
|
return this.usersService.setDefaultAddress(req.user.id, addressId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,7 +220,10 @@ export class UsersController {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiBadRequestResponse({ description: 'مبلغ نامعتبر است' })
|
@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);
|
const amount = Number(body.amount);
|
||||||
if (!amount || amount <= 0) {
|
if (!amount || amount <= 0) {
|
||||||
throw new BadRequestException('مبلغ شارژ باید بزرگتر از صفر باشد');
|
throw new BadRequestException('مبلغ شارژ باید بزرگتر از صفر باشد');
|
||||||
|
|||||||
@ -56,7 +56,16 @@ describe('UsersService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should addAddress', async () => {
|
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({
|
mockPrisma.userAddress.create.mockResolvedValue({
|
||||||
id: 'addr-id',
|
id: 'addr-id',
|
||||||
...addressData,
|
...addressData,
|
||||||
@ -72,7 +81,16 @@ describe('UsersService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should updateAddress', async () => {
|
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({
|
mockPrisma.userAddress.update.mockResolvedValue({
|
||||||
id: 'addr-id',
|
id: 'addr-id',
|
||||||
...addressData,
|
...addressData,
|
||||||
|
|||||||
@ -1,6 +1,18 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
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()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
constructor(private prisma: PrismaService) {}
|
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({
|
return this.prisma.user.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
@ -56,7 +68,7 @@ export class UsersService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async addAddress(userId: string, data: any) {
|
async addAddress(userId: string, data: UserAddressInput) {
|
||||||
if (data.isDefault) {
|
if (data.isDefault) {
|
||||||
await this.prisma.userAddress.updateMany({
|
await this.prisma.userAddress.updateMany({
|
||||||
where: { userId },
|
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) {
|
if (data.isDefault) {
|
||||||
await this.prisma.userAddress.updateMany({
|
await this.prisma.userAddress.updateMany({
|
||||||
where: { userId, NOT: { id: addressId } },
|
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';
|
} from '@nestjs/common';
|
||||||
import { VideosService } from './videos.service';
|
import { VideosService } from './videos.service';
|
||||||
import { CreateVideoDto } from './dto/create-video.dto';
|
import { CreateVideoDto } from './dto/create-video.dto';
|
||||||
|
import { GetVideosQueryDto } from './dto/get-videos-query.dto';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
@ -30,7 +31,7 @@ export class VideosController {
|
|||||||
@ApiQuery({ name: 'limit', required: false })
|
@ApiQuery({ name: 'limit', required: false })
|
||||||
@ApiQuery({ name: 'search', required: false })
|
@ApiQuery({ name: 'search', required: false })
|
||||||
@ApiQuery({ name: 'featured', required: false })
|
@ApiQuery({ name: 'featured', required: false })
|
||||||
findAll(@Query() query: any) {
|
findAll(@Query() query: GetVideosQueryDto) {
|
||||||
return this.videosService.findAll(query);
|
return this.videosService.findAll(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,21 +1,26 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateVideoDto } from './dto/create-video.dto';
|
import { CreateVideoDto } from './dto/create-video.dto';
|
||||||
|
|
||||||
|
export class VideoQuery {
|
||||||
|
page?: number | string;
|
||||||
|
limit?: number | string;
|
||||||
|
search?: string;
|
||||||
|
featured?: boolean | string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VideosService {
|
export class VideosService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
private get video() {
|
async findAll(query: VideoQuery = {}) {
|
||||||
return (this.prisma as any).video;
|
|
||||||
}
|
|
||||||
|
|
||||||
async findAll(query: any) {
|
|
||||||
const page = Number(query.page) || 1;
|
const page = Number(query.page) || 1;
|
||||||
const limit = Number(query.limit) || 20;
|
const limit = Number(query.limit) || 20;
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const where: any = {};
|
const where: Prisma.VideoWhereInput = {};
|
||||||
|
|
||||||
if (query.search) {
|
if (query.search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ title: { contains: query.search, mode: 'insensitive' } },
|
{ title: { contains: query.search, mode: 'insensitive' } },
|
||||||
@ -23,82 +28,71 @@ export class VideosService {
|
|||||||
{ description: { contains: query.search, mode: 'insensitive' } },
|
{ description: { contains: query.search, mode: 'insensitive' } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.featured !== undefined) {
|
if (query.featured !== undefined) {
|
||||||
where.isFeatured = query.featured === 'true' || query.featured === true;
|
where.isFeatured = query.featured === 'true' || query.featured === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [data, total] = await Promise.all([
|
const [videos, total] = await Promise.all([
|
||||||
this.video.findMany({
|
this.prisma.video.findMany({
|
||||||
where,
|
where,
|
||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
|
orderBy: { createdAt: 'desc' },
|
||||||
}),
|
}),
|
||||||
this.video.count({ where }),
|
this.prisma.video.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
videos,
|
||||||
meta: {
|
meta: {
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
lastPage: Math.ceil(total / limit),
|
totalPages: Math.ceil(total / limit),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string, incrementView = false) {
|
async findOne(id: string) {
|
||||||
const video = await this.video.findUnique({
|
const video = await this.prisma.video.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!video) {
|
if (!video) {
|
||||||
throw new NotFoundException('ویدئوی مورد نظر یافت نشد');
|
throw new NotFoundException(`Video with ID ${id} not found`);
|
||||||
}
|
|
||||||
if (incrementView) {
|
|
||||||
await this.video
|
|
||||||
.update({
|
|
||||||
where: { id },
|
|
||||||
data: { viewsCount: { increment: 1 } },
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return video;
|
return video;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateVideoDto) {
|
async create(createVideoDto: CreateVideoDto) {
|
||||||
return this.video.create({
|
return this.prisma.video.create({
|
||||||
data: {
|
data: {
|
||||||
title: dto.title,
|
title: createVideoDto.title,
|
||||||
doctor: dto.doctor,
|
doctor: createVideoDto.doctor,
|
||||||
duration: dto.duration || '۰۲:۰۰',
|
duration: createVideoDto.duration || '00:00',
|
||||||
thumbnail: dto.thumbnail,
|
videoUrl: createVideoDto.videoUrl,
|
||||||
videoUrl: dto.videoUrl,
|
thumbnail: createVideoDto.thumbnail || '',
|
||||||
description: dto.description,
|
description: createVideoDto.description || null,
|
||||||
isFeatured: dto.isFeatured ?? false,
|
isFeatured: createVideoDto.isFeatured || false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: Partial<CreateVideoDto>) {
|
async update(id: string, updateVideoDto: Partial<CreateVideoDto>) {
|
||||||
await this.findOne(id);
|
await this.findOne(id);
|
||||||
return this.video.update({
|
|
||||||
|
return this.prisma.video.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: updateVideoDto,
|
||||||
title: dto.title,
|
|
||||||
doctor: dto.doctor,
|
|
||||||
duration: dto.duration,
|
|
||||||
thumbnail: dto.thumbnail,
|
|
||||||
videoUrl: dto.videoUrl,
|
|
||||||
description: dto.description,
|
|
||||||
isFeatured: dto.isFeatured,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: string) {
|
async remove(id: string) {
|
||||||
await this.findOne(id);
|
await this.findOne(id);
|
||||||
return this.video.delete({
|
|
||||||
|
return this.prisma.video.delete({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,10 @@ export class WholesaleController {
|
|||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'ثبت درخواست همکاری عمدهفروشی (ارسال پروانه کلینیک/داروخانه)',
|
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);
|
return this.wholesaleService.applyForWholesale(req.user.id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||||
|
|
||||||
@ -17,7 +18,7 @@ export class WikiService {
|
|||||||
const allowedSortFields = ['key', 'term', 'wikiId'];
|
const allowedSortFields = ['key', 'term', 'wikiId'];
|
||||||
const sortBy = allowedSortFields.includes(rawSortBy) ? rawSortBy : 'term';
|
const sortBy = allowedSortFields.includes(rawSortBy) ? rawSortBy : 'term';
|
||||||
|
|
||||||
const whereClause: any = {};
|
const whereClause: Prisma.ScientificTermWhereInput = {};
|
||||||
if (search) {
|
if (search) {
|
||||||
whereClause.OR = [
|
whereClause.OR = [
|
||||||
{ term: { contains: search, mode: 'insensitive' } },
|
{ 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 { App } from 'supertest/types';
|
||||||
import { AppModule } from './../src/app.module';
|
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)', () => {
|
describe('AppController (e2e)', () => {
|
||||||
let app: INestApplication<App>;
|
let app: INestApplication<App>;
|
||||||
|
|
||||||
@ -13,14 +20,12 @@ describe('AppController (e2e)', () => {
|
|||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
await app.init();
|
await app.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/ (GET)', () => {
|
it('/api/metrics (GET)', () => {
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer()).get('/api/metrics').expect(200);
|
||||||
.get('/')
|
|
||||||
.expect(200)
|
|
||||||
.expect('Hello World!');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
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