Compare commits

..

No commits in common. "c9a525bead8b70630a11f8656c7c661976958fb7" and "49049a7f78775301f503035ba2d3680eb0ec9e0e" have entirely different histories.

278 changed files with 3633 additions and 35574 deletions

View File

@ -1,9 +1,4 @@
node_modules # dist
**/node_modules
.next
**/.next
dist
**/dist
.env .env
.git .git
.dockerignore .dockerignore

View File

@ -1,81 +0,0 @@
#!/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

View File

@ -9,18 +9,24 @@ on:
jobs: jobs:
deploy: deploy:
runs-on: canina runs-on: canina
timeout-minutes: 20
steps: steps:
- name: Run deploy.sh from within the scripts folder - name: Setup SSH key
shell: sh
env:
SSH_KEY: ${{ secrets.DEPLOY_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p 2234 -H 172.17.0.1 >> ~/.ssh/known_hosts
- name: Deploy with VPN for build
shell: sh
timeout-minutes: 20
env: env:
BRANCH: ${{ github.ref_name }} BRANCH: ${{ github.ref_name }}
run: | run: |
# 1. رفع مشکل DNS echo "Deploying branch: ${BRANCH}"
echo "87.248.133.138 git.parsaaghayi.ir" >> /etc/hosts ssh -o StrictHostKeyChecking=no -p 2234 devops@172.17.0.1 \
"bash /opt/services/scripts/with-vpn.sh \"bash /opt/services/canina/deploy.sh ${BRANCH}\""
# 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}

View File

@ -1,16 +1,7 @@
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 ./
@ -19,17 +10,8 @@ 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 ./
@ -38,7 +20,6 @@ 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

View File

@ -1,7 +1,5 @@
node_modules
dist dist
.env .env
.git .git
.dockerignore .dockerignore
Dockerfile Dockerfile
npm-debug.log

View File

@ -1,25 +1,14 @@
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
USER root RUN apk add --no-cache openssl 2>/dev/null || true
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 . .
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 ENV PRISMA_CLI_BINARY_TARGETS=linux-musl-openssl-3.0.x
RUN npx prisma generate && npm run build
FROM node:20-alpine FROM node:20-alpine
USER root RUN apk add --no-cache openssl 2>/dev/null || true
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 ./
@ -29,6 +18,7 @@ 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"]

View File

@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint';
export default tseslint.config( export default tseslint.config(
{ {
ignores: ['eslint.config.mjs', 'dist/**'], ignores: ['eslint.config.mjs'],
}, },
eslint.configs.recommended, eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked, ...tseslint.configs.recommendedTypeChecked,
@ -29,18 +29,7 @@ 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',
}, },
}, },
); );

View File

@ -23,7 +23,6 @@
"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",
@ -40,7 +39,6 @@
"@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",
@ -2971,13 +2969,6 @@
"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",

View File

@ -17,8 +17,7 @@
"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",
@ -35,7 +34,6 @@
"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",
@ -52,7 +50,6 @@
"@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",

View File

@ -9,25 +9,23 @@ 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")
} }
@ -45,7 +43,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")
@ -61,22 +59,18 @@ 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
altText String? @map("alt_text") @db.VarChar(250) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
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")
} }
@ -93,9 +87,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")
} }
@ -114,7 +108,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")
} }
@ -141,25 +135,20 @@ 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])
@ -169,18 +158,6 @@ 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)
@ -200,22 +177,21 @@ 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")
} }
@ -230,13 +206,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)
@ -252,7 +228,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])
@ -260,68 +236,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")
} }
@ -334,8 +310,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")
} }
@ -348,8 +324,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)
@ -360,42 +336,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")
} }
@ -415,138 +391,3 @@ 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")
}

View File

@ -56,7 +56,6 @@ 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,

View File

@ -50,24 +50,16 @@ 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':
@ -88,20 +80,10 @@ 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':
@ -119,10 +101,7 @@ 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 ( return images[baseSlug] || `/products/${baseSlug}.png`;
images[baseSlug] ||
'https://www.canina.de/media/83/86/e1/123000_123005_Canhydrox_GAG_Canina-Pharma_1280x1280.png'
);
} }
async function findOrCreateCategory( async function findOrCreateCategory(

View File

@ -3,13 +3,6 @@ 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": "ایران",
@ -138,11 +131,10 @@ 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) {
await prisma.uiText.upsert({ const existing = await prisma.uiText.findUnique({ where: { key } });
where: { key }, if (!existing) {
update: { value }, await prisma.uiText.create({ data: { key, value } });
create: { key, value }, }
});
} }
console.log(`Seeded ${entries.length} UI texts.`); console.log(`Seeded ${entries.length} UI texts.`);

View File

@ -3,7 +3,6 @@ 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();
@ -30,21 +29,16 @@ 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: { email: 'admin@canino-iran.com' }, where: { id: '12345678-1234-1234-1234-123456789012' },
update: { update: {},
password: adminHashedPassword,
role: 'Admin',
},
create: { create: {
id: '12345678-1234-1234-1234-123456789012', id: '12345678-1234-1234-1234-123456789012',
firstName: 'مدیر', firstName: 'Admin',
lastName: 'سیستم', lastName: 'System',
email: 'admin@canino-iran.com', email: 'admin@canino.ir',
password: adminHashedPassword, role: 'ADMIN',
role: 'Admin', mobile: '09000000000'
mobile: '09120000001'
} }
}); });
@ -237,146 +231,6 @@ 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([
@ -439,32 +293,7 @@ async function main() {
} }
} }
// 2.5. Seed Ingredients // 3. Seed Products & Categories
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');

View File

@ -1,46 +0,0 @@
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);
});

View File

@ -1,6 +1,5 @@
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;
@ -8,7 +7,6 @@ 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);

View File

@ -1,16 +1,15 @@
import { import {
Controller, Controller,
Get, Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards, UseGuards,
Param,
Put,
Post,
Body,
Delete,
Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { AdminService, ProductInput, CouponInput } from './admin.service'; import { AdminService } 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,
@ -19,85 +18,102 @@ 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 data = await this.adminService.getDashboardStats(); const stats = await this.adminService.getDashboardStats();
return { success: true, data }; return {
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({ name: 'search', required: false, description: 'جستجو' }) @ApiQuery({
async getUsers(@Query() query: AdminQueryDto) { name: 'search',
const result = await this.adminService.getUsers(query); required: false,
return { success: true, ...result }; description: 'جستجو در نام یا ایمیل',
} })
@ApiQuery({
@Get('users/:id') name: 'role',
@ApiOperation({ summary: 'جزئیات کاربر' }) required: false,
async getUserDetails(@Param('id') id: string) { description: 'فیلتر بر اساس نقش کاربر',
const data = await this.adminService.getUserDetails(id); })
return { success: true, data }; async getUsers(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('search') search?: string,
@Query('role') role?: string,
) {
const data = await this.adminService.getUsers({
page,
limit,
search,
role,
});
return { success: true, ...data };
} }
@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,
message: 'موجود کیف پول با موفقیت بروزرسانی شد', data: user,
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: 'جستجو' })
async getProducts(@Query() query: AdminQueryDto) { @ApiQuery({
const result = await this.adminService.getProducts(query); name: 'categoryId',
return { success: true, ...result }; required: false,
description: 'شناسه دسته‌بندی',
})
async getProducts(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('search') search?: string,
@Query('categoryId') categoryId?: string,
) {
const data = await this.adminService.getProducts({
page,
limit,
search,
categoryId,
});
return { success: true, ...data };
} }
@UseGuards(JwtAuthGuard)
@Post('products') @Post('products')
@ApiOperation({ summary: 'ایجاد محصول جدید' }) @ApiOperation({ summary: 'ایجاد محصول جدید' })
async createProduct(@Body() data: ProductInput) { async createProduct(@Body() data: any) {
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: ProductInput) { async updateProduct(@Param('id') id: string, @Body() data: any) {
const product = await this.adminService.updateProduct(id, data); const product = await this.adminService.updateProduct(id, data);
return { return {
success: true, success: true,
@ -105,62 +121,87 @@ 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 { success: true }; return {
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: 'status', required: false, description: 'فیلتر وضعیت' }) @ApiQuery({ name: 'search', required: false, description: 'جستجو' })
async getOrders(@Query() query: AdminQueryDto) { @ApiQuery({ name: 'status', required: false, description: 'وضعیت سفارش' })
const result = await this.adminService.getOrders(query); async getOrders(
return { success: true, ...result }; @Query('page') page?: string,
@Query('limit') limit?: string,
@Query('search') search?: string,
@Query('status') status?: string,
) {
const data = await this.adminService.getOrders({
page,
limit,
search,
status,
});
return { success: true, ...data };
} }
@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('trackingCode') trackingCode?: string, @Body('trackingNumber') trackingNumber?: string,
) { ) {
const order = await this.adminService.updateOrderStatus( const order = await this.adminService.updateOrderStatus(
id, id,
status, status,
trackingCode, trackingNumber,
); );
return { success: true, data: order }; return {
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: AdminQueryDto) { async getCoupons(@Query() query: any) {
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: CouponInput) { async createCoupon(@Body() data: any) {
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: CouponInput) { async updateCoupon(@Param('id') id: string, @Body() data: any) {
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(
@ -171,6 +212,7 @@ 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) {
@ -178,64 +220,20 @@ export class AdminController {
return { success: true }; return { success: true };
} }
@Get('doctors') // --- Settings ---
@ApiOperation({ summary: 'لیست پزشکان و متخصصین' }) @UseGuards(JwtAuthGuard)
async getDoctors() {
const data = await this.adminService.getDoctors();
return { success: true, data };
}
@Post('doctors')
@ApiOperation({ summary: 'افزودن پزشک جدید' })
async createDoctor(
@Body()
body: {
name: string;
title: string;
avatarUrl?: string;
bio?: string;
clinic?: string;
},
) {
const data = await this.adminService.createDoctor(body);
return { success: true, data };
}
@Put('doctors/:id')
@ApiOperation({ summary: 'ویرایش پزشک' })
async updateDoctor(
@Param('id') id: string,
@Body()
body: {
name?: string;
title?: string;
avatarUrl?: string;
bio?: string;
clinic?: string;
},
) {
const data = await this.adminService.updateDoctor(id, body);
return { success: true, data };
}
@Delete('doctors/:id')
@ApiOperation({ summary: 'حذف پزشک' })
async deleteDoctor(@Param('id') id: string) {
await this.adminService.deleteDoctor(id);
return { success: true };
}
@Get('settings') @Get('settings')
@ApiOperation({ summary: 'دریافت تنظیمات کلی سیستم' }) @ApiOperation({ summary: 'دریافت تنظیمات' })
async getSettings() { async getSettings() {
const data = await this.adminService.getSettings(); const settings = await this.adminService.getSettings();
return { success: true, data }; return { success: true, data: settings };
} }
@UseGuards(JwtAuthGuard)
@Put('settings') @Put('settings')
@ApiOperation({ summary: 'بروزرسانی تنظیمات کلی سیستم' }) @ApiOperation({ summary: 'ذخیره تنظیمات' })
async updateSettings(@Body() body: Record<string, string>) { async updateSettings(@Body() data: Record<string, string>) {
const data = await this.adminService.updateSettings(body); const settings = await this.adminService.updateSettings(data);
return { success: true, data }; return { success: true, data: settings };
} }
} }

View File

@ -1,18 +1,12 @@
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: [ providers: [AdminService],
AdminService,
{ provide: PrismaService, useValue: {} },
{ provide: RedisService, useValue: {} },
],
}).compile(); }).compile();
service = module.get<AdminService>(AdminService); service = module.get<AdminService>(AdminService);

View File

@ -1,64 +1,7 @@
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(
@ -67,6 +10,7 @@ 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 },
@ -74,12 +18,15 @@ 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 {
@ -89,33 +36,20 @@ 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: PaginationQuery = {}) { async getUsers(query: any) {
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: Prisma.UserWhereInput = {}; const where: any = {};
if (query.search) { if (query.search) {
where.OR = [ where.OR = [
{ firstName: { contains: query.search, mode: 'insensitive' } }, { firstName: { contains: query.search, mode: 'insensitive' } },
@ -138,13 +72,6 @@ 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 }),
]); ]);
@ -155,20 +82,6 @@ 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 },
@ -176,16 +89,16 @@ export class AdminService {
}); });
} }
async getProducts(query: PaginationQuery = {}) { async getProducts(query: any) {
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: Prisma.ProductWhereInput = {}; const where: any = {};
if (query.search) { if (query.search) {
where.OR = [ where.OR = [
{ nameFa: { contains: query.search, mode: 'insensitive' } }, { name: { contains: query.search, mode: 'insensitive' } },
{ artNo: { contains: query.search } }, { artNo: { contains: query.search } },
]; ];
} }
@ -209,43 +122,34 @@ 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) {
const err = error as { message?: string }; console.error('[AdminService] getProducts error:', error);
console.error('[AdminService] getProducts error:', err); throw new HttpException(error.message || 'Error fetching products', 500);
throw new HttpException(err.message || 'Error fetching products', 500);
} }
} }
async createProduct(data: ProductInput) { async createProduct(data: any) {
const product = await this.prisma.product.create({ const product = await this.prisma.product.create({
data: { data: {
artNo: data.artNo || `ART-${Date.now()}`, artNo: data.artNo,
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 || 0, priceValue: data.priceValue,
priceDisplay: data.priceDisplay || '', priceDisplay: data.priceDisplay,
unit: data.unit || 'عدد', unit: data.unit,
packageSize: data.packageSize || 100, packageSize: data.packageSize,
dosageLogic: data.dosageLogic || '', dosageLogic: data.dosageLogic,
suitableFor: data.suitableFor || 'سگ', suitableFor: data.suitableFor,
imageUrl: data.imageUrl || '', imageUrl: data.imageUrl,
images: Array.isArray(data.images) metaTitle: data.metaTitle,
? data.images metaDescription: data.metaDescription,
: data.images keywords: data.keywords,
? [data.images] canonicalUrl: data.canonicalUrl,
: [], 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()}`,
}, },
}); });
@ -256,14 +160,14 @@ export class AdminService {
productId: product.id, productId: product.id,
symptom: s.trim(), symptom: s.trim(),
})) }))
.filter((s: { symptom: string }) => s.symptom.length > 0), .filter((s: any) => s.symptom.length > 0),
}); });
} }
return product; return product;
} }
async updateProduct(id: string, data: ProductInput) { async updateProduct(id: string, data: any) {
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} یافت نشد.`);
@ -287,14 +191,6 @@ 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,
@ -312,7 +208,7 @@ export class AdminService {
productId: id, productId: id,
symptom: s.trim(), symptom: s.trim(),
})) }))
.filter((s: { symptom: string }) => s.symptom.length > 0), .filter((s: any) => s.symptom.length > 0),
}); });
} }
} }
@ -326,20 +222,21 @@ export class AdminService {
}); });
} }
async getOrders(query: PaginationQuery = {}) { async getOrders(query: any) {
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: Prisma.OrderWhereInput = {}; const where: any = {};
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: { mobile: { contains: query.search } } }, { user: { phone: { contains: query.search } } },
]; ];
} }
if (query.status) { if (query.status) {
@ -372,7 +269,7 @@ export class AdminService {
} }
async updateOrderStatus(id: string, status: string, trackingNumber?: string) { async updateOrderStatus(id: string, status: string, trackingNumber?: string) {
const dataToUpdate: Prisma.OrderUpdateInput = { status }; const dataToUpdate: any = { status };
if (trackingNumber !== undefined) { if (trackingNumber !== undefined) {
dataToUpdate.trackingNumber = trackingNumber; dataToUpdate.trackingNumber = trackingNumber;
} }
@ -390,22 +287,22 @@ export class AdminService {
}); });
} }
async getCoupons(query: PaginationQuery = {}) { // --- Coupons Engine ---
const page = Number(query.page) || 1; async getCoupons(query: any) {
const limit = Number(query.limit) || 10; const { page = 1, limit = 10, search = '' } = query;
const skip = (page - 1) * limit; const skip = (Number(page) - 1) * Number(limit);
const where: Prisma.CouponWhereInput = query.search const where = search
? { code: { contains: query.search, mode: 'insensitive' } } ? { code: { contains: search, mode: 'insensitive' as any } }
: {}; : {};
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: limit, take: Number(limit),
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { targets: true }, include: { targets: true }, // Include polymorphic targets
}), }),
this.prisma.coupon.count({ where }), this.prisma.coupon.count({ where }),
]); ]);
@ -414,14 +311,14 @@ export class AdminService {
data, data,
meta: { meta: {
total, total,
page, page: Number(page),
limit, limit: Number(limit),
lastPage: Math.ceil(total / limit), lastPage: Math.ceil(total / Number(limit)),
}, },
}; };
} }
async createCoupon(data: CouponInput) { async createCoupon(data: any) {
return this.prisma.coupon.create({ return this.prisma.coupon.create({
data: { data: {
code: data.code, code: data.code,
@ -435,7 +332,7 @@ export class AdminService {
targets: targets:
data.targets && data.targets.length > 0 data.targets && data.targets.length > 0
? { ? {
create: data.targets.map((t) => ({ create: data.targets.map((t: any) => ({
targetType: t.targetType, targetType: t.targetType,
targetId: t.targetId, targetId: t.targetId,
modifierType: t.modifierType || 'override', modifierType: t.modifierType || 'override',
@ -448,7 +345,8 @@ export class AdminService {
}); });
} }
async updateCoupon(id: string, data: CouponInput) { async updateCoupon(id: string, data: any) {
// 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({
@ -465,7 +363,7 @@ export class AdminService {
targets: targets:
data.targets && data.targets.length > 0 data.targets && data.targets.length > 0
? { ? {
create: data.targets.map((t) => ({ create: data.targets.map((t: any) => ({
targetType: t.targetType, targetType: t.targetType,
targetId: t.targetId, targetId: t.targetId,
modifierType: t.modifierType || 'override', modifierType: t.modifierType || 'override',
@ -491,15 +389,27 @@ export class AdminService {
}); });
} }
// --- Settings ---
async getSettings() { async getSettings() {
const settings = await this.prisma.uiText.findMany(); const keys = [
'SHIPPING_FEE',
'MIN_ORDER_AMOUNT',
'B2B_DISCOUNT_PERCENT',
'MAINTENANCE_MODE',
];
const settings = await this.prisma.uiText.findMany({
where: { key: { in: keys } },
});
// Transform to an object { SHIPPING_FEE: '50000', ... }
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 },
@ -511,121 +421,4 @@ 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;
}
} }

View File

@ -10,7 +10,6 @@ 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 {
@ -20,8 +19,6 @@ 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)
@ -34,26 +31,20 @@ 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: PaginationDto) { async getBlogs(@Query() query: any) {
return this.blogsService.getBlogs(query); return this.blogsService.getBlogs(query);
} }
@Post() @Post()
@ApiOperation({ summary: 'ایجاد مقاله جدید' }) @ApiOperation({ summary: 'ایجاد مقاله جدید' })
async createBlog( async createBlog(@Body() body: any, @Request() req: any) {
@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( async updateBlog(@Param('id') id: string, @Body() body: any) {
@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 };
} }

View File

@ -1,31 +1,23 @@
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: BlogQuery = {}) { async getBlogs(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: Prisma.BlogWhereInput = query.search const where = search
? { title: { contains: query.search, mode: 'insensitive' } } ? { title: { contains: search, mode: 'insensitive' as any } }
: {}; : {};
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: limit, take: Number(limit),
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { author: { select: { firstName: true, lastName: true } } }, include: { author: { select: { firstName: true, lastName: true } } },
}), }),
@ -36,23 +28,20 @@ export class BlogsService {
data, data,
meta: { meta: {
total, total,
page, page: Number(page),
limit, limit: Number(limit),
lastPage: Math.ceil(total / limit), lastPage: Math.ceil(total / Number(limit)),
}, },
}; };
} }
async createBlog( async createBlog(data: any, authorId: string) {
data: Prisma.BlogCreateWithoutAuthorInput,
authorId: string,
) {
return this.prisma.blog.create({ return this.prisma.blog.create({
data: { ...data, author: { connect: { id: authorId } } }, data: { ...data, authorId },
}); });
} }
async updateBlog(id: string, data: Prisma.BlogUpdateInput) { async updateBlog(id: string, data: any) {
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 });

View File

@ -9,7 +9,6 @@ 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 {
@ -19,8 +18,6 @@ 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)
@ -33,7 +30,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: PaginationDto) { async getCategories(@Query() query: any) {
return this.categoriesService.getCategories(query); return this.categoriesService.getCategories(query);
} }
@ -46,17 +43,14 @@ export class CategoriesController {
@Post() @Post()
@ApiOperation({ summary: 'ایجاد دسته‌بندی جدید' }) @ApiOperation({ summary: 'ایجاد دسته‌بندی جدید' })
async createCategory(@Body() body: Prisma.CategoryCreateInput) { async createCategory(@Body() body: any) {
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( async updateCategory(@Param('id') id: string, @Body() body: any) {
@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 };
} }

View File

@ -1,31 +1,23 @@
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: CategoryQuery = {}) { async getCategories(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: Prisma.CategoryWhereInput = query.search const where = search
? { name: { contains: query.search, mode: 'insensitive' } } ? { name: { contains: search, mode: 'insensitive' as any } }
: {}; : {};
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: limit, take: Number(limit),
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}), }),
this.prisma.category.count({ where }), this.prisma.category.count({ where }),
@ -35,9 +27,9 @@ export class CategoriesService {
data, data,
meta: { meta: {
total, total,
page, page: Number(page),
limit, limit: Number(limit),
lastPage: Math.ceil(total / limit), lastPage: Math.ceil(total / Number(limit)),
}, },
}; };
} }
@ -46,23 +38,28 @@ export class CategoriesService {
return this.prisma.category.findMany({ orderBy: { name: 'asc' } }); return this.prisma.category.findMany({ orderBy: { name: 'asc' } });
} }
async createCategory(data: Prisma.CategoryCreateInput) { async createCategory(data: any) {
const nameStr = data.name || ''; const cleanData = { ...data };
const slugStr = data.slug || nameStr.replace(/\s+/g, '-').toLowerCase(); Object.keys(cleanData).forEach((k) => {
if (cleanData[k] === '') cleanData[k] = null;
return this.prisma.category.create({
data: {
...data,
slug: slugStr,
},
}); });
// Ensure required fields
if (!cleanData.slug)
cleanData.slug = cleanData.name.replace(/\s+/g, '-').toLowerCase();
return this.prisma.category.create({ data: cleanData });
} }
async updateCategory(id: string, data: Prisma.CategoryUpdateInput) { async updateCategory(id: string, data: any) {
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');
return this.prisma.category.update({ where: { id }, data }); const cleanData = { ...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) {

View File

@ -1,20 +0,0 @@
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;
}

View File

@ -2,8 +2,6 @@ import {
Controller, Controller,
Get, Get,
Post, Post,
Put,
Body,
Delete, Delete,
Param, Param,
UseGuards, UseGuards,
@ -30,8 +28,9 @@ 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');
@ -46,23 +45,4 @@ 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 };
}
} }

View File

@ -61,24 +61,4 @@ 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,
},
});
}
} }

View File

@ -15,8 +15,6 @@ 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)
@ -29,7 +27,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: PaginationDto) { async getPets(@Query() query: any) {
return this.petsService.getPets(query); return this.petsService.getPets(query);
} }

View File

@ -1,31 +1,23 @@
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: PetQuery = {}) { async getPets(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: Prisma.PetWhereInput = query.search const where = search
? { name: { contains: query.search, mode: 'insensitive' } } ? { name: { contains: search, mode: 'insensitive' as any } }
: {}; : {};
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: limit, take: Number(limit),
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { include: {
user: { user: {
@ -45,9 +37,9 @@ export class PetsService {
data, data,
meta: { meta: {
total, total,
page, page: Number(page),
limit, limit: Number(limit),
lastPage: Math.ceil(total / limit), lastPage: Math.ceil(total / Number(limit)),
}, },
}; };
} }

View File

@ -76,6 +76,12 @@ 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 } } },
}); });

View File

@ -9,7 +9,6 @@ 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 {
@ -19,8 +18,6 @@ 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)
@ -33,23 +30,20 @@ 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: PaginationDto) { async getTerms(@Query() query: any) {
return this.wikiService.getTerms(query); return this.wikiService.getTerms(query);
} }
@Post() @Post()
@ApiOperation({ summary: 'ایجاد اصطلاح جدید' }) @ApiOperation({ summary: 'ایجاد اصطلاح جدید' })
async createTerm(@Body() body: Prisma.ScientificTermCreateInput) { async createTerm(@Body() body: any) {
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( async updateTerm(@Param('key') key: string, @Body() body: any) {
@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 };
} }

View File

@ -1,31 +1,23 @@
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: WikiQuery = {}) { async getTerms(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: Prisma.ScientificTermWhereInput = query.search const where = search
? { term: { contains: query.search, mode: 'insensitive' } } ? { term: { contains: search, mode: 'insensitive' as any } }
: {}; : {};
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: limit, take: Number(limit),
orderBy: { term: 'asc' }, orderBy: { term: 'asc' },
}), }),
this.prisma.scientificTerm.count({ where }), this.prisma.scientificTerm.count({ where }),
@ -35,18 +27,18 @@ export class WikiService {
data, data,
meta: { meta: {
total, total,
page, page: Number(page),
limit, limit: Number(limit),
lastPage: Math.ceil(total / limit), lastPage: Math.ceil(total / Number(limit)),
}, },
}; };
} }
async createTerm(data: Prisma.ScientificTermCreateInput) { async createTerm(data: any) {
return this.prisma.scientificTerm.create({ data }); return this.prisma.scientificTerm.create({ data });
} }
async updateTerm(key: string, data: Prisma.ScientificTermUpdateInput) { async updateTerm(key: string, data: any) {
const term = await this.prisma.scientificTerm.findUnique({ const term = await this.prisma.scientificTerm.findUnique({
where: { key }, where: { key },
}); });

View File

@ -20,18 +20,10 @@ 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,
@ -54,12 +46,6 @@ import { B2BModule } from './b2b/b2b.module';
CmsModule, CmsModule,
WholesaleModule, WholesaleModule,
VideosModule, VideosModule,
BannersModule,
SmartAdvisorModule,
TestimonialsModule,
IngredientsModule,
PrescriptionsModule,
B2BModule,
], ],
controllers: [MetricsController], controllers: [MetricsController],
providers: [ providers: [
@ -77,19 +63,9 @@ 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 url: string = req.originalUrl || req.url || ''; const todayKey = `visits:${new Date().toISOString().split('T')[0]}`;
const method: string = req.method || ''; this.redisService.incr(todayKey, 86400).catch(() => {}); // TTL = 24h
// 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')

View File

@ -12,8 +12,6 @@ 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({
@ -35,11 +33,12 @@ 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',
}, },
}, },
}) })
@ -126,7 +125,7 @@ export class AuthController {
@ApiOperation({ summary: 'ورود ادمین به پنل مدیریت' }) @ApiOperation({ summary: 'ورود ادمین به پنل مدیریت' })
@ApiOkResponse({ description: 'ورود موفق ادمین به همراه توکن' }) @ApiOkResponse({ description: 'ورود موفق ادمین به همراه توکن' })
@ApiBadRequestResponse({ description: 'اطلاعات ورود ادمین اشتباه است' }) @ApiBadRequestResponse({ description: 'اطلاعات ورود ادمین اشتباه است' })
adminLogin(@Body() body: AdminLoginDto) { adminLogin(@Body() body: any) {
return this.authService.adminLogin(body); return this.authService.adminLogin(body);
} }
} }

View File

@ -11,7 +11,7 @@ import { UsersModule } from '../users/users.module';
UsersModule, UsersModule,
PassportModule, PassportModule,
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_ACCESS_SECRET, secret: process.env.JWT_SECRET || 'super-secret-key',
signOptions: { expiresIn: '7d' }, signOptions: { expiresIn: '7d' },
}), }),
], ],

View File

@ -3,7 +3,6 @@ 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', () => {
@ -11,7 +10,6 @@ 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: {
@ -30,10 +28,6 @@ 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: [
@ -41,7 +35,6 @@ 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();
@ -49,7 +42,6 @@ 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(() => {
@ -64,7 +56,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

View File

@ -6,25 +6,6 @@ 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 {
@ -38,7 +19,8 @@ export class AuthService {
async sendOtp(sendOtpDto: SendOtpDto) { async sendOtp(sendOtpDto: SendOtpDto) {
const { phoneNumber } = sendOtpDto; const { phoneNumber } = sendOtpDto;
const code = crypto.randomInt(10000, 100000).toString(); const code = Math.floor(10000 + Math.random() * 90000).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);
@ -56,10 +38,17 @@ export class AuthService {
const savedCode = await this.redisService.get(`otp:${phoneNumber}`); const savedCode = await this.redisService.get(`otp:${phoneNumber}`);
if (!savedCode || savedCode !== code) { if (!savedCode) {
throw new BadRequestException({ throw new BadRequestException({
message: 'کد وارد شده اشتباه یا منقضی شده است', message: 'کد تایید منقضی شده است',
error: 'INVALID_OTP', error: 'OTP_EXPIRED',
});
}
if (savedCode !== code) {
throw new BadRequestException({
message: 'کد تایید اشتباه است',
error: 'OTP_INVALID',
}); });
} }
@ -75,6 +64,7 @@ export class AuthService {
mobile: phoneNumber, mobile: phoneNumber,
firstName: 'کاربر', firstName: 'کاربر',
lastName: 'جدید', lastName: 'جدید',
email: `${phoneNumber}@temp.local`,
}, },
}); });
} }
@ -91,7 +81,7 @@ export class AuthService {
}; };
} }
async register(registerDto: RegisterInput) { async register(registerDto: any) {
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({
@ -116,7 +106,7 @@ export class AuthService {
} }
} }
const hashedPassword = password ? await bcrypt.hash(password, 10) : ''; const hashedPassword = await bcrypt.hash(password, 10);
const user = await this.prisma.user.create({ const user = await this.prisma.user.create({
data: { data: {
@ -140,7 +130,7 @@ export class AuthService {
}; };
} }
async login(loginDto: LoginInput) { async login(loginDto: any) {
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 } });
@ -158,9 +148,7 @@ export class AuthService {
}); });
} }
const isMatch = password const isMatch = await bcrypt.compare(password, user.password);
? await bcrypt.compare(password, user.password)
: false;
if (!isMatch) { if (!isMatch) {
throw new BadRequestException({ throw new BadRequestException({
message: 'نام کاربری یا رمز عبور اشتباه است', message: 'نام کاربری یا رمز عبور اشتباه است',
@ -180,38 +168,11 @@ export class AuthService {
}; };
} }
async adminLogin(body: AdminLoginInput) { async adminLogin(body: any) {
const adminEmail = process.env.ADMIN_EMAIL || 'admin@canina-iran.com'; if (
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'; body.email === 'admin@canino-iran.com' &&
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,
@ -229,10 +190,9 @@ export class AuthService {
}, },
}; };
} }
throw new BadRequestException({ throw new BadRequestException({
message: 'ایمیل یا رمز عبور مدیریت اشتباه است', message: 'ایمیل یا رمز عبور اشتباه است',
error: 'INVALID_ADMIN_CREDENTIALS', error: 'INVALID_CREDENTIALS',
}); });
} }
} }

View File

@ -1,15 +0,0 @@
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;
}

View File

@ -3,14 +3,10 @@ import { AuthGuard } from '@nestjs/passport';
@Injectable() @Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') { export class JwtAuthGuard extends AuthGuard('jwt') {
handleRequest<TUser = Record<string, unknown>>( handleRequest(err: any, user: any, info: any) {
err: unknown,
user: TUser | false,
): TUser {
if (err || !user) { if (err || !user) {
throw ( throw (
(err as Error) || err || new UnauthorizedException('لطفا ابتدا وارد حساب کاربری خود شوید')
new UnauthorizedException('لطفا ابتدا وارد حساب کاربری خود شوید')
); );
} }
return user; return user;

View File

@ -3,24 +3,17 @@ 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_ACCESS_SECRET!, secretOrKey: process.env.JWT_SECRET || 'super-secret-key',
}); });
} }
async validate(payload: JwtPayload) { async validate(payload: any) {
// 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 };

View File

@ -1 +1,4 @@
export * from '../common/decorators/roles.decorator'; import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

View File

@ -1 +1,36 @@
export * from '../common/guards/roles.guard'; import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from './roles.decorator';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
ROLES_KEY,
[context.getHandler(), context.getClass()],
);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user || !user.role) {
throw new ForbiddenException('شما دسترسی لازم برای این بخش را ندارید');
}
const hasRole = requiredRoles.includes(user.role);
if (!hasRole) {
throw new ForbiddenException('سطح دسترسی شما کافی نیست');
}
return true;
}
}

View File

@ -1,89 +0,0 @@
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();
}
}

View File

@ -1,12 +0,0 @@
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 {}

View File

@ -1,101 +0,0 @@
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' },
});
}
}

View File

@ -1,65 +0,0 @@
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);
}
}

View File

@ -1,12 +0,0 @@
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 {}

View File

@ -1,50 +0,0 @@
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' };
}
}

View File

@ -6,6 +6,7 @@ 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';

View File

@ -1,5 +1,4 @@
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';
@ -16,7 +15,7 @@ export class BlogsService {
sortOrder = 'desc', sortOrder = 'desc',
} = filters; } = filters;
const whereClause: Prisma.BlogWhereInput = { isPublished: true }; const whereClause: any = { isPublished: true };
if (search) { if (search) {
whereClause.OR = [ whereClause.OR = [
{ title: { contains: search, mode: 'insensitive' } }, { title: { contains: search, mode: 'insensitive' } },

View File

@ -1,4 +0,0 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);

View File

@ -49,15 +49,10 @@ export class CustomHttpExceptionFilter implements ExceptionFilter {
}; };
}); });
} else { } else {
const rawMsg = const rawMsg = typeof resObj.message === 'string' ? resObj.message : exception.message;
typeof resObj.message === 'string'
? resObj.message
: exception.message;
message = this.translateGenericMessage(rawMsg, status); message = this.translateGenericMessage(rawMsg, status);
const rawCode = const rawCode = typeof resObj.code === 'string' ? resObj.code : undefined;
typeof resObj.code === 'string' ? resObj.code : undefined; const rawError = typeof resObj.error === 'string' ? resObj.error : 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>) || {};
} }

View File

@ -1,71 +0,0 @@
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);
});
});

View File

@ -1,48 +0,0 @@
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;
}
}

View File

@ -1,67 +0,0 @@
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();
});
});
});

View File

@ -1,41 +0,0 @@
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;
}
}

View File

@ -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 type { Response } from 'express'; import * as express from 'express';
@ApiExcludeController() @ApiExcludeController()
@Controller('metrics') @Controller('metrics')
@ -15,14 +15,14 @@ export class MetricsController {
} }
@Get() @Get()
async getMetrics(@Res() res: Response) { async getMetrics(@Res() res: express.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 { } catch (e) {
dbStatus = 0; dbStatus = 0;
} }

View File

@ -22,7 +22,7 @@ export class PaginatedResponse<T> {
meta: PaginationMeta; meta: PaginationMeta;
} }
export function createPaginatedSchema(dtoModel: { name: string }) { export function createPaginatedSchema(dtoModel: any) {
return { return {
schema: { schema: {
allOf: [ allOf: [

View File

@ -1,4 +1,5 @@
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 {
@ -7,15 +8,10 @@ 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 || '9364100228'; private readonly username = process.env.MELIPAYAMAK_USERNAME || '';
private readonly password = process.env.MELIPAYAMAK_PASSWORD || ''; private readonly password = process.env.MELIPAYAMAK_PASSWORD || '';
/** /**
@ -24,7 +20,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 Simulated] MeliPayamak dispatch to ${options.to} (Pattern: ${options.bodyId}, Args: ${options.args.join(', ')})`, `[SMS Disabled] MeliPayamak credentials missing. Simulated dispatch to ${options.to} (Pattern: ${options.bodyId}, Args: ${options.args.join(', ')})`,
); );
return true; return true;
} }
@ -52,16 +48,15 @@ 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) as MeliPayamakResponse; const json = JSON.parse(data);
const val = json.Value ?? 0; if (json && (json.Value > 15 || json.RetStatus === 1)) {
if (json && (val > 15 || json.RetStatus === 1)) {
this.logger.log( this.logger.log(
`[SMS Sent] Pattern SMS ${options.bodyId} dispatched to ${options.to} (RecId: ${val})`, `[SMS Sent] Pattern SMS ${options.bodyId} dispatched to ${options.to} (RecId: ${json.Value})`,
); );
resolve(true); resolve(true);
} else { } else {
this.logger.error( this.logger.error(
`[SMS Error] Failed sending pattern SMS to ${options.to}. Code: ${val}`, `[SMS Error] Failed sending pattern SMS to ${options.to}. Code: ${json?.Value}`,
); );
resolve(false); resolve(false);
} }
@ -85,13 +80,10 @@ export class SmsService {
} }
/** /**
* Send OTP Verification Code (Pattern 508079) * Send OTP Verification Code
*/ */
async sendOtp(phone: string, otpCode: string): Promise<boolean> { async sendOtp(phone: string, otpCode: string): Promise<boolean> {
const bodyId = parseInt( const bodyId = parseInt(process.env.MELIPAYAMAK_OTP_BODY_ID || '0', 10);
process.env.MELIPAYAMAK_OTP_BODY_ID || '508079',
10,
);
return this.sendPatternSms({ return this.sendPatternSms({
to: phone, to: phone,
bodyId, bodyId,
@ -100,17 +92,14 @@ export class SmsService {
} }
/** /**
* Send Order Confirmation SMS (Pattern 508081) * Send Order Confirmation SMS
*/ */
async sendOrderConfirmation( async sendOrderConfirmation(
phone: string, phone: string,
orderNumber: string, orderNumber: string,
amount: string, amount: string,
): Promise<boolean> { ): Promise<boolean> {
const bodyId = parseInt( const bodyId = parseInt(process.env.MELIPAYAMAK_ORDER_BODY_ID || '0', 10);
process.env.MELIPAYAMAK_ORDER_BODY_ID || '508081',
10,
);
return this.sendPatternSms({ return this.sendPatternSms({
to: phone, to: phone,
bodyId, bodyId,
@ -119,7 +108,7 @@ export class SmsService {
} }
/** /**
* Send Shipping Status SMS with Tracking Code (Pattern 508082) * Send Shipping Status SMS with Tracking Code
*/ */
async sendShippingNotification( async sendShippingNotification(
phone: string, phone: string,
@ -127,7 +116,7 @@ export class SmsService {
trackingCode: string, trackingCode: string,
): Promise<boolean> { ): Promise<boolean> {
const bodyId = parseInt( const bodyId = parseInt(
process.env.MELIPAYAMAK_SHIPPING_BODY_ID || '508082', process.env.MELIPAYAMAK_SHIPPING_BODY_ID || '0',
10, 10,
); );
return this.sendPatternSms({ return this.sendPatternSms({
@ -137,24 +126,6 @@ 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
*/ */
@ -173,12 +144,4 @@ 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);
}
} }

View File

@ -1,85 +0,0 @@
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);
}
}

View File

@ -1,12 +0,0 @@
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 {}

View File

@ -1,180 +0,0 @@
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();
}
}

View File

@ -1,61 +0,0 @@
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);
}
}

View File

@ -1,12 +0,0 @@
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 {}

View File

@ -1,54 +0,0 @@
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 } });
}
}

View File

@ -6,37 +6,9 @@ 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
@ -91,31 +63,18 @@ async function bootstrap() {
new PrismaExceptionFilter(), new PrismaExceptionFilter(),
); );
app.useGlobalInterceptors(new DecimalInterceptor()); 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 isProduction = process.env.NODE_ENV === 'production'; const document = SwaggerModule.createDocument(app, config);
const enableSwagger = process.env.ENABLE_SWAGGER === 'true'; SwaggerModule.setup('api/docs', app, document);
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().catch((err: unknown) => { bootstrap();
console.error('Bootstrap error:', err);
process.exit(1);
});

View File

@ -2,6 +2,7 @@ import {
Controller, Controller,
Get, Get,
Post, Post,
Patch,
Body, Body,
Param, Param,
UseGuards, UseGuards,
@ -12,7 +13,6 @@ 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,6 +22,7 @@ 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';
@ -82,10 +83,7 @@ export class OrdersController {
}, },
}, },
}) })
create( create(@Req() req: any, @Body() createOrderDto: CreateOrderDto) {
@Req() req: { user: { id: string } },
@Body() createOrderDto: CreateOrderDto,
) {
return this.ordersService.create(req.user.id, createOrderDto); return this.ordersService.create(req.user.id, createOrderDto);
} }
@ -107,13 +105,10 @@ export class OrdersController {
@ApiBadRequestResponse({ @ApiBadRequestResponse({
description: 'کد تخفیف نامعتبر، منقضی، یا شرایط آن برقرار نیست', description: 'کد تخفیف نامعتبر، منقضی، یا شرایط آن برقرار نیست',
}) })
validateCoupon( validateCoupon(@Req() req: any, @Body() body: ValidateCouponDto) {
@Req() req: { user: { id: string } },
@Body() body: ValidateCouponDto,
) {
return this.ordersService.validateCoupon( return this.ordersService.validateCoupon(
body.code, body.code,
new Prisma.Decimal(body.cartTotal), body.cartTotal,
req.user.id, req.user.id,
); );
} }
@ -143,7 +138,7 @@ export class OrdersController {
}, },
}, },
}) })
findAll(@Req() req: { user: { id: string } }, @Query() query: PaginationDto) { findAll(@Req() req: any, @Query() query: PaginationDto) {
return this.ordersService.findAllByUser(req.user.id, query); return this.ordersService.findAllByUser(req.user.id, query);
} }
@ -186,7 +181,7 @@ export class OrdersController {
}, },
}, },
}) })
findOne(@Req() req: { user: { id: string } }, @Param('id') id: string) { findOne(@Req() req: any, @Param('id') id: string) {
return this.ordersService.findOne(id, req.user.id); return this.ordersService.findOne(id, req.user.id);
} }
} }

View File

@ -1,9 +1,7 @@
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;
@ -12,7 +10,6 @@ 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(),
@ -20,17 +17,6 @@ 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 () => {
@ -38,7 +24,6 @@ describe('OrdersService', () => {
providers: [ providers: [
OrdersService, OrdersService,
{ provide: PrismaService, useValue: mockPrisma }, { provide: PrismaService, useValue: mockPrisma },
{ provide: SmsService, useValue: mockSmsService },
], ],
}).compile(); }).compile();
@ -55,6 +40,14 @@ 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(
@ -62,68 +55,35 @@ describe('OrdersService', () => {
); );
}); });
it('should throw BadRequestException for duplicate product IDs in order items', async () => { it('should successfully create order and sum amounts', async () => {
const dto = { const prod = { id: 'prod-1', priceValue: 1000 };
items: [ mockPrisma.product.findUnique.mockResolvedValue(prod);
{ 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: new Prisma.Decimal('80.48'), totalAmount: 2000,
}); });
const dto = { const dto = { items: [{ productId: 'prod-1', quantity: 2 }] };
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.findMany).toHaveBeenCalledWith({ expect(prisma.product.findUnique).toHaveBeenCalledWith({
where: { id: { in: ['prod-1', 'prod-2'] } }, where: { id: 'prod-1' },
});
expect(prisma.order.create).toHaveBeenCalledWith({
data: {
userId: 'user-id',
totalAmount: 2000,
status: 'processing',
orderItems: {
create: [{ productId: 'prod-1', quantity: 2 }],
},
},
include: {
orderItems: {
include: { product: true },
},
},
}); });
expect(prisma.order.create).toHaveBeenCalled();
expect(result.id).toBe('order-1'); expect(result.id).toBe('order-1');
}); });
}); });

View File

@ -7,7 +7,6 @@ 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 {
@ -23,11 +22,7 @@ export class OrdersService {
return `CN-${dateStr}-${random}`; return `CN-${dateStr}-${random}`;
} }
async validateCoupon( async validateCoupon(code: string, cartTotal: number, userId: string) {
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 },
@ -54,7 +49,7 @@ export class OrdersService {
}); });
} }
if (coupon.minCartValue && cartTotal.lessThan(coupon.minCartValue)) { if (coupon.minCartValue && cartTotal < Number(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',
@ -73,27 +68,20 @@ export class OrdersService {
}); });
} }
let discountAmount = new Prisma.Decimal(0); let discountAmount = 0;
if (coupon.type === 'percent' || coupon.type === 'PERCENTAGE') { if (coupon.type === 'percent' || coupon.type === 'PERCENTAGE') {
discountAmount = cartTotal.mul(coupon.value).div(100); discountAmount = (cartTotal * Number(coupon.value)) / 100;
if ( if (coupon.maxCartValue && discountAmount > Number(coupon.maxCartValue)) {
coupon.maxCartValue && discountAmount = Number(coupon.maxCartValue);
discountAmount.greaterThan(coupon.maxCartValue)
) {
discountAmount = new Prisma.Decimal(coupon.maxCartValue);
} }
} else { } else {
discountAmount = new Prisma.Decimal(coupon.value); discountAmount = Number(coupon.value);
} }
const finalDiscount = discountAmount.greaterThan(cartTotal)
? cartTotal
: discountAmount;
return { return {
couponId: coupon.id, couponId: coupon.id,
code: coupon.code, code: coupon.code,
discountAmount: finalDiscount, discountAmount: Math.min(discountAmount, cartTotal),
}; };
} }
@ -102,36 +90,9 @@ export class OrdersService {
throw new BadRequestException('سبد خرید خالی است'); throw new BadRequestException('سبد خرید خالی است');
} }
// Validate quantities and duplicate product IDs // Verify stock and build items
const productIds = createOrderDto.items.map((item) => item.productId); let cartTotal = 0;
const uniqueProductIds = new Set(productIds); const orderItems: Array<{
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;
@ -139,20 +100,24 @@ export class OrdersService {
}> = []; }> = [];
for (const item of createOrderDto.items) { for (const item of createOrderDto.items) {
const product = productMap.get(item.productId)!; const product = await this.prisma.product.findUnique({
const unitPrice = new Prisma.Decimal(product.priceValue); where: { id: item.productId },
const totalItemPrice = unitPrice.mul(item.quantity); });
cartTotal = cartTotal.add(totalItemPrice); if (!product) {
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: Number(unitPrice), unitPrice: itemPrice,
totalPrice: Number(totalItemPrice), totalPrice: totalItemPrice,
}); });
} }
let discountAmount = new Prisma.Decimal(0); let discountAmount = 0;
let couponId: string | undefined = undefined; let couponId: string | undefined = undefined;
if (createOrderDto.couponCode) { if (createOrderDto.couponCode) {
@ -165,78 +130,37 @@ export class OrdersService {
couponId = couponResult.couponId; couponId = couponResult.couponId;
} }
const charityAmount = new Prisma.Decimal( const charityAmount = createOrderDto.charityDonation || 0;
createOrderDto.charityDonation || 0, const finalAmount = Math.max(0, cartTotal - discountAmount) + charityAmount;
);
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 and create order atomically // Deduct user wallet balance if payment method is wallet
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 = new Prisma.Decimal(user.walletBalance || 0); const userBalance = Number(user.walletBalance || 0);
if (userBalance.lessThan(finalAmount)) { if (userBalance < finalAmount) {
throw new BadRequestException( throw new BadRequestException(
'موجودی کیف پول برای پرداخت این سفارش کافی نیست', 'موجودی کیف پول برای پرداخت این سفارش کافی نیست',
); );
} }
await this.prisma.user.update({
return this.prisma.$transaction(async (tx) => { where: { id: userId },
await tx.user.update({ data: {
where: { id: userId }, walletBalance: { decrement: finalAmount },
data: { },
walletBalance: { decrement: Number(finalAmount) }, });
}, await this.prisma.walletTransaction.create({
}); data: {
userId,
await tx.walletTransaction.create({ amount: finalAmount,
data: { type: 'withdrawal',
userId, status: 'completed',
amount: Number(finalAmount), description: `پرداخت سفارش ${trackingNumber}`,
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 },
},
},
});
}); });
} }
@ -244,16 +168,16 @@ export class OrdersService {
data: { data: {
userId, userId,
couponId, couponId,
totalAmount: Number(finalAmount), totalAmount: finalAmount,
charityDonation: Number(charityAmount), charityDonation: 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: orderItemsData, create: orderItems,
}, },
}, } as any,
include: { include: {
orderItems: { orderItems: {
include: { product: true }, include: { product: true },
@ -263,18 +187,20 @@ export class OrdersService {
// Send Order Confirmation SMS // Send Order Confirmation SMS
if (userId) { if (userId) {
const user = await this.prisma.user.findUnique({ this.prisma.user
where: { id: userId }, .findUnique({ where: { id: userId } })
}); .then((user) => {
if (user?.mobile) { if (user && user.mobile) {
await this.smsService this.smsService
.sendOrderConfirmation( .sendOrderConfirmation(
user.mobile, user.mobile,
trackingNumber, trackingNumber,
Number(finalAmount).toLocaleString('fa-IR'), finalAmount.toLocaleString('fa-IR'),
) )
.catch(() => {}); .catch(() => {});
} }
})
.catch(() => {});
} }
return createdOrder; return createdOrder;
@ -357,31 +283,4 @@ 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(() => {});
}
}
}
} }

View File

@ -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({ data: [{ id: 'pet-id', name: 'Buddy' }] }), .mockResolvedValue([{ 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 }),

View File

@ -78,10 +78,7 @@ export class PetsController {
}, },
}, },
}) })
create( create(@Req() req: any, @Body() createPetDto: CreatePetDto) {
@Req() req: { user: { id: string } },
@Body() createPetDto: CreatePetDto,
) {
return this.petsService.create(req.user.id, createPetDto); return this.petsService.create(req.user.id, createPetDto);
} }
@ -114,7 +111,7 @@ export class PetsController {
}, },
}, },
}) })
findAll(@Req() req: { user: { id: string } }, @Query() query: PaginationDto) { findAll(@Req() req: any, @Query() query: PaginationDto) {
return this.petsService.findAllByUser(req.user.id, query); return this.petsService.findAllByUser(req.user.id, query);
} }
@ -151,7 +148,7 @@ export class PetsController {
}, },
}, },
}) })
findOne(@Req() req: { user: { id: string } }, @Param('id') id: string) { findOne(@Req() req: any, @Param('id') id: string) {
return this.petsService.findOne(id, req.user.id); return this.petsService.findOne(id, req.user.id);
} }
@ -183,7 +180,7 @@ export class PetsController {
}, },
}) })
update( update(
@Req() req: { user: { id: string } }, @Req() req: any,
@Param('id') id: string, @Param('id') id: string,
@Body() updatePetDto: UpdatePetDto, @Body() updatePetDto: UpdatePetDto,
) { ) {
@ -212,14 +209,14 @@ export class PetsController {
}, },
}, },
}) })
remove(@Req() req: { user: { id: string } }, @Param('id') id: string) { remove(@Req() req: any, @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: { user: { id: string } }, @Req() req: any,
@Param('petId') petId: string, @Param('petId') petId: string,
@Body() createReminderDto: CreateReminderDto, @Body() createReminderDto: CreateReminderDto,
) { ) {
@ -229,7 +226,7 @@ export class PetsController {
@Post(':petId/reminders/:reminderId/toggle') @Post(':petId/reminders/:reminderId/toggle')
@ApiOperation({ summary: 'تغییر وضعیت انجام یادآور در یک تاریخ خاص' }) @ApiOperation({ summary: 'تغییر وضعیت انجام یادآور در یک تاریخ خاص' })
toggleReminder( toggleReminder(
@Req() req: { user: { id: string } }, @Req() req: any,
@Param('petId') petId: string, @Param('petId') petId: string,
@Param('reminderId') reminderId: string, @Param('reminderId') reminderId: string,
@Body('date') date: string, @Body('date') date: string,
@ -245,7 +242,7 @@ export class PetsController {
@Post(':petId/health-logs') @Post(':petId/health-logs')
@ApiOperation({ summary: 'ثبت لاگ سلامت جدید برای پت' }) @ApiOperation({ summary: 'ثبت لاگ سلامت جدید برای پت' })
addHealthLog( addHealthLog(
@Req() req: { user: { id: string } }, @Req() req: any,
@Param('petId') petId: string, @Param('petId') petId: string,
@Body() createHealthLogDto: CreateHealthLogDto, @Body() createHealthLogDto: CreateHealthLogDto,
) { ) {

View File

@ -1,24 +1,9 @@
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) {}
@ -46,66 +31,6 @@ 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,
@ -115,7 +40,7 @@ export class PetsService {
sortOrder = 'desc', sortOrder = 'desc',
} = filters; } = filters;
const whereClause: Prisma.PetWhereInput = { userId }; const whereClause: any = { userId };
if (search) { if (search) {
whereClause.OR = [ whereClause.OR = [
{ name: { contains: search, mode: 'insensitive' } }, { name: { contains: search, mode: 'insensitive' } },
@ -131,7 +56,6 @@ 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 }),
]); ]);
@ -159,7 +83,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); await this.findOne(id, userId); // Ensure it exists and belongs to user
if (updatePetDto.medicalConditions) { if (updatePetDto.medicalConditions) {
await this.prisma.petMedicalCondition.deleteMany({ await this.prisma.petMedicalCondition.deleteMany({
@ -195,7 +119,7 @@ export class PetsService {
}); });
} }
async addReminder(userId: string, petId: string, data: ReminderInput) { async addReminder(userId: string, petId: string, data: any) {
await this.findOne(petId, userId); await this.findOne(petId, userId);
return this.prisma.reminder.create({ return this.prisma.reminder.create({
data: { data: {
@ -257,7 +181,7 @@ export class PetsService {
} }
} }
async addHealthLog(userId: string, petId: string, data: HealthLogInput) { async addHealthLog(userId: string, petId: string, data: any) {
await this.findOne(petId, userId); await this.findOne(petId, userId);
return this.prisma.healthLog.create({ return this.prisma.healthLog.create({
data: { data: {

View File

@ -1,78 +0,0 @@
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);
}
}

View File

@ -1,12 +0,0 @@
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 {}

View File

@ -1,135 +0,0 @@
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 },
});
}
}

View File

@ -8,11 +8,6 @@ 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'],
@ -30,14 +25,4 @@ 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;
} }

View File

@ -1,33 +1,76 @@
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 { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import {
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; ApiTags,
import { RolesGuard } from '../common/guards/roles.guard'; ApiOperation,
import { Roles } from '../common/decorators/roles.decorator'; ApiResponse,
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);
} }
@ -36,6 +79,16 @@ 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();
} }
@ -44,35 +97,76 @@ 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('محصول یافت نشد'); throw new NotFoundException(`Product with ID ${id} not found`);
} }
return product; return product;
} }

View File

@ -10,8 +10,6 @@ describe('ProductsService', () => {
product: { product: {
findMany: jest.fn(), findMany: jest.fn(),
findUnique: jest.fn(), findUnique: jest.fn(),
findFirst: jest.fn(),
count: jest.fn(),
}, },
}; };
@ -38,19 +36,39 @@ 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(result.data).toHaveLength(1); expect(prisma.product.findMany).toHaveBeenCalledWith({
where: {
categorySlug: 'joints',
suitableFor: { in: ['سگ', 'هر دو'] },
OR: [
{ name: { contains: 'can', mode: 'insensitive' } },
{ description: { contains: 'can', mode: 'insensitive' } },
],
},
include: {
ingredients: true,
symptoms: true,
},
});
expect(result).toHaveLength(1);
}); });
}); });
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.findFirst.mockResolvedValue(prod); mockPrisma.product.findUnique.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);
}); });
}); });

View File

@ -1,7 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable } 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 {
@ -14,70 +13,65 @@ 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 andConditions: Prisma.ProductWhereInput[] = []; const whereClause: any = {};
if (requiresRx !== undefined && requiresRx !== null && requiresRx !== '') { if (requiresRx !== undefined && requiresRx !== null && requiresRx !== '') {
andConditions.push({ whereClause.requiresRx = requiresRx === 'true' || requiresRx === '1';
requiresRx: requiresRx === 'true' || requiresRx === '1',
});
} }
if (category) { if (category) {
andConditions.push({ categorySlug: category }); whereClause.categorySlug = category;
} }
if (petType && petType !== 'all') { if (petType && petType !== 'all') {
andConditions.push({ suitableFor: { in: [petType, 'هر دو'] } }); whereClause.suitableFor = { in: [petType, 'هر دو'] };
} }
// Filter by a specific symptom (from URL param ?symptom=...)
if (symptom) { if (symptom) {
andConditions.push({ whereClause.symptoms = {
symptoms: { some: {
some: { symptom: { contains: symptom, mode: 'insensitive' },
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) {
andConditions.push({ // If symptom filter is already applied, extend via AND to also search names/desc
OR: [ // If not, use OR across names, description AND symptoms
{ artNo: { contains: search, mode: 'insensitive' } }, const searchConditions = [
{ barcode: { contains: search, mode: 'insensitive' } }, { artNo: { contains: search, mode: 'insensitive' } },
{ nameFa: { contains: search, mode: 'insensitive' } }, { barcode: { contains: search, mode: 'insensitive' } },
{ nameEn: { contains: search, mode: 'insensitive' } }, { nameFa: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } }, { nameEn: { contains: search, mode: 'insensitive' } },
{ shortDescription: { contains: search, mode: 'insensitive' } }, { description: { contains: search, mode: 'insensitive' } },
{ { shortDescription: { contains: search, mode: 'insensitive' } },
symptoms: { {
some: { symptoms: {
symptom: { contains: search, mode: 'insensitive' }, some: {
}, symptom: { contains: search, mode: 'insensitive' },
}, },
}, },
], },
}); ];
}
const whereClause: Prisma.ProductWhereInput = if (symptom) {
andConditions.length > 0 ? { AND: andConditions } : {}; // 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 skip = (page - 1) * limit; const skip = (page - 1) * limit;
@ -96,22 +90,13 @@ export class ProductsService {
]); ]);
const isWholesaleOrAdmin = const isWholesaleOrAdmin =
userRole === 'User_Wholesale' || userRole === 'User_Wholesale' || userRole === 'ADMIN';
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) {
return publicProduct; const { wholesalePrice, ...rest } = p;
return rest;
} }
return isAdmin return p;
? p
: { ...publicProduct, wholesalePrice: p.wholesalePrice };
}); });
return { return {
@ -140,21 +125,12 @@ export class ProductsService {
if (!product) return null; if (!product) return null;
const isWholesaleOrAdmin = const isWholesaleOrAdmin =
userRole === 'User_Wholesale' || userRole === 'User_Wholesale' || userRole === 'ADMIN';
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) {
return publicProduct; const { wholesalePrice, ...rest } = product;
return rest;
} }
return isAdmin return product;
? product
: { ...publicProduct, wholesalePrice: product.wholesalePrice };
} }
async getActiveFilters() { async getActiveFilters() {
@ -226,26 +202,4 @@ 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 },
});
}
} }

View File

@ -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> {

View File

@ -1,11 +1,6 @@
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;
@ -37,16 +32,6 @@ 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();

View File

@ -7,20 +7,20 @@ import {
Body, Body,
Param, Param,
UseGuards, UseGuards,
HttpStatus,
} from '@nestjs/common'; } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { SettingsService } from './settings.service';
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,99 +30,117 @@ 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, RolesGuard) @UseGuards(JwtAuthGuard)
@Roles('Admin')
@ApiBearerAuth() @ApiBearerAuth()
@Patch('ui-texts/:key') @Patch('ui-texts/:key')
@Put('ui-texts/:key') @ApiOperation({ summary: 'ویرایش متن یک کلید در رابط کاربری (نیازمند توکن)' })
@ApiOperation({ summary: 'ویرایش یا ثبت متن یک کلید در رابط کاربری' }) @ApiOkResponse({
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, RolesGuard) @UseGuards(JwtAuthGuard)
@Roles('Admin')
@ApiBearerAuth() @ApiBearerAuth()
@Put('scientific-terms/:key') @Put('scientific-terms/:key')
@ApiOperation({ summary: 'ثبت یا ویرایش یک اصطلاح علمی' }) @ApiOperation({ summary: 'ثبت یا ویرایش یک اصطلاح علمی (نیازمند توکن)' })
upsertScientificTerm( @ApiOkResponse({
@Param('key') key: string, description: 'اصطلاح علمی ثبت یا ویرایش شد',
@Body() data: ScientificTermData, schema: {
) { example: {
key: 'green-mussel',
term: 'صدف لب‌سبز اصل نیوزیلند',
definition: 'این صدف بومی سواحل بکر نیوزیلند است...',
wikiId: 'general',
},
},
})
@ApiUnauthorizedResponse({
description: 'عدم دسترسی',
schema: {
example: {
success: false,
message: 'Unauthorized',
code: 'UNAUTHORIZED',
},
},
})
upsertScientificTerm(@Param('key') key: string, @Body() data: any) {
return this.settingsService.upsertScientificTerm(key, data); return this.settingsService.upsertScientificTerm(key, data);
} }
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard)
@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);
}
} }

View File

@ -1,31 +1,12 @@
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() {
const [uiTexts, settings] = await Promise.all([ return this.prisma.uiText.findMany();
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) {
@ -40,23 +21,19 @@ export class SettingsService {
return this.prisma.scientificTerm.findMany(); return this.prisma.scientificTerm.findMany();
} }
async upsertScientificTerm(key: string, data: ScientificTermData) { async upsertScientificTerm(key: string, data: any) {
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, term: data.term,
definition, definition: data.definition,
wikiId, wikiId: data.wikiId || 'general',
}, },
create: { create: {
key, key,
term, term: data.term,
definition, definition: data.definition,
wikiId, wikiId: data.wikiId || 'general',
}, },
}); });
} }
@ -66,20 +43,4 @@ 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 },
});
}
} }

View File

@ -1,58 +0,0 @@
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);
}
}

View File

@ -1,12 +0,0 @@
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 {}

View File

@ -1,45 +0,0 @@
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 } });
}
}

View File

@ -1,55 +0,0 @@
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);
}
}

View File

@ -1,12 +0,0 @@
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 {}

View File

@ -1,39 +0,0 @@
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 } });
}
}

View File

@ -65,7 +65,7 @@ export class UsersController {
}, },
}, },
}) })
getProfile(@Req() req: { user: { id: string } }) { getProfile(@Req() req: any) {
return this.usersService.findById(req.user.id); return this.usersService.findById(req.user.id);
} }
@ -100,10 +100,7 @@ export class UsersController {
}, },
}, },
}) })
updateProfile( updateProfile(@Req() req: any, @Body() updateProfileDto: UpdateProfileDto) {
@Req() req: { user: { id: string } },
@Body() updateProfileDto: UpdateProfileDto,
) {
return this.usersService.update(req.user.id, updateProfileDto); return this.usersService.update(req.user.id, updateProfileDto);
} }
@ -128,10 +125,7 @@ export class UsersController {
}, },
}, },
}) })
addAddress( addAddress(@Req() req: any, @Body() addressDto: AddressDto) {
@Req() req: { user: { id: string } },
@Body() addressDto: AddressDto,
) {
return this.usersService.addAddress(req.user.id, addressDto); return this.usersService.addAddress(req.user.id, addressDto);
} }
@ -157,7 +151,7 @@ export class UsersController {
}, },
}) })
updateAddress( updateAddress(
@Req() req: { user: { id: string } }, @Req() req: any,
@Param('addressId') addressId: string, @Param('addressId') addressId: string,
@Body() addressDto: AddressDto, @Body() addressDto: AddressDto,
) { ) {
@ -176,10 +170,7 @@ export class UsersController {
}, },
}, },
}) })
deleteAddress( deleteAddress(@Req() req: any, @Param('addressId') addressId: string) {
@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);
} }
@ -195,10 +186,7 @@ export class UsersController {
}, },
}, },
}) })
setDefaultAddress( setDefaultAddress(@Req() req: any, @Param('addressId') addressId: string) {
@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);
} }
@ -220,10 +208,7 @@ export class UsersController {
}, },
}) })
@ApiBadRequestResponse({ description: 'مبلغ نامعتبر است' }) @ApiBadRequestResponse({ description: 'مبلغ نامعتبر است' })
async topUpWallet( async topUpWallet(@Req() req: any, @Body() body: { amount: number }) {
@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('مبلغ شارژ باید بزرگتر از صفر باشد');

View File

@ -56,16 +56,7 @@ describe('UsersService', () => {
}); });
it('should addAddress', async () => { it('should addAddress', async () => {
const addressData = { const addressData = { title: 'Home', isDefault: true };
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,
@ -81,16 +72,7 @@ describe('UsersService', () => {
}); });
it('should updateAddress', async () => { it('should updateAddress', async () => {
const addressData = { const addressData = { title: 'Work', isDefault: true };
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,

View File

@ -1,18 +1,6 @@
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) {}
@ -49,7 +37,7 @@ export class UsersService {
}); });
} }
async update(id: string, data: Prisma.UserUpdateInput) { async update(id: string, data: any) {
return this.prisma.user.update({ return this.prisma.user.update({
where: { id }, where: { id },
data, data,
@ -68,7 +56,7 @@ export class UsersService {
}); });
} }
async addAddress(userId: string, data: UserAddressInput) { async addAddress(userId: string, data: any) {
if (data.isDefault) { if (data.isDefault) {
await this.prisma.userAddress.updateMany({ await this.prisma.userAddress.updateMany({
where: { userId }, where: { userId },
@ -90,11 +78,7 @@ export class UsersService {
}); });
} }
async updateAddress( async updateAddress(userId: string, addressId: string, data: any) {
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 } },

View File

@ -1,18 +0,0 @@
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;
}

View File

@ -11,7 +11,6 @@ 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,
@ -31,7 +30,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: GetVideosQueryDto) { findAll(@Query() query: any) {
return this.videosService.findAll(query); return this.videosService.findAll(query);
} }

View File

@ -1,26 +1,21 @@
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) {}
async findAll(query: VideoQuery = {}) { private get video() {
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: Prisma.VideoWhereInput = {}; const where: any = {};
if (query.search) { if (query.search) {
where.OR = [ where.OR = [
{ title: { contains: query.search, mode: 'insensitive' } }, { title: { contains: query.search, mode: 'insensitive' } },
@ -28,71 +23,82 @@ 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 [videos, total] = await Promise.all([ const [data, total] = await Promise.all([
this.prisma.video.findMany({ this.video.findMany({
where, where,
skip, skip,
take: limit, take: limit,
orderBy: { createdAt: 'desc' }, orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
}), }),
this.prisma.video.count({ where }), this.video.count({ where }),
]); ]);
return { return {
videos, data,
meta: { meta: {
total, total,
page, page,
limit, limit,
totalPages: Math.ceil(total / limit), lastPage: Math.ceil(total / limit),
}, },
}; };
} }
async findOne(id: string) { async findOne(id: string, incrementView = false) {
const video = await this.prisma.video.findUnique({ const video = await this.video.findUnique({
where: { id }, where: { id },
}); });
if (!video) { if (!video) {
throw new NotFoundException(`Video with ID ${id} not found`); throw new NotFoundException('ویدئوی مورد نظر یافت نشد');
}
if (incrementView) {
await this.video
.update({
where: { id },
data: { viewsCount: { increment: 1 } },
})
.catch(() => {});
} }
return video; return video;
} }
async create(createVideoDto: CreateVideoDto) { async create(dto: CreateVideoDto) {
return this.prisma.video.create({ return this.video.create({
data: { data: {
title: createVideoDto.title, title: dto.title,
doctor: createVideoDto.doctor, doctor: dto.doctor,
duration: createVideoDto.duration || '00:00', duration: dto.duration || '۰۲:۰۰',
videoUrl: createVideoDto.videoUrl, thumbnail: dto.thumbnail,
thumbnail: createVideoDto.thumbnail || '', videoUrl: dto.videoUrl,
description: createVideoDto.description || null, description: dto.description,
isFeatured: createVideoDto.isFeatured || false, isFeatured: dto.isFeatured ?? false,
}, },
}); });
} }
async update(id: string, updateVideoDto: Partial<CreateVideoDto>) { async update(id: string, dto: Partial<CreateVideoDto>) {
await this.findOne(id); await this.findOne(id);
return this.video.update({
return this.prisma.video.update({
where: { id }, where: { id },
data: updateVideoDto, data: {
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 },
}); });
} }

View File

@ -26,10 +26,7 @@ export class WholesaleController {
@ApiOperation({ @ApiOperation({
summary: 'ثبت درخواست همکاری عمده‌فروشی (ارسال پروانه کلینیک/داروخانه)', summary: 'ثبت درخواست همکاری عمده‌فروشی (ارسال پروانه کلینیک/داروخانه)',
}) })
applyForWholesale( applyForWholesale(@Request() req: any, @Body() dto: WholesaleApplyDto) {
@Request() req: { user: { id: string } },
@Body() dto: WholesaleApplyDto,
) {
return this.wholesaleService.applyForWholesale(req.user.id, dto); return this.wholesaleService.applyForWholesale(req.user.id, dto);
} }

View File

@ -1,5 +1,4 @@
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';
@ -18,7 +17,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: Prisma.ScientificTermWhereInput = {}; const whereClause: any = {};
if (search) { if (search) {
whereClause.OR = [ whereClause.OR = [
{ term: { contains: search, mode: 'insensitive' } }, { term: { contains: search, mode: 'insensitive' } },

View File

@ -1,177 +0,0 @@
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');
});
});
});

View File

@ -4,13 +4,6 @@ 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>;
@ -20,12 +13,14 @@ describe('AppController (e2e)', () => {
}).compile(); }).compile();
app = moduleFixture.createNestApplication(); app = moduleFixture.createNestApplication();
app.setGlobalPrefix('api');
await app.init(); await app.init();
}); });
it('/api/metrics (GET)', () => { it('/ (GET)', () => {
return request(app.getHttpServer()).get('/api/metrics').expect(200); return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
}); });
afterEach(async () => { afterEach(async () => {

View File

@ -1,62 +0,0 @@
# 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/`

View File

@ -1,49 +0,0 @@
# 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