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
**/node_modules
.next
**/.next
dist
**/dist
# dist
.env
.git
.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:
deploy:
runs-on: canina
timeout-minutes: 20
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:
BRANCH: ${{ github.ref_name }}
run: |
# 1. رفع مشکل DNS
echo "87.248.133.138 git.parsaaghayi.ir" >> /etc/hosts
# 2. دانلود و اجرای اسکریپت deploy.sh بر اساس برنچ جاری
mkdir -p scripts
wget -qO scripts/deploy.sh https://git.parsaaghayi.ir/parsa/canina/raw/branch/${BRANCH}/scripts/deploy.sh
chmod +x scripts/deploy.sh
cd scripts
./deploy.sh ${BRANCH}
echo "Deploying branch: ${BRANCH}"
ssh -o StrictHostKeyChecking=no -p 2234 devops@172.17.0.1 \
"bash /opt/services/scripts/with-vpn.sh \"bash /opt/services/canina/deploy.sh ${BRANCH}\""

View File

@ -1,16 +1,7 @@
FROM node:20-alpine AS app-builder
USER root
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
ENV NEXT_PUBLIC_API_URL=https://api.canina.ir/api
# Optional: Use Nexus as npm registry
ARG NPM_REGISTRY=
RUN if [ -n "$NPM_REGISTRY" ]; then \
npm config set registry "$NPM_REGISTRY" && \
npm config set strict-ssl false; \
fi
COPY frontend/application/package*.json ./
RUN npm ci --include=dev --prefer-offline --no-audit
COPY frontend/application ./
@ -19,17 +10,8 @@ ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
FROM node:20-alpine AS admin-builder
USER root
WORKDIR /app
ENV VITE_API_URL=https://api.canina.ir/api
# Optional: Use Nexus as npm registry (inherits build-arg)
ARG NPM_REGISTRY=
RUN if [ -n "$NPM_REGISTRY" ]; then \
npm config set registry "$NPM_REGISTRY" && \
npm config set strict-ssl false; \
fi
COPY frontend/admin-panel/package*.json ./
RUN npm ci --include=dev --prefer-offline --no-audit
COPY frontend/admin-panel ./
@ -38,7 +20,6 @@ ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
FROM node:20-alpine
USER root
RUN addgroup -S nginx 2>/dev/null || true && adduser -S nginx -G nginx 2>/dev/null || true
RUN mkdir -p /var/log/nginx /var/cache/nginx /run/nginx /etc/nginx/conf.d /etc/nginx/http.d
COPY --from=nginx:alpine /etc/nginx /etc/nginx

View File

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

View File

@ -1,25 +1,14 @@
FROM node:20-alpine AS builder
USER root
RUN http_proxy=http://172.17.0.1:8888 https_proxy=http://172.17.0.1:8888 apk add --no-cache openssl || apk add --no-cache openssl || true
RUN apk add --no-cache openssl 2>/dev/null || true
WORKDIR /app
ENV PRISMA_SKIP_POSTINSTALL_GENERATE=true
# Optional: Use Nexus as npm registry (pass --build-arg NPM_REGISTRY=http://nexus:8081/repository/npm/)
ARG NPM_REGISTRY=
RUN if [ -n "$NPM_REGISTRY" ]; then \
npm config set registry "$NPM_REGISTRY" && \
npm config set strict-ssl false && \
echo "Using npm registry: $NPM_REGISTRY"; \
fi
COPY package*.json ./
RUN npm ci --prefer-offline --no-audit
COPY . .
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
USER root
RUN http_proxy=http://172.17.0.1:8888 https_proxy=http://172.17.0.1:8888 apk add --no-cache openssl || apk add --no-cache openssl || true
RUN apk add --no-cache openssl 2>/dev/null || true
RUN mkdir -p /app/uploads && chown node:node /app/uploads
WORKDIR /app
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/tsconfig.seed.json ./prisma/tsconfig.seed.json
COPY --chown=node:node --from=builder /app/prisma/scientificTerms.ts ./prisma/scientificTerms.ts
ENV PRISMA_CLI_BINARY_TARGETS=linux-musl-openssl-3.0.x
EXPOSE 3000
USER node
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main"]

View File

@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs', 'dist/**'],
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
@ -29,18 +29,7 @@ export default tseslint.config(
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
'prettier/prettier': ['error', { endOfLine: 'auto' }],
},
},
{
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',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

View File

@ -23,7 +23,6 @@
"class-validator": "^0.15.1",
"helmet": "^8.2.0",
"ioredis": "^5.11.0",
"js-yaml": "^4.1.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
@ -40,7 +39,6 @@
"@types/bcryptjs": "^2.4.6",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/js-yaml": "^4.0.9",
"@types/multer": "^2.1.0",
"@types/node": "^24.12.4",
"@types/passport-jwt": "^4.0.1",
@ -2971,13 +2969,6 @@
"pretty-format": "^30.0.0"
}
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",

View File

@ -17,8 +17,7 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"docs:generate": "ts-node -r tsconfig-paths/register scripts/generate-openapi.ts"
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
@ -35,7 +34,6 @@
"class-validator": "^0.15.1",
"helmet": "^8.2.0",
"ioredis": "^5.11.0",
"js-yaml": "^4.1.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
@ -52,7 +50,6 @@
"@types/bcryptjs": "^2.4.6",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/js-yaml": "^4.0.9",
"@types/multer": "^2.1.0",
"@types/node": "^24.12.4",
"@types/passport-jwt": "^4.0.1",

View File

@ -9,25 +9,23 @@ datasource db {
}
model User {
id String @id @default(uuid()) @db.Uuid
firstName String @map("first_name") @db.VarChar(100)
lastName String @map("last_name") @db.VarChar(100)
email String? @unique @db.VarChar(150)
mobile String @unique @db.VarChar(15)
password String? @db.VarChar(255)
role String @default("User_PetOwner") @db.VarChar(30)
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)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz()
addresses UserAddress[]
walletTransactions WalletTransaction[]
pets Pet[]
orders Order[]
blogs Blog[]
prescriptions Prescription[]
partnerAccount PartnerAccount?
id String @id @default(uuid()) @db.Uuid
firstName String @map("first_name") @db.VarChar(100)
lastName String @map("last_name") @db.VarChar(100)
email String? @unique @db.VarChar(150)
mobile String @unique @db.VarChar(15)
password String? @db.VarChar(255)
role String @default("User_PetOwner") @db.VarChar(30)
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)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz()
addresses UserAddress[]
walletTransactions WalletTransaction[]
pets Pet[]
orders Order[]
blogs Blog[]
@@map("users")
}
@ -45,7 +43,7 @@ model UserAddress {
isDefault Boolean @default(false) @map("is_default")
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])
@@map("user_addresses")
@ -61,22 +59,18 @@ model WalletTransaction {
description String? @db.Text
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")
}
model Media {
id String @id @default(uuid()) @db.Uuid
filename String @db.VarChar(200)
url String @db.Text
mimetype String @db.VarChar(50)
size Int
altText String? @map("alt_text") @db.VarChar(250)
title String? @db.VarChar(250)
description String? @db.Text
caption String? @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
id String @id @default(uuid()) @db.Uuid
filename String @db.VarChar(200)
url String @db.Text
mimetype String @db.VarChar(50)
size Int
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
@@map("media")
}
@ -93,9 +87,9 @@ model Category {
imageUrl String? @map("image_url") @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
parent Category? @relation("SubCategories", fields: [parentId], references: [id])
children Category[] @relation("SubCategories")
products Product[]
parent Category? @relation("SubCategories", fields: [parentId], references: [id])
children Category[] @relation("SubCategories")
products Product[]
@@map("categories")
}
@ -114,7 +108,7 @@ model Blog {
createdAt DateTime @default(now()) @map("created_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")
}
@ -141,25 +135,20 @@ model Product {
unit String @db.VarChar(50)
packageSize Decimal @map("package_size") @db.Decimal(10, 2)
dosageLogic String? @map("dosage_logic") @db.Text
dosageConfig Json? @map("dosage_config")
suitableFor String @map("suitable_for") @db.VarChar(15) // سگ, گربه, هر دو
imageUrl String @map("image_url") @db.Text
images String[] @default([])
podcastUrl String? @map("podcast_url") @db.Text
videoUrl String? @map("video_url") @db.Text
pdfUrl String? @map("pdf_url") @db.Text
metaTitle String? @map("meta_title") @db.VarChar(200)
metaDescription String? @map("meta_description") @db.Text
canonicalUrl String? @map("canonical_url") @db.Text
keywords String? @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
category Category @relation(fields: [categoryId], references: [id])
ingredientList ProductIngredient[]
symptoms ProductSymptom[]
reminders Reminder[]
orderItems OrderItem[]
advisorRules SmartAdvisorRule[]
category Category @relation(fields: [categoryId], references: [id])
ingredientList ProductIngredient[]
symptoms ProductSymptom[]
reminders Reminder[]
orderItems OrderItem[]
advisorRules SmartAdvisorRule[]
@@index([categorySlug])
@@index([suitableFor])
@ -169,18 +158,6 @@ model Product {
@@map("products")
}
model Doctor {
id String @id @default(uuid()) @db.Uuid
name String @db.VarChar(150)
title String @db.VarChar(150)
avatarUrl String? @map("avatar_url") @db.Text
bio String? @db.Text
clinic String? @db.VarChar(200)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
@@map("doctors")
}
model ProductIngredient {
productId String @map("product_id") @db.Uuid
ingredient String @db.VarChar(150)
@ -200,22 +177,21 @@ model ProductSymptom {
}
model Pet {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
name String @db.VarChar(100)
type String @db.VarChar(10) // سگ, گربه
breed String @db.VarChar(100)
age Int
weight Decimal @db.Decimal(5, 2)
activityLevel String @map("activity_level") @db.VarChar(15) // کم, متوسط, زیاد
imageUrl String? @map("image_url") @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
name String @db.VarChar(100)
type String @db.VarChar(10) // سگ, گربه
breed String @db.VarChar(100)
age Int
weight Decimal @db.Decimal(5, 2)
activityLevel String @map("activity_level") @db.VarChar(15) // کم, متوسط, زیاد
imageUrl String? @map("image_url") @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
medicalConditions PetMedicalCondition[]
reminders Reminder[]
healthLogs HealthLog[]
prescriptions Prescription[]
@@map("pets")
}
@ -230,13 +206,13 @@ model PetMedicalCondition {
}
model Reminder {
id String @id @default(uuid()) @db.Uuid
petId String @map("pet_id") @db.Uuid
productId String? @map("product_id") @db.Uuid
title String @db.VarChar(150)
time String @db.VarChar(5) // 08:30
frequency String @db.VarChar(20) // روزانه, هفتگی
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
id String @id @default(uuid()) @db.Uuid
petId String @map("pet_id") @db.Uuid
productId String? @map("product_id") @db.Uuid
title String @db.VarChar(150)
time String @db.VarChar(5) // 08:30
frequency String @db.VarChar(20) // روزانه, هفتگی
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
@ -252,7 +228,7 @@ model ReminderCompletion {
completedDate DateTime @map("completed_date") @db.Date
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])
@@index([reminderId, completedDate])
@ -260,68 +236,68 @@ model ReminderCompletion {
}
model HealthLog {
id String @id @default(uuid()) @db.Uuid
petId String @map("pet_id") @db.Uuid
appetite String @db.VarChar(15)
energy String @db.VarChar(15)
digestion String @db.VarChar(15)
note String? @db.Text
loggedDate DateTime @default(now()) @map("logged_date") @db.Date
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
id String @id @default(uuid()) @db.Uuid
petId String @map("pet_id") @db.Uuid
appetite String @db.VarChar(15)
energy String @db.VarChar(15)
digestion String @db.VarChar(15)
note String? @db.Text
loggedDate DateTime @default(now()) @map("logged_date") @db.Date
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")
}
model Coupon {
id String @id @default(uuid()) @db.Uuid
code String @unique @db.VarChar(50)
type String @default("percent") @db.VarChar(20) // fixed, percent
value Decimal @db.Decimal(15, 2)
minCartValue Decimal? @map("min_cart_value") @db.Decimal(15, 2)
maxCartValue Decimal? @map("max_cart_value") @db.Decimal(15, 2)
maxUses Int? @map("max_uses")
usedCount Int @default(0) @map("used_count")
expiresAt DateTime? @map("expires_at") @db.Timestamptz()
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
id String @id @default(uuid()) @db.Uuid
code String @unique @db.VarChar(50)
type String @default("percent") @db.VarChar(20) // fixed, percent
value Decimal @db.Decimal(15, 2)
minCartValue Decimal? @map("min_cart_value") @db.Decimal(15, 2)
maxCartValue Decimal? @map("max_cart_value") @db.Decimal(15, 2)
maxUses Int? @map("max_uses")
usedCount Int @default(0) @map("used_count")
expiresAt DateTime? @map("expires_at") @db.Timestamptz()
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
orders Order[]
targets CouponTarget[]
orders Order[]
targets CouponTarget[]
@@map("coupons")
}
model CouponTarget {
id String @id @default(uuid()) @db.Uuid
couponId String @map("coupon_id") @db.Uuid
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
modifierType String @default("override") @map("modifier_type") @db.VarChar(20) // override, add, subtract
id String @id @default(uuid()) @db.Uuid
couponId String @map("coupon_id") @db.Uuid
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
modifierType String @default("override") @map("modifier_type") @db.VarChar(20) // override, add, subtract
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])
@@map("coupon_targets")
}
model Order {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
couponId String? @map("coupon_id") @db.Uuid
totalAmount Decimal @map("total_amount") @db.Decimal(15, 2)
charityDonation Decimal @default(0.00) @map("charity_donation") @db.Decimal(15, 2)
isRefill Boolean @default(false) @map("is_refill")
refillIntervalDays Int? @map("refill_interval_days")
status String @default("processing") @db.VarChar(30) // processing, shipped, delivered
trackingNumber String? @unique @map("tracking_number") @db.VarChar(100)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
couponId String? @map("coupon_id") @db.Uuid
totalAmount Decimal @map("total_amount") @db.Decimal(15, 2)
charityDonation Decimal @default(0.00) @map("charity_donation") @db.Decimal(15, 2)
isRefill Boolean @default(false) @map("is_refill")
refillIntervalDays Int? @map("refill_interval_days")
status String @default("processing") @db.VarChar(30) // processing, shipped, delivered
trackingNumber String? @unique @map("tracking_number") @db.VarChar(100)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
coupon Coupon? @relation(fields: [couponId], references: [id])
orderItems OrderItem[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
coupon Coupon? @relation(fields: [couponId], references: [id])
orderItems OrderItem[]
@@map("orders")
}
@ -334,8 +310,8 @@ model OrderItem {
doseQty Decimal? @map("dose_qty") @db.Decimal(10, 2)
doseUnit String? @map("dose_unit") @db.VarChar(50)
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
@@map("order_items")
}
@ -348,8 +324,8 @@ model UiText {
}
model ScientificTerm {
key String @id @db.VarChar(100)
term String @db.VarChar(150)
key String @id @db.VarChar(100)
term String @db.VarChar(150)
definition String @db.Text
wikiId String @map("wiki_id") @db.VarChar(50)
metaTitle String? @map("meta_title") @db.VarChar(200)
@ -360,42 +336,42 @@ model ScientificTerm {
}
model HeroBanner {
id String @id @default(uuid()) @db.Uuid
title String @db.VarChar(200)
subtitle String? @db.Text
imageUrl String @map("image_url") @db.Text
buttonText String? @map("button_text") @db.VarChar(100)
buttonLink String? @map("button_link") @db.Text
isActive Boolean @default(true) @map("is_active")
order Int @default(0)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
id String @id @default(uuid()) @db.Uuid
title String @db.VarChar(200)
subtitle String? @db.Text
imageUrl String @map("image_url") @db.Text
buttonText String? @map("button_text") @db.VarChar(100)
buttonLink String? @map("button_link") @db.Text
isActive Boolean @default(true) @map("is_active")
order Int @default(0)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
@@map("hero_banners")
}
model VetTestimonial {
id String @id @default(uuid()) @db.Uuid
vetName String @map("vet_name") @db.VarChar(150)
clinicName String? @map("clinic_name") @db.VarChar(150)
imageUrl String? @map("image_url") @db.Text
quote String @db.Text
rating Int @default(5)
isActive Boolean @default(true) @map("is_active")
order Int @default(0)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
id String @id @default(uuid()) @db.Uuid
vetName String @map("vet_name") @db.VarChar(150)
clinicName String? @map("clinic_name") @db.VarChar(150)
imageUrl String? @map("image_url") @db.Text
quote String @db.Text
rating Int @default(5)
isActive Boolean @default(true) @map("is_active")
order Int @default(0)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
@@map("vet_testimonials")
}
model SmartAdvisorRule {
id String @id @default(uuid()) @db.Uuid
condition String @db.VarChar(200)
targetPetType String? @map("target_pet_type") @db.VarChar(50)
condition String @db.VarChar(200)
targetPetType String? @map("target_pet_type") @db.VarChar(50)
recommendedProduct String @map("recommended_product_id") @db.Uuid
reason String @db.Text
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")
}
@ -415,138 +391,3 @@ model Video {
@@map("videos")
}
model ContactSubmission {
id String @id @default(uuid()) @db.Uuid
name String @db.VarChar(100)
phone String @db.VarChar(20)
email String? @db.VarChar(150)
subject String? @db.VarChar(200)
message String @db.Text
status String @default("PENDING") @db.VarChar(20) // PENDING, IN_PROGRESS, RESOLVED
adminNotes String? @map("admin_notes") @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
@@map("contact_submissions")
}
model ContactInfo {
id String @id @default(uuid()) @db.Uuid
key String @unique @db.VarChar(100)
title String @db.VarChar(200)
value String @db.Text
icon String? @db.VarChar(100)
order Int @default(0)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz()
@@map("contact_info")
}
model Banner {
id String @id @default(uuid()) @db.Uuid
title String @db.VarChar(200)
subtitle String? @db.Text
imageUrl String @map("image_url") @db.Text
linkUrl String? @map("link_url") @db.Text
position String @default("home_hero") @db.VarChar(50)
isActive Boolean @default(true) @map("is_active")
order Int @default(0)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
@@map("banners")
}
model Testimonial {
id String @id @default(uuid()) @db.Uuid
authorName String @map("author_name") @db.VarChar(150)
roleTitle String? @map("role_title") @db.VarChar(150)
avatarUrl String? @map("avatar_url") @db.Text
content String @db.Text
rating Int @default(5)
isFeatured Boolean @default(false) @map("is_featured")
isActive Boolean @default(true) @map("is_active")
order Int @default(0)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
@@map("testimonials")
}
model Ingredient {
id String @id @default(uuid()) @db.Uuid
nameFa String @map("name_fa") @db.VarChar(150)
nameEn String @map("name_en") @db.VarChar(150)
slug String @unique @db.VarChar(150)
description String? @db.Text
scientificName String? @map("scientific_name") @db.VarChar(200)
imageUrl String? @map("image_url") @db.Text
benefits String[] @default([])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
@@map("ingredients")
}
model Prescription {
id String @id @default(uuid()) @db.Uuid
userId String? @map("user_id") @db.Uuid
petId String? @map("pet_id") @db.Uuid
fileUrl String @map("file_url") @db.Text
status String @default("PENDING") @db.VarChar(30)
notes String? @db.Text
adminNotes String? @map("admin_notes") @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
pet Pet? @relation(fields: [petId], references: [id], onDelete: SetNull)
@@index([userId])
@@map("prescriptions")
}
model B2BInquiry {
id String @id @default(uuid()) @db.Uuid
companyName String @map("company_name") @db.VarChar(200)
contactName String @map("contact_name") @db.VarChar(150)
email String @db.VarChar(150)
phone String @db.VarChar(20)
businessType String @map("business_type") @db.VarChar(50)
estimatedVolume String? @map("estimated_volume") @db.VarChar(100)
message String @db.Text
status String @default("PENDING") @db.VarChar(30)
adminNotes String? @map("admin_notes") @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
@@map("b2b_inquiries")
}
model PartnerAccount {
id String @id @default(uuid()) @db.Uuid
userId String @unique @map("user_id") @db.Uuid
companyName String @map("company_name") @db.VarChar(200)
taxId String? @map("tax_id") @db.VarChar(50)
creditLimit Decimal @default(0.00) @map("credit_limit") @db.Decimal(15, 2)
discountTier String @default("STANDARD") @map("discount_tier") @db.VarChar(30)
status String @default("ACTIVE") @db.VarChar(30)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("partner_accounts")
}
model Setting {
id String @id @default(uuid()) @db.Uuid
category String @db.VarChar(50)
key String @unique @db.VarChar(100)
value Json
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
@@index([category])
@@map("settings")
}

View File

@ -56,7 +56,6 @@ async function main() {
const createdProduct = await prisma.product.upsert({
where: { artNo },
update: {
slug: artNo,
nameFa: name,
nameEn: name,
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',
'canina-herz-vital':
'https://www.canina.de/media/b9/8b/4c/112036_Herz_Vital_Canina-Pharma_1280x1280.png',
'canina-immun-booster':
'https://www.canina.de/media/8c/fb/8e/311000_Canino_Immun_Booster_Paste_Abwehrkraefte_1280x1280.png',
'canina-immun-booster-paste':
'https://www.canina.de/media/8c/fb/8e/311000_Canino_Immun_Booster_Paste_Abwehrkraefte_1280x1280.png',
'canina-katzenmilch':
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
'canina-lachsol':
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
'canina-lachs-l':
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
'canina-lachs-ol':
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
'canina-marine-olmischung-premium':
'https://www.canina.de/media/ad/28/71/153008_Marine_Oelmischung_Premium_Canina-Pharma_1280x1280.png',
'canina-marine-lmischung-premium':
'https://www.canina.de/media/ad/28/71/153008_Marine_Oelmischung_Premium_Canina-Pharma_1280x1280.png',
'canina-moortranke':
'https://www.canina.de/media/9d/b2/87/112708_Moortraenke_Canina-Pharma_1280x1280.png',
'canina-moortrnke':
'https://www.canina.de/media/9d/b2/87/112708_Moortraenke_Canina-Pharma_1280x1280.png',
'canina-petvital-arthro-tabletten':
@ -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',
'canina-rinderfett-pulver':
'https://www.canina.de/media/9a/31/59/131235_Rinderfett_Pulver_Canina-Pharma_1280x1280.png',
'canina-schwarz-kummel-samen':
'https://www.canina.de/media/a9/c8/aa/131105_Schwarzkuemmelsamen_Canina-Pharma_1280x1280.png',
'canina-schwarz-kmmel-samen':
'https://www.canina.de/media/a9/c8/aa/131105_Schwarzkuemmelsamen_Canina-Pharma_1280x1280.png',
'canina-seelgen-tabletten':
'https://www.canina.de/media/e4/c4/b2/130504_130511_130412_Seealgen_Bio-Seealgenmehl_Canina-Pharma_1280x1280.png',
'canina-bio-seelgenmehl':
'https://www.canina.de/media/e4/c4/b2/130504_130511_130412_Seealgen_Bio-Seealgenmehl_Canina-Pharma_1280x1280.png',
'canina-seealgen-tabletten':
'https://www.canina.de/media/e4/c4/b2/130504_130511_130412_Seealgen_Bio-Seealgenmehl_Canina-Pharma_1280x1280.png',
'canina-seealgen-bio-seealgenmehl':
'https://www.canina.de/media/e4/c4/b2/130504_130511_130412_Seealgen_Bio-Seealgenmehl_Canina-Pharma_1280x1280.png',
'canina-taurin-fur-katzen':
'https://www.canina.de/media/c6/aa/62/229505_Taurin_fuer_Katzen_Canina-Pharma_1280x1280.png',
'canina-taurin-fr-katzen':
'https://www.canina.de/media/c6/aa/62/229505_Taurin_fuer_Katzen_Canina-Pharma_1280x1280.png',
'canina-velox-gelenkenergie':
@ -119,10 +101,7 @@ function getProductImageUrl(baseSlug: string): string {
'canina-novagard-green-pfotenpflege':
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
};
return (
images[baseSlug] ||
'https://www.canina.de/media/83/86/e1/123000_123005_Canhydrox_GAG_Canina-Pharma_1280x1280.png'
);
return images[baseSlug] || `/products/${baseSlug}.png`;
}
async function findOrCreateCategory(

View File

@ -3,13 +3,6 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
const UI_TEXTS: Record<string, string> = {
// === Catalog & Maintenance Mode ===
"maintenance_mode": "false",
"catalog_mode": "false",
"catalog_hide_prices": "false",
"catalog_disable_cart": "false",
"catalog_disable_checkout": "false",
// === Header / Navigation ===
"shipping_notice": "ارسال رایگان برای سفارش‌های بالای ۱۵۰ هزار تومان",
"brand_name_fa": "ایران",
@ -138,11 +131,10 @@ async function main() {
const entries = Object.entries(UI_TEXTS);
for (const [key, value] of entries) {
await prisma.uiText.upsert({
where: { key },
update: { value },
create: { key, value },
});
const existing = await prisma.uiText.findUnique({ where: { key } });
if (!existing) {
await prisma.uiText.create({ data: { key, value } });
}
}
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 ts from 'typescript';
import * as vm from 'vm';
import * as bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
@ -30,21 +29,16 @@ async function main() {
console.log('Database successfully wiped.');
console.log('Creating Admin User...');
const adminHashedPassword = await bcrypt.hash('admin123', 10);
await prisma.user.upsert({
where: { email: 'admin@canino-iran.com' },
update: {
password: adminHashedPassword,
role: 'Admin',
},
where: { id: '12345678-1234-1234-1234-123456789012' },
update: {},
create: {
id: '12345678-1234-1234-1234-123456789012',
firstName: 'مدیر',
lastName: 'سیستم',
email: 'admin@canino-iran.com',
password: adminHashedPassword,
role: 'Admin',
mobile: '09120000001'
firstName: 'Admin',
lastName: 'System',
email: 'admin@canino.ir',
role: 'ADMIN',
mobile: '09000000000'
}
});
@ -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([
@ -439,32 +293,7 @@ async function main() {
}
}
// 2.5. Seed Ingredients
console.log('Seeding Ingredients...');
try {
const rawIngs = uiTexts.ingredients_wiki ? JSON.parse(uiTexts.ingredients_wiki) : [];
for (const ing of rawIngs) {
await prisma.ingredient.upsert({
where: { slug: ing.id },
update: {
nameFa: ing.name,
nameEn: ing.id,
description: ing.description,
benefits: ing.benefits || [],
},
create: {
slug: ing.id,
nameFa: ing.name,
nameEn: ing.id,
scientificName: ing.name.includes('(') ? ing.name.split('(')[1].replace(')', '') : ing.name,
description: ing.description,
benefits: ing.benefits || [],
},
});
}
} catch (err) {
console.error('Failed to seed ingredients:', err);
}
// 3. Seed Products & Categories
console.log('Seeding Products & Categories...');
try {
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 { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
describe('AdminController', () => {
let controller: AdminController;
@ -8,7 +7,6 @@ describe('AdminController', () => {
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AdminController],
providers: [{ provide: AdminService, useValue: {} }],
}).compile();
controller = module.get<AdminController>(AdminController);

View File

@ -1,16 +1,15 @@
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Param,
Put,
Post,
Body,
Delete,
Query,
} from '@nestjs/common';
import { AdminService, ProductInput, CouponInput } from './admin.service';
import { AdminQueryDto } from './dto/admin-query.dto';
import { AdminService } from './admin.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import {
ApiTags,
@ -19,85 +18,102 @@ import {
ApiQuery,
} from '@nestjs/swagger';
@ApiTags('Admin - مدیریت سیستم')
@ApiTags('Admin - پنل مدیریت')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('admin')
export class AdminController {
constructor(private readonly adminService: AdminService) {}
@UseGuards(JwtAuthGuard)
@Get('dashboard/stats')
@ApiOperation({ summary: 'داشبورد مدیریت و آمار کلیدی سیستم' })
@ApiOperation({ summary: 'دریافت آمار کلی داشبورد' })
async getDashboardStats() {
const data = await this.adminService.getDashboardStats();
return { success: true, data };
const stats = await this.adminService.getDashboardStats();
return {
success: true,
data: stats,
};
}
@UseGuards(JwtAuthGuard)
@Get('users')
@ApiOperation({ summary: 'لیست کاربران سیستم (با صفحه‌بندی)' })
@ApiOperation({ summary: 'لیست کاربران' })
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' })
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
async getUsers(@Query() query: AdminQueryDto) {
const result = await this.adminService.getUsers(query);
return { success: true, ...result };
}
@Get('users/:id')
@ApiOperation({ summary: 'جزئیات کاربر' })
async getUserDetails(@Param('id') id: string) {
const data = await this.adminService.getUserDetails(id);
return { success: true, data };
@ApiQuery({
name: 'search',
required: false,
description: 'جستجو در نام یا ایمیل',
})
@ApiQuery({
name: 'role',
required: false,
description: 'فیلتر بر اساس نقش کاربر',
})
async getUsers(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('search') search?: string,
@Query('role') role?: string,
) {
const data = await this.adminService.getUsers({
page,
limit,
search,
role,
});
return { success: true, ...data };
}
@UseGuards(JwtAuthGuard)
@Put('users/:id/role')
@ApiOperation({ summary: 'تغییر نقش کاربر' })
async updateUserRole(@Param('id') id: string, @Body('role') role: string) {
const user = await this.adminService.updateUserRole(id, role);
return { success: true, data: user };
}
@Post('users/:id/wallet-adjust')
@ApiOperation({ summary: 'شارژ یا کسر مستقیم کیف پول کاربر توسط ادمین' })
async adjustWallet(
@Param('id') id: string,
@Body('amount') amount: number,
@Body('type') type: 'deposit' | 'withdrawal' | 'refund',
@Body('description') description?: string,
) {
const result = await this.adminService.adjustUserWallet(
id,
Number(amount),
type,
description,
);
return {
success: true,
message: 'موجود کیف پول با موفقیت بروزرسانی شد',
data: result,
data: user,
};
}
@UseGuards(JwtAuthGuard)
@Get('products')
@ApiOperation({ summary: 'لیست محصولات (مدیریت)' })
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' })
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
async getProducts(@Query() query: AdminQueryDto) {
const result = await this.adminService.getProducts(query);
return { success: true, ...result };
@ApiQuery({
name: 'categoryId',
required: false,
description: 'شناسه دسته‌بندی',
})
async getProducts(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('search') search?: string,
@Query('categoryId') categoryId?: string,
) {
const data = await this.adminService.getProducts({
page,
limit,
search,
categoryId,
});
return { success: true, ...data };
}
@UseGuards(JwtAuthGuard)
@Post('products')
@ApiOperation({ summary: 'ایجاد محصول جدید' })
async createProduct(@Body() data: ProductInput) {
async createProduct(@Body() data: any) {
const product = await this.adminService.createProduct(data);
return { success: true, data: product };
}
@UseGuards(JwtAuthGuard)
@Put('products/:id')
@ApiOperation({ summary: 'ویرایش محصول' })
async updateProduct(@Param('id') id: string, @Body() data: ProductInput) {
async updateProduct(@Param('id') id: string, @Body() data: any) {
const product = await this.adminService.updateProduct(id, data);
return {
success: true,
@ -105,62 +121,87 @@ export class AdminController {
};
}
@UseGuards(JwtAuthGuard)
@Delete('products/:id')
@ApiOperation({ summary: 'حذف محصول' })
async deleteProduct(@Param('id') id: string) {
await this.adminService.deleteProduct(id);
return { success: true };
return {
success: true,
message: 'Product deleted',
};
}
@UseGuards(JwtAuthGuard)
@Get('orders')
@ApiOperation({ summary: 'لیست سفارش‌ها (مدیریت)' })
@ApiOperation({ summary: 'لیست سفارش‌ها' })
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' })
@ApiQuery({ name: 'status', required: false, description: 'فیلتر وضعیت' })
async getOrders(@Query() query: AdminQueryDto) {
const result = await this.adminService.getOrders(query);
return { success: true, ...result };
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
@ApiQuery({ name: 'status', required: false, description: 'وضعیت سفارش' })
async getOrders(
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('search') search?: string,
@Query('status') status?: string,
) {
const data = await this.adminService.getOrders({
page,
limit,
search,
status,
});
return { success: true, ...data };
}
@UseGuards(JwtAuthGuard)
@Put('orders/:id/status')
@ApiOperation({ summary: 'بروزرسانی وضعیت سفارش' })
@ApiOperation({ summary: 'تغییر وضعیت و کد رهگیری سفارش' })
async updateOrderStatus(
@Param('id') id: string,
@Body('status') status: string,
@Body('trackingCode') trackingCode?: string,
@Body('trackingNumber') trackingNumber?: string,
) {
const order = await this.adminService.updateOrderStatus(
id,
status,
trackingCode,
trackingNumber,
);
return { success: true, data: order };
return {
success: true,
data: order,
};
}
// --- Coupons ---
@UseGuards(JwtAuthGuard)
@Get('coupons')
@ApiOperation({ summary: 'لیست کد تخفیف‌ها' })
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' })
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
async getCoupons(@Query() query: AdminQueryDto) {
async getCoupons(@Query() query: any) {
const coupons = await this.adminService.getCoupons(query);
return { success: true, ...coupons };
}
@UseGuards(JwtAuthGuard)
@Post('coupons')
@ApiOperation({ summary: 'ایجاد کد تخفیف جدید' })
async createCoupon(@Body() data: CouponInput) {
async createCoupon(@Body() data: any) {
const coupon = await this.adminService.createCoupon(data);
return { success: true, data: coupon };
}
@UseGuards(JwtAuthGuard)
@Put('coupons/:id')
@ApiOperation({ summary: 'ویرایش کد تخفیف' })
async updateCoupon(@Param('id') id: string, @Body() data: CouponInput) {
async updateCoupon(@Param('id') id: string, @Body() data: any) {
const coupon = await this.adminService.updateCoupon(id, data);
return { success: true, data: coupon };
}
@UseGuards(JwtAuthGuard)
@Put('coupons/:id/toggle')
@ApiOperation({ summary: 'فعال/غیرفعال کردن کد تخفیف' })
async toggleCoupon(
@ -171,6 +212,7 @@ export class AdminController {
return { success: true, data: coupon };
}
@UseGuards(JwtAuthGuard)
@Delete('coupons/:id')
@ApiOperation({ summary: 'حذف کد تخفیف' })
async deleteCoupon(@Param('id') id: string) {
@ -178,64 +220,20 @@ export class AdminController {
return { success: true };
}
@Get('doctors')
@ApiOperation({ summary: 'لیست پزشکان و متخصصین' })
async getDoctors() {
const data = await this.adminService.getDoctors();
return { success: true, data };
}
@Post('doctors')
@ApiOperation({ summary: 'افزودن پزشک جدید' })
async createDoctor(
@Body()
body: {
name: string;
title: string;
avatarUrl?: string;
bio?: string;
clinic?: string;
},
) {
const data = await this.adminService.createDoctor(body);
return { success: true, data };
}
@Put('doctors/:id')
@ApiOperation({ summary: 'ویرایش پزشک' })
async updateDoctor(
@Param('id') id: string,
@Body()
body: {
name?: string;
title?: string;
avatarUrl?: string;
bio?: string;
clinic?: string;
},
) {
const data = await this.adminService.updateDoctor(id, body);
return { success: true, data };
}
@Delete('doctors/:id')
@ApiOperation({ summary: 'حذف پزشک' })
async deleteDoctor(@Param('id') id: string) {
await this.adminService.deleteDoctor(id);
return { success: true };
}
// --- Settings ---
@UseGuards(JwtAuthGuard)
@Get('settings')
@ApiOperation({ summary: 'دریافت تنظیمات کلی سیستم' })
@ApiOperation({ summary: 'دریافت تنظیمات' })
async getSettings() {
const data = await this.adminService.getSettings();
return { success: true, data };
const settings = await this.adminService.getSettings();
return { success: true, data: settings };
}
@UseGuards(JwtAuthGuard)
@Put('settings')
@ApiOperation({ summary: 'بروزرسانی تنظیمات کلی سیستم' })
async updateSettings(@Body() body: Record<string, string>) {
const data = await this.adminService.updateSettings(body);
return { success: true, data };
@ApiOperation({ summary: 'ذخیره تنظیمات' })
async updateSettings(@Body() data: Record<string, string>) {
const settings = await this.adminService.updateSettings(data);
return { success: true, data: settings };
}
}

View File

@ -1,18 +1,12 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AdminService } from './admin.service';
import { PrismaService } from '../prisma/prisma.service';
import { RedisService } from '../redis/redis.service';
describe('AdminService', () => {
let service: AdminService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AdminService,
{ provide: PrismaService, useValue: {} },
{ provide: RedisService, useValue: {} },
],
providers: [AdminService],
}).compile();
service = module.get<AdminService>(AdminService);

View File

@ -1,64 +1,7 @@
import { Injectable, HttpException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { RedisService } from '../redis/redis.service';
export class PaginationQuery {
page?: number | string;
limit?: number | string;
search?: string;
role?: string;
categoryId?: string;
status?: string;
}
export class ProductInput {
artNo?: string;
nameFa?: string;
nameEn?: string;
scientificTagline?: string;
description?: string;
shortDescription?: string;
categoryId?: string;
categorySlug?: string;
priceValue?: number;
priceDisplay?: string;
unit?: string;
packageSize?: number;
dosageLogic?: string;
suitableFor?: string;
imageUrl?: string;
images?: string | string[];
podcastUrl?: string | null;
videoUrl?: string | null;
pdfUrl?: string | null;
metaTitle?: string;
metaDescription?: string;
keywords?: string;
canonicalUrl?: string;
slug?: string;
symptoms?: string[];
}
export class CouponTargetInput {
targetType!: string;
targetId!: string;
modifierType?: string;
modifierValue?: number;
}
export class CouponInput {
code!: string;
type?: string;
value!: number;
minCartValue?: number;
maxCartValue?: number;
maxUses?: number;
expiresAt?: string | Date;
isActive?: boolean;
targets?: CouponTargetInput[];
}
@Injectable()
export class AdminService {
constructor(
@ -67,6 +10,7 @@ export class AdminService {
) {}
async getDashboardStats() {
// total revenue
const orders = await this.prisma.order.findMany({
where: { status: { not: 'failed' } },
select: { totalAmount: true },
@ -74,12 +18,15 @@ export class AdminService {
const revenue = orders.reduce((sum, o) => sum + Number(o.totalAmount), 0);
// new orders count (processing status)
const newOrders = await this.prisma.order.count({
where: { status: 'processing' },
});
// active users count
const users = await this.prisma.user.count();
// Today's request count from Redis counter (set by MetricsController)
const todayKey = `visits:${new Date().toISOString().split('T')[0]}`;
let todayVisits = 0;
try {
@ -89,33 +36,20 @@ export class AdminService {
todayVisits = 0;
}
const [dogCount, catCount, bothCount] = await Promise.all([
this.prisma.product.count({ where: { suitableFor: 'سگ' } }),
this.prisma.product.count({ where: { suitableFor: 'گربه' } }),
this.prisma.product.count({
where: { suitableFor: { contains: 'هر دو' } },
}),
]);
return {
revenue,
newOrders,
users,
todayVisits,
categoriesDistribution: [
{ name: 'مکمل سگ', value: dogCount || 8 },
{ name: 'مکمل گربه', value: catCount || 6 },
{ name: 'هر دو (سگ و گربه)', value: bothCount || 12 },
],
};
}
async getUsers(query: PaginationQuery = {}) {
async getUsers(query: any) {
const page = Number(query.page) || 1;
const limit = Number(query.limit) || 10;
const skip = (page - 1) * limit;
const where: Prisma.UserWhereInput = {};
const where: any = {};
if (query.search) {
where.OR = [
{ firstName: { contains: query.search, mode: 'insensitive' } },
@ -138,13 +72,6 @@ export class AdminService {
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
addresses: true,
walletTransactions: {
orderBy: { createdAt: 'desc' },
take: 20,
},
},
}),
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) {
return this.prisma.user.update({
where: { id },
@ -176,16 +89,16 @@ export class AdminService {
});
}
async getProducts(query: PaginationQuery = {}) {
async getProducts(query: any) {
try {
const page = Number(query.page) || 1;
const limit = Number(query.limit) || 10;
const skip = (page - 1) * limit;
const where: Prisma.ProductWhereInput = {};
const where: any = {};
if (query.search) {
where.OR = [
{ nameFa: { contains: query.search, mode: 'insensitive' } },
{ name: { contains: query.search, mode: 'insensitive' } },
{ artNo: { contains: query.search } },
];
}
@ -209,43 +122,34 @@ export class AdminService {
meta: { total, page, limit, lastPage: Math.ceil(total / limit) },
};
} catch (error) {
const err = error as { message?: string };
console.error('[AdminService] getProducts error:', err);
throw new HttpException(err.message || 'Error fetching products', 500);
console.error('[AdminService] getProducts error:', error);
throw new HttpException(error.message || 'Error fetching products', 500);
}
}
async createProduct(data: ProductInput) {
async createProduct(data: any) {
const product = await this.prisma.product.create({
data: {
artNo: data.artNo || `ART-${Date.now()}`,
nameFa: data.nameFa || '',
nameEn: data.nameEn || '',
scientificTagline: data.scientificTagline || '',
description: data.description || '',
shortDescription: data.shortDescription || '',
categoryId: data.categoryId || '',
artNo: data.artNo,
nameFa: data.nameFa,
nameEn: data.nameEn,
scientificTagline: data.scientificTagline,
description: data.description,
shortDescription: data.shortDescription,
categoryId: data.categoryId,
categorySlug: data.categorySlug || 'general',
priceValue: data.priceValue || 0,
priceDisplay: data.priceDisplay || '',
unit: data.unit || 'عدد',
packageSize: data.packageSize || 100,
dosageLogic: data.dosageLogic || '',
suitableFor: data.suitableFor || 'سگ',
imageUrl: data.imageUrl || '',
images: Array.isArray(data.images)
? data.images
: data.images
? [data.images]
: [],
podcastUrl: data.podcastUrl || null,
videoUrl: data.videoUrl || null,
pdfUrl: data.pdfUrl || null,
metaTitle: data.metaTitle || '',
metaDescription: data.metaDescription || '',
keywords: data.keywords || '',
canonicalUrl: data.canonicalUrl || '',
slug: data.slug || data.artNo || `slug-${Date.now()}`,
priceValue: data.priceValue,
priceDisplay: data.priceDisplay,
unit: data.unit,
packageSize: data.packageSize,
dosageLogic: data.dosageLogic,
suitableFor: data.suitableFor,
imageUrl: data.imageUrl,
metaTitle: data.metaTitle,
metaDescription: data.metaDescription,
keywords: data.keywords,
canonicalUrl: data.canonicalUrl,
slug: data.slug || data.artNo,
},
});
@ -256,14 +160,14 @@ export class AdminService {
productId: product.id,
symptom: s.trim(),
}))
.filter((s: { symptom: string }) => s.symptom.length > 0),
.filter((s: any) => s.symptom.length > 0),
});
}
return product;
}
async updateProduct(id: string, data: ProductInput) {
async updateProduct(id: string, data: any) {
const existing = await this.prisma.product.findUnique({ where: { id } });
if (!existing) {
throw new NotFoundException(`محصولی با شناسه ${id} یافت نشد.`);
@ -287,14 +191,6 @@ export class AdminService {
dosageLogic: data.dosageLogic,
suitableFor: data.suitableFor,
imageUrl: data.imageUrl,
images: Array.isArray(data.images)
? data.images
: data.images
? [data.images]
: undefined,
podcastUrl: data.podcastUrl !== undefined ? data.podcastUrl : undefined,
videoUrl: data.videoUrl !== undefined ? data.videoUrl : undefined,
pdfUrl: data.pdfUrl !== undefined ? data.pdfUrl : undefined,
metaTitle: data.metaTitle,
metaDescription: data.metaDescription,
keywords: data.keywords,
@ -312,7 +208,7 @@ export class AdminService {
productId: id,
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 limit = Number(query.limit) || 10;
const skip = (page - 1) * limit;
const where: Prisma.OrderWhereInput = {};
const where: any = {};
if (query.search) {
where.OR = [
{ id: { contains: query.search } },
{ trackingNumber: { contains: query.search, mode: 'insensitive' } },
{
user: { firstName: { contains: query.search, mode: 'insensitive' } },
},
{ user: { lastName: { contains: query.search, mode: 'insensitive' } } },
{ user: { mobile: { contains: query.search } } },
{ user: { phone: { contains: query.search } } },
];
}
if (query.status) {
@ -372,7 +269,7 @@ export class AdminService {
}
async updateOrderStatus(id: string, status: string, trackingNumber?: string) {
const dataToUpdate: Prisma.OrderUpdateInput = { status };
const dataToUpdate: any = { status };
if (trackingNumber !== undefined) {
dataToUpdate.trackingNumber = trackingNumber;
}
@ -390,22 +287,22 @@ export class AdminService {
});
}
async getCoupons(query: PaginationQuery = {}) {
const page = Number(query.page) || 1;
const limit = Number(query.limit) || 10;
const skip = (page - 1) * limit;
// --- Coupons Engine ---
async getCoupons(query: any) {
const { page = 1, limit = 10, search = '' } = query;
const skip = (Number(page) - 1) * Number(limit);
const where: Prisma.CouponWhereInput = query.search
? { code: { contains: query.search, mode: 'insensitive' } }
const where = search
? { code: { contains: search, mode: 'insensitive' as any } }
: {};
const [data, total] = await Promise.all([
this.prisma.coupon.findMany({
where,
skip,
take: limit,
take: Number(limit),
orderBy: { createdAt: 'desc' },
include: { targets: true },
include: { targets: true }, // Include polymorphic targets
}),
this.prisma.coupon.count({ where }),
]);
@ -414,14 +311,14 @@ export class AdminService {
data,
meta: {
total,
page,
limit,
lastPage: Math.ceil(total / limit),
page: Number(page),
limit: Number(limit),
lastPage: Math.ceil(total / Number(limit)),
},
};
}
async createCoupon(data: CouponInput) {
async createCoupon(data: any) {
return this.prisma.coupon.create({
data: {
code: data.code,
@ -435,7 +332,7 @@ export class AdminService {
targets:
data.targets && data.targets.length > 0
? {
create: data.targets.map((t) => ({
create: data.targets.map((t: any) => ({
targetType: t.targetType,
targetId: t.targetId,
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 } });
return this.prisma.coupon.update({
@ -465,7 +363,7 @@ export class AdminService {
targets:
data.targets && data.targets.length > 0
? {
create: data.targets.map((t) => ({
create: data.targets.map((t: any) => ({
targetType: t.targetType,
targetId: t.targetId,
modifierType: t.modifierType || 'override',
@ -491,15 +389,27 @@ export class AdminService {
});
}
// --- Settings ---
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(
(acc, curr) => ({ ...acc, [curr.key]: curr.value }),
{} as Record<string, string>,
{},
);
}
async updateSettings(data: Record<string, string>) {
// Upsert all keys
const operations = Object.entries(data).map(([key, value]) => {
return this.prisma.uiText.upsert({
where: { key },
@ -511,121 +421,4 @@ export class AdminService {
await this.prisma.$transaction(operations);
return this.getSettings();
}
async getPets(query: PaginationQuery = {}) {
const page = Number(query.page) || 1;
const limit = Number(query.limit) || 10;
const skip = (page - 1) * limit;
const where: Prisma.PetWhereInput = {};
if (query.search) {
where.OR = [
{ name: { contains: query.search, mode: 'insensitive' } },
{ breed: { contains: query.search, mode: 'insensitive' } },
{
user: { firstName: { contains: query.search, mode: 'insensitive' } },
},
{ user: { lastName: { contains: query.search, mode: 'insensitive' } } },
];
}
const [data, total] = await Promise.all([
this.prisma.pet.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
mobile: true,
email: true,
},
},
medicalConditions: true,
reminders: true,
healthLogs: {
orderBy: { loggedDate: 'desc' },
take: 10,
},
},
}),
this.prisma.pet.count({ where }),
]);
return {
data,
meta: { total, page, limit, lastPage: Math.ceil(total / limit) },
};
}
async getDoctors() {
return this.prisma.doctor.findMany({
orderBy: { createdAt: 'desc' },
});
}
async createDoctor(data: {
name: string;
title: string;
avatarUrl?: string;
bio?: string;
clinic?: string;
}) {
return this.prisma.doctor.create({ data });
}
async updateDoctor(
id: string,
data: {
name?: string;
title?: string;
avatarUrl?: string;
bio?: string;
clinic?: string;
},
) {
return this.prisma.doctor.update({ where: { id }, data });
}
async deleteDoctor(id: string) {
return this.prisma.doctor.delete({ where: { id } });
}
async adjustUserWallet(
userId: string,
amount: number,
type: 'deposit' | 'withdrawal' | 'refund',
description?: string,
) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException(`کاربری با شناسه ${userId} یافت نشد.`);
}
const transaction = await this.prisma.walletTransaction.create({
data: {
userId,
amount,
type,
status: 'completed',
description: description || 'تغییر دستی توسط مدیر سیستم',
},
});
const isIncrement = type === 'deposit' || type === 'refund';
await this.prisma.user.update({
where: { id: userId },
data: {
walletBalance: isIncrement
? { increment: amount }
: { decrement: amount },
},
});
return transaction;
}
}

View File

@ -10,7 +10,6 @@ import {
UseGuards,
Request,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { BlogsService } from './blogs.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import {
@ -20,8 +19,6 @@ import {
ApiQuery,
} from '@nestjs/swagger';
import { PaginationDto } from '../common/dto/pagination.dto';
@ApiTags('Admin - مدیریت مقالات (بلاگ)')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ -34,26 +31,20 @@ export class BlogsController {
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' })
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
async getBlogs(@Query() query: PaginationDto) {
async getBlogs(@Query() query: any) {
return this.blogsService.getBlogs(query);
}
@Post()
@ApiOperation({ summary: 'ایجاد مقاله جدید' })
async createBlog(
@Body() body: Prisma.BlogCreateWithoutAuthorInput,
@Request() req: { user: { id: string } },
) {
async createBlog(@Body() body: any, @Request() req: any) {
const data = await this.blogsService.createBlog(body, req.user.id);
return { success: true, data };
}
@Put(':id')
@ApiOperation({ summary: 'ویرایش مقاله' })
async updateBlog(
@Param('id') id: string,
@Body() body: Prisma.BlogUpdateInput,
) {
async updateBlog(@Param('id') id: string, @Body() body: any) {
const data = await this.blogsService.updateBlog(id, body);
return { success: true, data };
}

View File

@ -1,31 +1,23 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export class BlogQuery {
page?: number | string;
limit?: number | string;
search?: string;
}
@Injectable()
export class BlogsService {
constructor(private prisma: PrismaService) {}
async getBlogs(query: BlogQuery = {}) {
const page = Number(query.page) || 1;
const limit = Number(query.limit) || 10;
const skip = (page - 1) * limit;
async getBlogs(query: any) {
const { page = 1, limit = 10, search = '' } = query;
const skip = (Number(page) - 1) * Number(limit);
const where: Prisma.BlogWhereInput = query.search
? { title: { contains: query.search, mode: 'insensitive' } }
const where = search
? { title: { contains: search, mode: 'insensitive' as any } }
: {};
const [data, total] = await Promise.all([
this.prisma.blog.findMany({
where,
skip,
take: limit,
take: Number(limit),
orderBy: { createdAt: 'desc' },
include: { author: { select: { firstName: true, lastName: true } } },
}),
@ -36,23 +28,20 @@ export class BlogsService {
data,
meta: {
total,
page,
limit,
lastPage: Math.ceil(total / limit),
page: Number(page),
limit: Number(limit),
lastPage: Math.ceil(total / Number(limit)),
},
};
}
async createBlog(
data: Prisma.BlogCreateWithoutAuthorInput,
authorId: string,
) {
async createBlog(data: any, authorId: string) {
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 } });
if (!blog) throw new NotFoundException('Blog not found');
return this.prisma.blog.update({ where: { id }, data });

View File

@ -9,7 +9,6 @@ import {
Query,
UseGuards,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { CategoriesService } from './categories.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import {
@ -19,8 +18,6 @@ import {
ApiQuery,
} from '@nestjs/swagger';
import { PaginationDto } from '../common/dto/pagination.dto';
@ApiTags('Admin - مدیریت دسته‌بندی‌ها')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ -33,7 +30,7 @@ export class CategoriesController {
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' })
@ApiQuery({ name: 'search', required: false, description: 'جستجو در نام' })
async getCategories(@Query() query: PaginationDto) {
async getCategories(@Query() query: any) {
return this.categoriesService.getCategories(query);
}
@ -46,17 +43,14 @@ export class CategoriesController {
@Post()
@ApiOperation({ summary: 'ایجاد دسته‌بندی جدید' })
async createCategory(@Body() body: Prisma.CategoryCreateInput) {
async createCategory(@Body() body: any) {
const data = await this.categoriesService.createCategory(body);
return { success: true, data };
}
@Put(':id')
@ApiOperation({ summary: 'ویرایش دسته‌بندی' })
async updateCategory(
@Param('id') id: string,
@Body() body: Prisma.CategoryUpdateInput,
) {
async updateCategory(@Param('id') id: string, @Body() body: any) {
const data = await this.categoriesService.updateCategory(id, body);
return { success: true, data };
}

View File

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

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,
Get,
Post,
Put,
Body,
Delete,
Param,
UseGuards,
@ -30,8 +28,9 @@ export class MediaController {
return { success: true, data };
}
@UseGuards(JwtAuthGuard)
@Post('upload')
@ApiOperation({ summary: 'آپلود فایل جدید (عمومی/ادمین)' })
@ApiOperation({ summary: 'آپلود فایل جدید' })
@UseInterceptors(FileInterceptor('file'))
async uploadFile(@UploadedFile() file: Express.Multer.File) {
if (!file) throw new BadRequestException('File is missing');
@ -46,23 +45,4 @@ export class MediaController {
const data = await this.mediaService.deleteMedia(id);
return { success: true, data };
}
@UseGuards(JwtAuthGuard)
@Put(':id')
@ApiOperation({
summary: 'ویرایش متادیتای سئوی فایل (Alt, Title, Description, Caption)',
})
async updateMedia(
@Param('id') id: string,
@Body()
body: {
altText?: string;
title?: string;
description?: string;
caption?: string;
},
) {
const data = await this.mediaService.updateMedia(id, body);
return { success: true, data };
}
}

View File

@ -61,24 +61,4 @@ export class MediaService {
await this.prisma.media.delete({ where: { id } });
return { success: true };
}
async updateMedia(
id: string,
data: {
altText?: string;
title?: string;
description?: string;
caption?: string;
},
) {
return this.prisma.media.update({
where: { id },
data: {
altText: data.altText,
title: data.title,
description: data.description,
caption: data.caption,
},
});
}
}

View File

@ -15,8 +15,6 @@ import {
ApiQuery,
} from '@nestjs/swagger';
import { PaginationDto } from '../common/dto/pagination.dto';
@ApiTags('Admin - مدیریت حیوانات خانگی')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ -29,7 +27,7 @@ export class PetsController {
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' })
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
async getPets(@Query() query: PaginationDto) {
async getPets(@Query() query: any) {
return this.petsService.getPets(query);
}

View File

@ -1,31 +1,23 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export class PetQuery {
page?: number | string;
limit?: number | string;
search?: string;
}
@Injectable()
export class PetsService {
constructor(private prisma: PrismaService) {}
async getPets(query: PetQuery = {}) {
const page = Number(query.page) || 1;
const limit = Number(query.limit) || 10;
const skip = (page - 1) * limit;
async getPets(query: any) {
const { page = 1, limit = 10, search = '' } = query;
const skip = (Number(page) - 1) * Number(limit);
const where: Prisma.PetWhereInput = query.search
? { name: { contains: query.search, mode: 'insensitive' } }
const where = search
? { name: { contains: search, mode: 'insensitive' as any } }
: {};
const [data, total] = await Promise.all([
this.prisma.pet.findMany({
where,
skip,
take: limit,
take: Number(limit),
orderBy: { createdAt: 'desc' },
include: {
user: {
@ -45,9 +37,9 @@ export class PetsService {
data,
meta: {
total,
page,
limit,
lastPage: Math.ceil(total / limit),
page: Number(page),
limit: Number(limit),
lastPage: Math.ceil(total / Number(limit)),
},
};
}

View File

@ -76,6 +76,12 @@ export class ReportsService {
});
// 3. Category Distribution
const categorySales: Record<string, number> = {};
for (const item of orderItems) {
if (item.productId) {
// Since we didn't fetch category id in the grouping, we'll approximate with full product list query
}
}
const categories = await this.prisma.category.findMany({
include: { products: { select: { id: true } } },
});

View File

@ -9,7 +9,6 @@ import {
Query,
UseGuards,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { WikiService } from './wiki.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import {
@ -19,8 +18,6 @@ import {
ApiQuery,
} from '@nestjs/swagger';
import { PaginationDto } from '../common/dto/pagination.dto';
@ApiTags('Admin - مدیریت دانشنامه (اصطلاحات علمی)')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ -33,23 +30,20 @@ export class WikiController {
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' })
@ApiQuery({ name: 'search', required: false, description: 'جستجو' })
async getTerms(@Query() query: PaginationDto) {
async getTerms(@Query() query: any) {
return this.wikiService.getTerms(query);
}
@Post()
@ApiOperation({ summary: 'ایجاد اصطلاح جدید' })
async createTerm(@Body() body: Prisma.ScientificTermCreateInput) {
async createTerm(@Body() body: any) {
const data = await this.wikiService.createTerm(body);
return { success: true, data };
}
@Put(':key')
@ApiOperation({ summary: 'ویرایش اصطلاح' })
async updateTerm(
@Param('key') key: string,
@Body() body: Prisma.ScientificTermUpdateInput,
) {
async updateTerm(@Param('key') key: string, @Body() body: any) {
const data = await this.wikiService.updateTerm(key, body);
return { success: true, data };
}

View File

@ -1,31 +1,23 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export class WikiQuery {
page?: number | string;
limit?: number | string;
search?: string;
}
@Injectable()
export class WikiService {
constructor(private prisma: PrismaService) {}
async getTerms(query: WikiQuery = {}) {
const page = Number(query.page) || 1;
const limit = Number(query.limit) || 10;
const skip = (page - 1) * limit;
async getTerms(query: any) {
const { page = 1, limit = 10, search = '' } = query;
const skip = (Number(page) - 1) * Number(limit);
const where: Prisma.ScientificTermWhereInput = query.search
? { term: { contains: query.search, mode: 'insensitive' } }
const where = search
? { term: { contains: search, mode: 'insensitive' as any } }
: {};
const [data, total] = await Promise.all([
this.prisma.scientificTerm.findMany({
where,
skip,
take: limit,
take: Number(limit),
orderBy: { term: 'asc' },
}),
this.prisma.scientificTerm.count({ where }),
@ -35,18 +27,18 @@ export class WikiService {
data,
meta: {
total,
page,
limit,
lastPage: Math.ceil(total / limit),
page: Number(page),
limit: Number(limit),
lastPage: Math.ceil(total / Number(limit)),
},
};
}
async createTerm(data: Prisma.ScientificTermCreateInput) {
async createTerm(data: any) {
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({
where: { key },
});

View File

@ -20,18 +20,10 @@ import { CmsModule } from './cms/cms.module';
import { WholesaleModule } from './wholesale/wholesale.module';
import { VideosModule } from './videos/videos.module';
import { SmsModule } from './common/sms.module';
import { ContactModule } from './contact/contact.module';
import { BannersModule } from './banners/banners.module';
import { SmartAdvisorModule } from './smart-advisor/smart-advisor.module';
import { TestimonialsModule } from './testimonials/testimonials.module';
import { IngredientsModule } from './ingredients/ingredients.module';
import { PrescriptionsModule } from './prescriptions/prescriptions.module';
import { B2BModule } from './b2b/b2b.module';
@Module({
imports: [
SmsModule,
ContactModule,
PrismaModule,
RedisModule,
ProductsModule,
@ -54,12 +46,6 @@ import { B2BModule } from './b2b/b2b.module';
CmsModule,
WholesaleModule,
VideosModule,
BannersModule,
SmartAdvisorModule,
TestimonialsModule,
IngredientsModule,
PrescriptionsModule,
B2BModule,
],
controllers: [MetricsController],
providers: [
@ -77,19 +63,9 @@ export class AppModule implements NestModule {
consumer
.apply((req: any, res: any, next: () => void) => {
MetricsController.incrementRequestCount();
const url: string = req.originalUrl || req.url || '';
const method: string = req.method || '';
// Exclude options, admin panel requests, static assets, and health metrics from visit counter
const isAdminPath = url.includes('/admin') || url.includes('/api/admin');
const isStaticAsset = url.match(/\.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?|map)$/i);
const isIgnoredMethod = method === 'OPTIONS' || method === 'HEAD';
if (!isAdminPath && !isStaticAsset && !isIgnoredMethod) {
const todayKey = `visits:${new Date().toISOString().split('T')[0]}`;
this.redisService.incr(todayKey, 86400).catch(() => {});
}
// Increment daily visits counter in Redis (fire-and-forget)
const todayKey = `visits:${new Date().toISOString().split('T')[0]}`;
this.redisService.incr(todayKey, 86400).catch(() => {}); // TTL = 24h
next();
})
.exclude('metrics')

View File

@ -12,8 +12,6 @@ import {
ApiBadRequestResponse,
} from '@nestjs/swagger';
import { AdminLoginDto } from './dto/admin-login.dto';
@ApiTags('Auth - احراز هویت')
@Controller('auth')
@ApiResponse({
@ -35,11 +33,12 @@ export class AuthController {
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'ارسال کد تایید پیامکی (OTP)' })
@ApiOkResponse({
description: 'کد با موفقیت به شماره تلفن همراه پیامک شد',
description: 'کد با موفقیت ارسال شد (کد تایید در پاسخ بازگردانده می‌شود)',
schema: {
example: {
success: true,
message: 'کد تایید با موفقیت به شماره شما پیامک شد.',
message: 'کد تایید ارسال شد',
code: '12345',
},
},
})
@ -126,7 +125,7 @@ export class AuthController {
@ApiOperation({ summary: 'ورود ادمین به پنل مدیریت' })
@ApiOkResponse({ description: 'ورود موفق ادمین به همراه توکن' })
@ApiBadRequestResponse({ description: 'اطلاعات ورود ادمین اشتباه است' })
adminLogin(@Body() body: AdminLoginDto) {
adminLogin(@Body() body: any) {
return this.authService.adminLogin(body);
}
}

View File

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

View File

@ -3,7 +3,6 @@ import { AuthService } from './auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { JwtService } from '@nestjs/jwt';
import { RedisService } from '../redis/redis.service';
import { SmsService } from '../common/services/sms.service';
import { BadRequestException } from '@nestjs/common';
describe('AuthService', () => {
@ -11,7 +10,6 @@ describe('AuthService', () => {
let prisma: PrismaService;
let jwt: JwtService;
let redis: RedisService;
let sms: SmsService;
const mockPrisma = {
user: {
@ -30,10 +28,6 @@ describe('AuthService', () => {
del: jest.fn(),
};
const mockSms = {
sendOtp: jest.fn().mockResolvedValue(true),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
@ -41,7 +35,6 @@ describe('AuthService', () => {
{ provide: PrismaService, useValue: mockPrisma },
{ provide: JwtService, useValue: mockJwt },
{ provide: RedisService, useValue: mockRedis },
{ provide: SmsService, useValue: mockSms },
],
}).compile();
@ -49,7 +42,6 @@ describe('AuthService', () => {
prisma = module.get<PrismaService>(PrismaService);
jwt = module.get<JwtService>(JwtService);
redis = module.get<RedisService>(RedisService);
sms = module.get<SmsService>(SmsService);
});
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 () => {
const result = await service.sendOtp({ phoneNumber: '09123456789' });
expect(result.success).toBe(true);
expect(result.message).toBe('کد تایید با موفقیت به شماره شما پیامک شد.');
expect(result.message).toBe('کد تایید ارسال شد');
// Code should NOT be in the response (security)
expect((result as any).code).toBeUndefined();
// But it should have been stored in Redis

View File

@ -6,25 +6,6 @@ import { SendOtpDto } from './dto/send-otp.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
import { SmsService } from '../common/services/sms.service';
import * as bcrypt from 'bcryptjs';
import * as crypto from 'crypto';
export class RegisterInput {
firstName!: string;
lastName!: string;
email?: string;
mobile!: string;
password?: string;
}
export class LoginInput {
mobile!: string;
password?: string;
}
export class AdminLoginInput {
email!: string;
password?: string;
}
@Injectable()
export class AuthService {
@ -38,7 +19,8 @@ export class AuthService {
async sendOtp(sendOtpDto: 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);
@ -56,10 +38,17 @@ export class AuthService {
const savedCode = await this.redisService.get(`otp:${phoneNumber}`);
if (!savedCode || savedCode !== code) {
if (!savedCode) {
throw new BadRequestException({
message: 'کد وارد شده اشتباه یا منقضی شده است',
error: 'INVALID_OTP',
message: 'کد تایید منقضی شده است',
error: 'OTP_EXPIRED',
});
}
if (savedCode !== code) {
throw new BadRequestException({
message: 'کد تایید اشتباه است',
error: 'OTP_INVALID',
});
}
@ -75,6 +64,7 @@ export class AuthService {
mobile: phoneNumber,
firstName: 'کاربر',
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 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({
data: {
@ -140,7 +130,7 @@ export class AuthService {
};
}
async login(loginDto: LoginInput) {
async login(loginDto: any) {
const { mobile, password } = loginDto;
const user = await this.prisma.user.findUnique({ where: { mobile } });
@ -158,9 +148,7 @@ export class AuthService {
});
}
const isMatch = password
? await bcrypt.compare(password, user.password)
: false;
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
throw new BadRequestException({
message: 'نام کاربری یا رمز عبور اشتباه است',
@ -180,38 +168,11 @@ export class AuthService {
};
}
async adminLogin(body: AdminLoginInput) {
const adminEmail = process.env.ADMIN_EMAIL || 'admin@canina-iran.com';
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
// First check database for user with role 'Admin' or 'SUPER_ADMIN'
const dbAdmin = await this.prisma.user.findFirst({
where: {
email: body.email,
role: { in: ['Admin', 'SUPER_ADMIN', 'ADMIN'] },
},
});
if (dbAdmin && dbAdmin.password && body.password) {
const isMatch = await bcrypt.compare(body.password, dbAdmin.password);
if (isMatch) {
const payload = {
sub: dbAdmin.id,
email: dbAdmin.email,
role: dbAdmin.role,
};
return {
success: true,
data: {
user: dbAdmin,
accessToken: this.jwtService.sign(payload),
},
};
}
}
// Fallback to validated environment variable credentials
if (body.email === adminEmail && body.password === adminPassword) {
async adminLogin(body: any) {
if (
body.email === 'admin@canino-iran.com' &&
body.password === 'admin123'
) {
const payload = {
sub: '12345678-1234-1234-1234-123456789012',
email: body.email,
@ -229,10 +190,9 @@ export class AuthService {
},
};
}
throw new BadRequestException({
message: 'ایمیل یا رمز عبور مدیریت اشتباه است',
error: 'INVALID_ADMIN_CREDENTIALS',
message: 'ایمیل یا رمز عبور اشتباه است',
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()
export class JwtAuthGuard extends AuthGuard('jwt') {
handleRequest<TUser = Record<string, unknown>>(
err: unknown,
user: TUser | false,
): TUser {
handleRequest(err: any, user: any, info: any) {
if (err || !user) {
throw (
(err as Error) ||
new UnauthorizedException('لطفا ابتدا وارد حساب کاربری خود شوید')
err || new UnauthorizedException('لطفا ابتدا وارد حساب کاربری خود شوید')
);
}
return user;

View File

@ -3,24 +3,17 @@ import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UsersService } from '../users/users.service';
export interface JwtPayload {
sub: string;
email?: string;
role?: string;
phoneNumber?: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly usersService: UsersService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_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
if (payload.sub === '12345678-1234-1234-1234-123456789012') {
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,
ApiOkResponse,
ApiNotFoundResponse,
ApiQuery,
} from '@nestjs/swagger';
import { PaginationDto } from '../common/dto/pagination.dto';

View File

@ -1,5 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { PaginationDto } from '../common/dto/pagination.dto';
@ -16,7 +15,7 @@ export class BlogsService {
sortOrder = 'desc',
} = filters;
const whereClause: Prisma.BlogWhereInput = { isPublished: true };
const whereClause: any = { isPublished: true };
if (search) {
whereClause.OR = [
{ 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 {
const rawMsg =
typeof resObj.message === 'string'
? resObj.message
: exception.message;
const rawMsg = typeof resObj.message === 'string' ? resObj.message : exception.message;
message = this.translateGenericMessage(rawMsg, status);
const rawCode =
typeof resObj.code === 'string' ? resObj.code : undefined;
const rawError =
typeof resObj.error === 'string' ? resObj.error : undefined;
const rawCode = typeof resObj.code === 'string' ? resObj.code : undefined;
const rawError = typeof resObj.error === 'string' ? resObj.error : undefined;
code = rawCode || this.deriveErrorCode(status, rawError);
details = (resObj.details as Record<string, unknown>) || {};
}

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 { ApiExcludeController } from '@nestjs/swagger';
import { PrismaService } from '../prisma/prisma.service';
import type { Response } from 'express';
import * as express from 'express';
@ApiExcludeController()
@Controller('metrics')
@ -15,14 +15,14 @@ export class MetricsController {
}
@Get()
async getMetrics(@Res() res: Response) {
async getMetrics(@Res() res: express.Response) {
const memory = process.memoryUsage();
const cpu = process.cpuUsage();
let dbStatus = 1;
try {
await this.prisma.$queryRaw`SELECT 1`;
} catch {
} catch (e) {
dbStatus = 0;
}

View File

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

View File

@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import * as http from 'http';
import * as https from 'https';
export interface SendPatternSmsOptions {
@ -7,15 +8,10 @@ export interface SendPatternSmsOptions {
args: string[]; // Dynamic variables inside pattern
}
interface MeliPayamakResponse {
Value?: number;
RetStatus?: number;
}
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
private readonly username = process.env.MELIPAYAMAK_USERNAME || '9364100228';
private readonly username = process.env.MELIPAYAMAK_USERNAME || '';
private readonly password = process.env.MELIPAYAMAK_PASSWORD || '';
/**
@ -24,7 +20,7 @@ export class SmsService {
async sendPatternSms(options: SendPatternSmsOptions): Promise<boolean> {
if (!this.username || !this.password) {
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;
}
@ -52,16 +48,15 @@ export class SmsService {
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
try {
const json = JSON.parse(data) as MeliPayamakResponse;
const val = json.Value ?? 0;
if (json && (val > 15 || json.RetStatus === 1)) {
const json = JSON.parse(data);
if (json && (json.Value > 15 || json.RetStatus === 1)) {
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);
} else {
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);
}
@ -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> {
const bodyId = parseInt(
process.env.MELIPAYAMAK_OTP_BODY_ID || '508079',
10,
);
const bodyId = parseInt(process.env.MELIPAYAMAK_OTP_BODY_ID || '0', 10);
return this.sendPatternSms({
to: phone,
bodyId,
@ -100,17 +92,14 @@ export class SmsService {
}
/**
* Send Order Confirmation SMS (Pattern 508081)
* Send Order Confirmation SMS
*/
async sendOrderConfirmation(
phone: string,
orderNumber: string,
amount: string,
): Promise<boolean> {
const bodyId = parseInt(
process.env.MELIPAYAMAK_ORDER_BODY_ID || '508081',
10,
);
const bodyId = parseInt(process.env.MELIPAYAMAK_ORDER_BODY_ID || '0', 10);
return this.sendPatternSms({
to: phone,
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(
phone: string,
@ -127,7 +116,7 @@ export class SmsService {
trackingCode: string,
): Promise<boolean> {
const bodyId = parseInt(
process.env.MELIPAYAMAK_SHIPPING_BODY_ID || '508082',
process.env.MELIPAYAMAK_SHIPPING_BODY_ID || '0',
10,
);
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
*/
@ -173,12 +144,4 @@ export class SmsService {
args: [petName, reminderType],
});
}
/**
* Generic text SMS dispatch
*/
async sendSms(phone: string, message: string): Promise<boolean> {
this.logger.log(`[SMS Text Sent] To: ${phone}, Content: ${message}`);
return Promise.resolve(true);
}
}

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 { CustomHttpExceptionFilter } from './common/filters/http-exception.filter';
import { PrismaExceptionFilter } from './common/filters/prisma-exception.filter';
import { DecimalInterceptor } from './common/interceptors/decimal.interceptor';
import helmet from 'helmet';
async function bootstrap() {
// Fallback defaults for development environment
if (process.env.NODE_ENV !== 'production') {
process.env.JWT_ACCESS_SECRET =
process.env.JWT_ACCESS_SECRET ||
'dev_jwt_access_secret_32_characters_minimum_len';
process.env.JWT_REFRESH_SECRET =
process.env.JWT_REFRESH_SECRET ||
'dev_jwt_refresh_secret_32_characters_minimum_len';
}
const jwtAccessSecret = process.env.JWT_ACCESS_SECRET;
const jwtRefreshSecret = process.env.JWT_REFRESH_SECRET;
if (!jwtAccessSecret || jwtAccessSecret.trim().length < 32) {
console.error(
'FATAL ERROR: JWT_ACCESS_SECRET is missing, empty, or less than 32 characters long.',
);
process.exit(1);
}
if (!jwtRefreshSecret || jwtRefreshSecret.trim().length < 32) {
console.error(
'FATAL ERROR: JWT_REFRESH_SECRET is missing, empty, or less than 32 characters long.',
);
process.exit(1);
}
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Serve static uploads folder
@ -91,31 +63,18 @@ async function bootstrap() {
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 enableSwagger = process.env.ENABLE_SWAGGER === 'true';
if (!isProduction || enableSwagger) {
const config = new DocumentBuilder()
.setTitle('Canina Iran API')
.setDescription(
'API Documentation for Canina Iran Pet Health & Supplement Platform',
)
.setVersion('1.0.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
console.log('Swagger UI is ACTIVE on /api/docs');
} else {
console.log('Swagger UI is DISABLED for security (production environment)');
}
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
await app.listen(process.env.PORT ?? 4001);
}
bootstrap().catch((err: unknown) => {
console.error('Bootstrap error:', err);
process.exit(1);
});
bootstrap();

View File

@ -2,6 +2,7 @@ import {
Controller,
Get,
Post,
Patch,
Body,
Param,
UseGuards,
@ -12,7 +13,6 @@ import {
import { OrdersService } from './orders.service';
import { CreateOrderDto } from './dto/create-order.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { Prisma } from '@prisma/client';
import {
ApiTags,
ApiBearerAuth,
@ -22,6 +22,7 @@ import {
ApiCreatedResponse,
ApiBadRequestResponse,
ApiNotFoundResponse,
ApiBody,
} from '@nestjs/swagger';
import { PaginationDto } from '../common/dto/pagination.dto';
import { IsString, IsNumber, IsNotEmpty } from 'class-validator';
@ -82,10 +83,7 @@ export class OrdersController {
},
},
})
create(
@Req() req: { user: { id: string } },
@Body() createOrderDto: CreateOrderDto,
) {
create(@Req() req: any, @Body() createOrderDto: CreateOrderDto) {
return this.ordersService.create(req.user.id, createOrderDto);
}
@ -107,13 +105,10 @@ export class OrdersController {
@ApiBadRequestResponse({
description: 'کد تخفیف نامعتبر، منقضی، یا شرایط آن برقرار نیست',
})
validateCoupon(
@Req() req: { user: { id: string } },
@Body() body: ValidateCouponDto,
) {
validateCoupon(@Req() req: any, @Body() body: ValidateCouponDto) {
return this.ordersService.validateCoupon(
body.code,
new Prisma.Decimal(body.cartTotal),
body.cartTotal,
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);
}
@ -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);
}
}

View File

@ -1,9 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OrdersService } from './orders.service';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/services/sms.service';
import { NotFoundException, BadRequestException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
describe('OrdersService', () => {
let service: OrdersService;
@ -12,7 +10,6 @@ describe('OrdersService', () => {
const mockPrisma = {
product: {
findUnique: jest.fn(),
findMany: jest.fn(),
},
order: {
create: jest.fn(),
@ -20,17 +17,6 @@ describe('OrdersService', () => {
findFirst: jest.fn(),
count: jest.fn(),
},
user: {
findUnique: jest.fn(),
update: jest.fn(),
},
walletTransaction: {
create: jest.fn(),
},
};
const mockSmsService = {
sendOrderConfirmation: jest.fn().mockResolvedValue(true),
};
beforeEach(async () => {
@ -38,7 +24,6 @@ describe('OrdersService', () => {
providers: [
OrdersService,
{ provide: PrismaService, useValue: mockPrisma },
{ provide: SmsService, useValue: mockSmsService },
],
}).compile();
@ -55,6 +40,14 @@ describe('OrdersService', () => {
});
describe('create', () => {
it('should throw NotFoundException if product does not exist', async () => {
mockPrisma.product.findUnique.mockResolvedValue(null);
const dto = { items: [{ productId: 'invalid-prod', quantity: 2 }] };
await expect(service.create('user-id', dto)).rejects.toThrow(
NotFoundException,
);
});
it('should throw BadRequestException if items are empty', async () => {
const dto = { items: [] };
await expect(service.create('user-id', dto)).rejects.toThrow(
@ -62,68 +55,35 @@ describe('OrdersService', () => {
);
});
it('should throw BadRequestException for duplicate product IDs in order items', async () => {
const dto = {
items: [
{ productId: 'prod-1', quantity: 1 },
{ productId: 'prod-1', quantity: 2 },
],
};
await expect(service.create('user-id', dto)).rejects.toThrow(
BadRequestException,
);
});
it('should throw BadRequestException for non-positive or non-integer quantities', async () => {
const dtoZero = { items: [{ productId: 'prod-1', quantity: 0 }] };
await expect(service.create('user-id', dtoZero)).rejects.toThrow(
BadRequestException,
);
const dtoFloat = { items: [{ productId: 'prod-1', quantity: 1.5 }] };
await expect(service.create('user-id', dtoFloat)).rejects.toThrow(
BadRequestException,
);
});
it('should throw NotFoundException if any product is not found in batched query', async () => {
mockPrisma.product.findMany.mockResolvedValue([
{ id: 'prod-1', priceValue: new Prisma.Decimal('1000') },
]);
const dto = {
items: [
{ productId: 'prod-1', quantity: 1 },
{ productId: 'prod-missing', quantity: 2 },
],
};
await expect(service.create('user-id', dto)).rejects.toThrow(
NotFoundException,
);
});
it('should successfully create order using batched findMany and precise Decimal calculations', async () => {
const products = [
{ id: 'prod-1', priceValue: new Prisma.Decimal('32.49') },
{ id: 'prod-2', priceValue: new Prisma.Decimal('15.50') },
];
mockPrisma.product.findMany.mockResolvedValue(products);
it('should successfully create order and sum amounts', async () => {
const prod = { id: 'prod-1', priceValue: 1000 };
mockPrisma.product.findUnique.mockResolvedValue(prod);
mockPrisma.order.create.mockResolvedValue({
id: 'order-1',
totalAmount: new Prisma.Decimal('80.48'),
totalAmount: 2000,
});
const dto = {
items: [
{ productId: 'prod-1', quantity: 2 }, // 32.49 * 2 = 64.98
{ productId: 'prod-2', quantity: 1 }, // 15.50 * 1 = 15.50 Total = 80.48
],
};
const dto = { items: [{ productId: 'prod-1', quantity: 2 }] };
const result = await service.create('user-id', dto);
expect(prisma.product.findMany).toHaveBeenCalledWith({
where: { id: { in: ['prod-1', 'prod-2'] } },
expect(prisma.product.findUnique).toHaveBeenCalledWith({
where: { id: 'prod-1' },
});
expect(prisma.order.create).toHaveBeenCalledWith({
data: {
userId: 'user-id',
totalAmount: 2000,
status: 'processing',
orderItems: {
create: [{ productId: 'prod-1', quantity: 2 }],
},
},
include: {
orderItems: {
include: { product: true },
},
},
});
expect(prisma.order.create).toHaveBeenCalled();
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 { CreateOrderDto } from './dto/create-order.dto';
import { SmsService } from '../common/services/sms.service';
import { Prisma } from '@prisma/client';
@Injectable()
export class OrdersService {
@ -23,11 +22,7 @@ export class OrdersService {
return `CN-${dateStr}-${random}`;
}
async validateCoupon(
code: string,
cartTotal: Prisma.Decimal,
userId: string,
) {
async validateCoupon(code: string, cartTotal: number, userId: string) {
const coupon = await this.prisma.coupon.findUnique({
where: { code: code.toUpperCase().trim() },
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({
message: `حداقل مبلغ سبد خرید برای استفاده از این کد ${Number(coupon.minCartValue).toLocaleString('fa-IR')} تومان است`,
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') {
discountAmount = cartTotal.mul(coupon.value).div(100);
if (
coupon.maxCartValue &&
discountAmount.greaterThan(coupon.maxCartValue)
) {
discountAmount = new Prisma.Decimal(coupon.maxCartValue);
discountAmount = (cartTotal * Number(coupon.value)) / 100;
if (coupon.maxCartValue && discountAmount > Number(coupon.maxCartValue)) {
discountAmount = Number(coupon.maxCartValue);
}
} else {
discountAmount = new Prisma.Decimal(coupon.value);
discountAmount = Number(coupon.value);
}
const finalDiscount = discountAmount.greaterThan(cartTotal)
? cartTotal
: discountAmount;
return {
couponId: coupon.id,
code: coupon.code,
discountAmount: finalDiscount,
discountAmount: Math.min(discountAmount, cartTotal),
};
}
@ -102,36 +90,9 @@ export class OrdersService {
throw new BadRequestException('سبد خرید خالی است');
}
// Validate quantities and duplicate product IDs
const productIds = createOrderDto.items.map((item) => item.productId);
const uniqueProductIds = new Set(productIds);
if (uniqueProductIds.size !== productIds.length) {
throw new BadRequestException('تکرار محصول در سبد خرید مجاز نیست');
}
for (const item of createOrderDto.items) {
if (item.quantity <= 0 || !Number.isInteger(item.quantity)) {
throw new BadRequestException(
`تعداد محصول ${item.productId} نامعتبر است`,
);
}
}
// Batch fetch products using single findMany query
const products = await this.prisma.product.findMany({
where: { id: { in: productIds } },
});
const productMap = new Map(products.map((p) => [p.id, p]));
for (const productId of productIds) {
if (!productMap.has(productId)) {
throw new NotFoundException(`محصول یافت نشد`);
}
}
let cartTotal = new Prisma.Decimal(0);
const orderItemsData: Array<{
// Verify stock and build items
let cartTotal = 0;
const orderItems: Array<{
productId: string;
quantity: number;
unitPrice: number;
@ -139,20 +100,24 @@ export class OrdersService {
}> = [];
for (const item of createOrderDto.items) {
const product = productMap.get(item.productId)!;
const unitPrice = new Prisma.Decimal(product.priceValue);
const totalItemPrice = unitPrice.mul(item.quantity);
cartTotal = cartTotal.add(totalItemPrice);
orderItemsData.push({
const product = await this.prisma.product.findUnique({
where: { id: item.productId },
});
if (!product) {
throw new NotFoundException(`محصول یافت نشد`);
}
const itemPrice = Number(product.priceValue);
const totalItemPrice = itemPrice * item.quantity;
cartTotal += totalItemPrice;
orderItems.push({
productId: item.productId,
quantity: item.quantity,
unitPrice: Number(unitPrice),
totalPrice: Number(totalItemPrice),
unitPrice: itemPrice,
totalPrice: totalItemPrice,
});
}
let discountAmount = new Prisma.Decimal(0);
let discountAmount = 0;
let couponId: string | undefined = undefined;
if (createOrderDto.couponCode) {
@ -165,78 +130,37 @@ export class OrdersService {
couponId = couponResult.couponId;
}
const charityAmount = new Prisma.Decimal(
createOrderDto.charityDonation || 0,
);
const totalAfterDiscount = cartTotal.sub(discountAmount);
const finalAmount = (
totalAfterDiscount.lessThan(0)
? new Prisma.Decimal(0)
: totalAfterDiscount
).add(charityAmount);
const charityAmount = createOrderDto.charityDonation || 0;
const finalAmount = Math.max(0, cartTotal - discountAmount) + charityAmount;
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) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException('کاربر یافت نشد');
}
const userBalance = new Prisma.Decimal(user.walletBalance || 0);
if (userBalance.lessThan(finalAmount)) {
const userBalance = Number(user.walletBalance || 0);
if (userBalance < finalAmount) {
throw new BadRequestException(
'موجودی کیف پول برای پرداخت این سفارش کافی نیست',
);
}
return this.prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: userId },
data: {
walletBalance: { decrement: Number(finalAmount) },
},
});
await tx.walletTransaction.create({
data: {
userId,
amount: Number(finalAmount),
type: 'withdrawal',
status: 'completed',
description: `پرداخت سفارش ${trackingNumber}`,
},
});
if (charityAmount.greaterThan(0)) {
await tx.user.update({
where: { id: userId },
data: {
charityDonationTotal: { increment: Number(charityAmount) },
},
});
}
return tx.order.create({
data: {
userId,
couponId,
totalAmount: Number(finalAmount),
charityDonation: Number(charityAmount),
isRefill: Boolean(createOrderDto.isRefill),
refillIntervalDays: createOrderDto.refillIntervalDays || 60,
trackingNumber,
status: 'processing',
orderItems: {
create: orderItemsData,
},
},
include: {
orderItems: {
include: { product: true },
},
},
});
await this.prisma.user.update({
where: { id: userId },
data: {
walletBalance: { decrement: finalAmount },
},
});
await this.prisma.walletTransaction.create({
data: {
userId,
amount: finalAmount,
type: 'withdrawal',
status: 'completed',
description: `پرداخت سفارش ${trackingNumber}`,
},
});
}
@ -244,16 +168,16 @@ export class OrdersService {
data: {
userId,
couponId,
totalAmount: Number(finalAmount),
charityDonation: Number(charityAmount),
totalAmount: finalAmount,
charityDonation: charityAmount,
isRefill: Boolean(createOrderDto.isRefill),
refillIntervalDays: createOrderDto.refillIntervalDays || 60,
trackingNumber,
status: 'processing',
orderItems: {
create: orderItemsData,
create: orderItems,
},
},
} as any,
include: {
orderItems: {
include: { product: true },
@ -263,18 +187,20 @@ export class OrdersService {
// Send Order Confirmation SMS
if (userId) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
});
if (user?.mobile) {
await this.smsService
.sendOrderConfirmation(
user.mobile,
trackingNumber,
Number(finalAmount).toLocaleString('fa-IR'),
)
.catch(() => {});
}
this.prisma.user
.findUnique({ where: { id: userId } })
.then((user) => {
if (user && user.mobile) {
this.smsService
.sendOrderConfirmation(
user.mobile,
trackingNumber,
finalAmount.toLocaleString('fa-IR'),
)
.catch(() => {});
}
})
.catch(() => {});
}
return createdOrder;
@ -357,31 +283,4 @@ export class OrdersService {
}
return order;
}
async checkAndSendRefillReminders() {
const fiftyEightDaysAgo = new Date(Date.now() - 58 * 24 * 60 * 60 * 1000);
const fiftyNineDaysAgo = new Date(Date.now() - 59 * 24 * 60 * 60 * 1000);
const dueRefillOrders = await this.prisma.order.findMany({
where: {
isRefill: true,
createdAt: {
gte: fiftyNineDaysAgo,
lte: fiftyEightDaysAgo,
},
},
include: { user: true },
});
for (const order of dueRefillOrders) {
if (order.user?.mobile) {
await this.smsService
.sendSms(
order.user.mobile,
`کاربر گرامی، مکمل درمانی پت شما در حال اتمام است. جهت شارژ مجدد و استفاده از ۵٪ تخفیف دوره جدید به لینک زیر مراجعه کنید:\nhttps://canina-iran.com/shop`,
)
.catch(() => {});
}
}
}
}

View File

@ -10,7 +10,7 @@ describe('PetsController', () => {
create: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy' }),
findAllByUser: jest
.fn()
.mockResolvedValue({ data: [{ id: 'pet-id', name: 'Buddy' }] }),
.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' }),
remove: jest.fn().mockResolvedValue({ success: true }),

View File

@ -78,10 +78,7 @@ export class PetsController {
},
},
})
create(
@Req() req: { user: { id: string } },
@Body() createPetDto: CreatePetDto,
) {
create(@Req() req: any, @Body() createPetDto: 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);
}
@ -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);
}
@ -183,7 +180,7 @@ export class PetsController {
},
})
update(
@Req() req: { user: { id: string } },
@Req() req: any,
@Param('id') id: string,
@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);
}
@Post(':petId/reminders')
@ApiOperation({ summary: 'ثبت یادآور جدید برای پت' })
addReminder(
@Req() req: { user: { id: string } },
@Req() req: any,
@Param('petId') petId: string,
@Body() createReminderDto: CreateReminderDto,
) {
@ -229,7 +226,7 @@ export class PetsController {
@Post(':petId/reminders/:reminderId/toggle')
@ApiOperation({ summary: 'تغییر وضعیت انجام یادآور در یک تاریخ خاص' })
toggleReminder(
@Req() req: { user: { id: string } },
@Req() req: any,
@Param('petId') petId: string,
@Param('reminderId') reminderId: string,
@Body('date') date: string,
@ -245,7 +242,7 @@ export class PetsController {
@Post(':petId/health-logs')
@ApiOperation({ summary: 'ثبت لاگ سلامت جدید برای پت' })
addHealthLog(
@Req() req: { user: { id: string } },
@Req() req: any,
@Param('petId') petId: string,
@Body() createHealthLogDto: CreateHealthLogDto,
) {

View File

@ -1,24 +1,9 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { PaginationDto } from '../common/dto/pagination.dto';
import { CreatePetDto } from './dto/create-pet.dto';
import { UpdatePetDto } from './dto/update-pet.dto';
export interface ReminderInput {
productId?: string | null;
title: string;
time: string;
frequency: string;
}
export interface HealthLogInput {
appetite: string;
energy: string;
digestion: string;
note?: string | null;
}
@Injectable()
export class PetsService {
constructor(private prisma: PrismaService) {}
@ -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) {
const {
search,
@ -115,7 +40,7 @@ export class PetsService {
sortOrder = 'desc',
} = filters;
const whereClause: Prisma.PetWhereInput = { userId };
const whereClause: any = { userId };
if (search) {
whereClause.OR = [
{ name: { contains: search, mode: 'insensitive' } },
@ -131,7 +56,6 @@ export class PetsService {
skip,
take: limit,
orderBy: { [sortBy]: sortOrder },
include: { medicalConditions: true, reminders: true, healthLogs: true },
}),
this.prisma.pet.count({ where: whereClause }),
]);
@ -159,7 +83,7 @@ export class PetsService {
}
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) {
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);
return this.prisma.reminder.create({
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);
return this.prisma.healthLog.create({
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()
category?: string;
@ApiPropertyOptional({ description: 'فیلتر بر اساس آی‌دی دسته‌بندی' })
@IsOptional()
@IsString()
categoryId?: string;
@ApiPropertyOptional({
description: 'فیلتر بر اساس نوع حیوان',
enum: ['سگ', 'گربه', 'all'],
@ -30,14 +25,4 @@ export class GetProductsDto extends PaginationDto {
@IsOptional()
@IsString()
requiresRx?: string;
@ApiPropertyOptional({ description: 'حداقل قیمت (تومان)' })
@IsOptional()
@IsString()
minPrice?: string;
@ApiPropertyOptional({ description: 'حداکثر قیمت (تومان)' })
@IsOptional()
@IsString()
maxPrice?: string;
}

View File

@ -1,33 +1,76 @@
import {
Controller,
Get,
Patch,
Body,
Query,
Param,
NotFoundException,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { ProductsService } from './products.service';
import { GetProductsDto } from './dto/get-products.dto';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiOkResponse,
ApiNotFoundResponse,
} from '@nestjs/swagger';
@ApiTags('Products - مدیریت محصولات دارویی')
@Controller('products')
@ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR,
description: 'خطای داخلی سرور',
schema: {
example: {
success: false,
message: 'خطای داخلی سرور',
code: 'SERVER_ERROR',
details: {},
},
},
})
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Get()
@ApiOperation({ summary: 'لیست و فیلتر محصولات' })
@ApiOkResponse({
description: 'لیست محصولات متناسب با فیلترها (دسته، پت و جستجو)',
schema: {
example: {
data: [
{
id: 'a1b2c3d4-1234-5678-abcd-ef1234567890',
artNo: 'canhydrox-gag',
name: 'Canhydrox GAG (کنهیدروکس)',
scientificTagline: 'برای تقویت مفاصل و استخوان‌ها',
description:
'کنهیدروکس محصولی بی‌نظیر برای مفاصل و سیستم حرکتی سگ‌ها...',
shortDescription: 'تقویت مفاصل و غضروف‌ها',
category: 'سیستم حرکتی و مفاصل',
categorySlug: 'joints',
priceValue: '1750000.00',
priceDisplay: '۱,۷۵۰,۰۰۰ تومان',
unit: 'عدد قرص',
packageSize: '120.00',
dosageLogic: 'یک قرص به ازای هر ۱۰ کیلوگرم وزن بدن سگ',
suitableFor: 'سگ',
imageUrl: 'https://example.com/canhydrox.png',
createdAt: '2026-05-26T18:10:00.000Z',
ingredients: [],
symptoms: [],
},
],
meta: {
total: 1,
page: 1,
lastPage: 1,
limit: 10,
},
},
},
})
findAll(@Query() query: GetProductsDto) {
return this.productsService.findAll(query);
}
@ -36,6 +79,16 @@ export class ProductsController {
@ApiOperation({
summary: 'دریافت فیلترهای فعال محصولات (دسته، علائم، نوع پت)',
})
@ApiOkResponse({
description: 'لیست فیلترهای پویا استخراج شده از دیتابیس',
schema: {
example: {
categories: [{ id: '1', name: 'مفاصل و استخوان', slug: 'joints' }],
symptoms: ['لنگش', 'ریزش مو'],
petTypes: ['سگ', 'گربه', 'هر دو'],
},
},
})
getActiveFilters() {
return this.productsService.getActiveFilters();
}
@ -44,35 +97,76 @@ export class ProductsController {
@ApiOperation({
summary: 'دریافت فیلترها و راهکارهای ناوبری (دسته با علائم مربوطه)',
})
@ApiOkResponse({
description: 'ساختار درختی فیلترها و تگ‌های درمانی واقعی متصل به محصولات',
schema: {
example: [
{
id: '1',
name: 'مفاصل و استخوان',
slug: 'joints',
symptoms: ['درد مفاصل', 'لنگش'],
},
],
},
})
getNavigationFilters() {
return this.productsService.getNavigationFilters();
}
@Get(':id/dosage-config')
@ApiOperation({ summary: 'دریافت پیکربندی دوز مصرف محصول' })
getDosageConfig(@Param('id') id: string) {
return this.productsService.getDosageConfig(id);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin')
@Patch(':id/dosage-config')
@ApiOperation({
summary: 'ویرایش پیکربندی دوز مصرف محصول (نیازمند دسترسی ادمین)',
})
updateDosageConfig(
@Param('id') id: string,
@Body() body: Prisma.InputJsonValue,
) {
return this.productsService.updateDosageConfig(id, body);
}
@Get(':id')
@ApiOperation({ summary: 'دریافت اطلاعات محصول با شناسه یکتا (ID)' })
@ApiOkResponse({
description: 'اطلاعات کامل محصول شامل مواد تشکیل‌دهنده و علائم مرتبط',
schema: {
example: {
id: 'a1b2c3d4-1234-5678-abcd-ef1234567890',
artNo: 'canhydrox-gag',
name: 'Canhydrox GAG (کنهیدروکس)',
scientificTagline: 'برای تقویت مفاصل و استخوان‌ها',
description: 'کنهیدروکس محصولی بی‌نظیر برای مفاصل...',
shortDescription: 'تقویت مفاصل و غضروف‌ها',
category: 'سیستم حرکتی و مفاصل',
categorySlug: 'joints',
priceValue: '1750000.00',
priceDisplay: '۱,۷۵۰,۰۰۰ تومان',
unit: 'عدد قرص',
packageSize: '120.00',
dosageLogic: 'یک قرص به ازای هر ۱۰ کیلوگرم وزن بدن سگ',
suitableFor: 'سگ',
imageUrl: 'https://example.com/canhydrox.png',
createdAt: '2026-05-26T18:10:00.000Z',
ingredients: [
{
productId: 'a1b2c3d4-1234-5678-abcd-ef1234567890',
ingredient: 'صدف لب‌سبز',
},
],
symptoms: [
{
productId: 'a1b2c3d4-1234-5678-abcd-ef1234567890',
symptom: 'لنگیدن',
},
],
},
},
})
@ApiNotFoundResponse({
description: 'محصول با شناسه ارسال شده پیدا نشد',
schema: {
example: {
success: false,
message:
'Product with ID a1b2c3d4-1234-5678-abcd-ef1234567890 not found',
code: 'NOT_FOUND',
details: {},
},
},
})
async findOne(@Param('id') id: string) {
const product = await this.productsService.findOne(id);
if (!product) {
throw new NotFoundException('محصول یافت نشد');
throw new NotFoundException(`Product with ID ${id} not found`);
}
return product;
}

View File

@ -10,8 +10,6 @@ describe('ProductsService', () => {
product: {
findMany: jest.fn(),
findUnique: jest.fn(),
findFirst: jest.fn(),
count: jest.fn(),
},
};
@ -38,19 +36,39 @@ describe('ProductsService', () => {
describe('findAll', () => {
it('should query products with correct filters', async () => {
mockPrisma.product.findMany.mockResolvedValue([{ id: 'prod-1' }]);
mockPrisma.product.count.mockResolvedValue(1);
const filters = { category: 'joints', petType: 'سگ', query: 'can' };
const result = await service.findAll(filters);
expect(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', () => {
it('should find product by id', async () => {
const prod = { id: 'prod-1' };
mockPrisma.product.findFirst.mockResolvedValue(prod);
mockPrisma.product.findUnique.mockResolvedValue(prod);
const result = await service.findOne('prod-1');
expect(prisma.product.findUnique).toHaveBeenCalledWith({
where: { id: 'prod-1' },
include: {
ingredients: true,
symptoms: true,
},
});
expect(result).toEqual(prod);
});
});

View File

@ -1,7 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { GetProductsDto } from './dto/get-products.dto';
import { Prisma } from '@prisma/client';
@Injectable()
export class ProductsService {
@ -14,70 +13,65 @@ export class ProductsService {
search,
symptom,
requiresRx,
minPrice,
maxPrice,
page = 1,
limit = 10,
sortBy = 'createdAt',
sortOrder = 'desc',
} = filters;
const andConditions: Prisma.ProductWhereInput[] = [];
const whereClause: any = {};
if (requiresRx !== undefined && requiresRx !== null && requiresRx !== '') {
andConditions.push({
requiresRx: requiresRx === 'true' || requiresRx === '1',
});
whereClause.requiresRx = requiresRx === 'true' || requiresRx === '1';
}
if (category) {
andConditions.push({ categorySlug: category });
whereClause.categorySlug = category;
}
if (petType && petType !== 'all') {
andConditions.push({ suitableFor: { in: [petType, 'هر دو'] } });
whereClause.suitableFor = { in: [petType, 'هر دو'] };
}
// Filter by a specific symptom (from URL param ?symptom=...)
if (symptom) {
andConditions.push({
symptoms: {
some: {
symptom: { contains: symptom, mode: 'insensitive' },
},
whereClause.symptoms = {
some: {
symptom: { contains: symptom, mode: 'insensitive' },
},
});
}
if (minPrice) {
andConditions.push({ priceValue: { gte: Number(minPrice) } });
}
if (maxPrice) {
andConditions.push({ priceValue: { lte: Number(maxPrice) } });
};
}
if (search) {
andConditions.push({
OR: [
{ artNo: { contains: search, mode: 'insensitive' } },
{ barcode: { contains: search, mode: 'insensitive' } },
{ nameFa: { contains: search, mode: 'insensitive' } },
{ nameEn: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ shortDescription: { contains: search, mode: 'insensitive' } },
{
symptoms: {
some: {
symptom: { contains: search, mode: 'insensitive' },
},
// If symptom filter is already applied, extend via AND to also search names/desc
// If not, use OR across names, description AND symptoms
const searchConditions = [
{ artNo: { contains: search, mode: 'insensitive' } },
{ barcode: { contains: search, mode: 'insensitive' } },
{ nameFa: { contains: search, mode: 'insensitive' } },
{ nameEn: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ shortDescription: { contains: search, mode: 'insensitive' } },
{
symptoms: {
some: {
symptom: { contains: search, mode: 'insensitive' },
},
},
],
});
}
},
];
const whereClause: Prisma.ProductWhereInput =
andConditions.length > 0 ? { AND: andConditions } : {};
if (symptom) {
// Already have a symptom filter; combine with AND
whereClause.AND = [
{ symptoms: whereClause.symptoms },
{ OR: searchConditions.filter((c) => !('symptoms' in c)) },
];
delete whereClause.symptoms;
} else {
whereClause.OR = searchConditions;
}
}
const skip = (page - 1) * limit;
@ -96,22 +90,13 @@ export class ProductsService {
]);
const isWholesaleOrAdmin =
userRole === 'User_Wholesale' ||
userRole === 'User_Partner' ||
userRole === 'ADMIN' ||
userRole === 'SuperAdmin';
const isAdmin = userRole === 'ADMIN' || userRole === 'SuperAdmin';
userRole === 'User_Wholesale' || userRole === 'ADMIN';
const data = rawProducts.map((p) => {
const { buyPrice, wholesalePrice, ...publicProduct } = p;
void buyPrice;
void wholesalePrice;
if (!isWholesaleOrAdmin) {
return publicProduct;
const { wholesalePrice, ...rest } = p;
return rest;
}
return isAdmin
? p
: { ...publicProduct, wholesalePrice: p.wholesalePrice };
return p;
});
return {
@ -140,21 +125,12 @@ export class ProductsService {
if (!product) return null;
const isWholesaleOrAdmin =
userRole === 'User_Wholesale' ||
userRole === 'User_Partner' ||
userRole === 'ADMIN' ||
userRole === 'SuperAdmin';
const isAdmin = userRole === 'ADMIN' || userRole === 'SuperAdmin';
const { buyPrice, wholesalePrice, ...publicProduct } = product;
void buyPrice;
void wholesalePrice;
userRole === 'User_Wholesale' || userRole === 'ADMIN';
if (!isWholesaleOrAdmin) {
return publicProduct;
const { wholesalePrice, ...rest } = product;
return rest;
}
return isAdmin
? product
: { ...publicProduct, wholesalePrice: product.wholesalePrice };
return product;
}
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() {
this.client?.disconnect();
this.client.disconnect();
}
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {

View File

@ -1,11 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SettingsController } from './settings.controller';
import { SettingsService } from './settings.service';
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { ROLES_KEY } from '../common/decorators/roles.decorator';
import { Reflector } from '@nestjs/core';
describe('SettingsController', () => {
let controller: SettingsController;
@ -37,16 +32,6 @@ describe('SettingsController', () => {
expect(controller).toBeDefined();
});
it('should have JwtAuthGuard, RolesGuard and Admin role applied at controller level', () => {
const guards = Reflect.getMetadata(GUARDS_METADATA, SettingsController);
expect(guards).toBeDefined();
expect(guards).toContain(JwtAuthGuard);
expect(guards).toContain(RolesGuard);
const roles = Reflect.getMetadata(ROLES_KEY, SettingsController);
expect(roles).toEqual(['Admin']);
});
it('should getUiTexts', async () => {
const result = await controller.getUiTexts();
expect(service.getUiTexts).toHaveBeenCalled();

View File

@ -7,20 +7,20 @@ import {
Body,
Param,
UseGuards,
HttpStatus,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { SettingsService, ScientificTermData } from './settings.service';
import { SettingsService } from './settings.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import {
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiResponse,
ApiOkResponse,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
@ApiTags('Settings - تنظیمات متون پویا، تنظیمات سئو، مالی و سیستم')
@ApiTags('Settings - تنظیمات متون پویا و واژه‌نامه علمی')
@Controller('settings')
export class SettingsController {
constructor(private readonly settingsService: SettingsService) {}
@ -30,99 +30,117 @@ export class SettingsController {
@ApiOkResponse({
description:
'یک آبجکت کلید-مقدار حاوی تمامی متون، شعارها و برچسب‌های دکمه‌ها',
schema: {
example: {
hero_badge: 'تخصص دارویی از آلمان',
hero_title: 'تخصص آلمانی در خدمت سلامت پت‌های خانگی',
hero_desc: 'بیش از ۴۰ سال تجربه نوآورانه...',
},
},
})
getUiTexts() {
return this.settingsService.getUiTexts();
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@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) {
return this.settingsService.updateUiText(key, value);
}
@Get('scientific-terms')
@ApiOperation({ summary: 'دریافت تمامی اصطلاحات واژه‌نامه علمی' })
@ApiOkResponse({
description: 'لیست کامل اصطلاحات علمی به همراه تعاریف و شناسه‌ها',
schema: {
example: [
{
key: 'green-mussel',
term: 'صدف لب‌سبز (Perna Canaliculus)',
definition: 'این صدف بومی سواحل بکر نیوزیلند است...',
wikiId: 'general',
},
],
},
})
getScientificTerms() {
return this.settingsService.getScientificTerms();
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Put('scientific-terms/:key')
@ApiOperation({ summary: 'ثبت یا ویرایش یک اصطلاح علمی' })
upsertScientificTerm(
@Param('key') key: string,
@Body() data: ScientificTermData,
) {
@ApiOperation({ summary: 'ثبت یا ویرایش یک اصطلاح علمی (نیازمند توکن)' })
@ApiOkResponse({
description: 'اصطلاح علمی ثبت یا ویرایش شد',
schema: {
example: {
key: 'green-mussel',
term: 'صدف لب‌سبز اصل نیوزیلند',
definition: 'این صدف بومی سواحل بکر نیوزیلند است...',
wikiId: 'general',
},
},
})
@ApiUnauthorizedResponse({
description: 'عدم دسترسی',
schema: {
example: {
success: false,
message: 'Unauthorized',
code: 'UNAUTHORIZED',
},
},
})
upsertScientificTerm(@Param('key') key: string, @Body() data: any) {
return this.settingsService.upsertScientificTerm(key, data);
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@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) {
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 { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export class ScientificTermData {
term?: string;
definition?: string;
wikiId?: string;
}
@Injectable()
export class SettingsService {
constructor(private prisma: PrismaService) {}
async getUiTexts() {
const [uiTexts, settings] = await Promise.all([
this.prisma.uiText.findMany(),
this.prisma.setting.findMany(),
]);
const merged = [...uiTexts];
settings.forEach((s) => {
const valStr =
typeof s.value === 'string' ? s.value : JSON.stringify(s.value);
merged.push({ key: s.key, value: valStr });
});
return merged;
return this.prisma.uiText.findMany();
}
async updateUiText(key: string, value: string) {
@ -40,23 +21,19 @@ export class SettingsService {
return this.prisma.scientificTerm.findMany();
}
async upsertScientificTerm(key: string, data: ScientificTermData) {
const term = String(data?.term || '');
const definition = String(data?.definition || '');
const wikiId = String(data?.wikiId || 'general');
async upsertScientificTerm(key: string, data: any) {
return this.prisma.scientificTerm.upsert({
where: { key },
update: {
term,
definition,
wikiId,
term: data.term,
definition: data.definition,
wikiId: data.wikiId || 'general',
},
create: {
key,
term,
definition,
wikiId,
term: data.term,
definition: data.definition,
wikiId: data.wikiId || 'general',
},
});
}
@ -66,20 +43,4 @@ export class SettingsService {
where: { key },
});
}
async getCategorySetting(category: string) {
const setting = await this.prisma.setting.findFirst({
where: { category },
});
return setting ? setting.value : {};
}
async updateCategorySetting(category: string, value: Prisma.InputJsonValue) {
const key = `${category}_config`;
return this.prisma.setting.upsert({
where: { key },
update: { category, value },
create: { key, category, value },
});
}
}

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

View File

@ -56,16 +56,7 @@ describe('UsersService', () => {
});
it('should addAddress', async () => {
const addressData = {
title: 'Home',
receptorName: 'Ali',
phone: '09123456789',
province: 'Tehran',
city: 'Tehran',
detail: 'Street 1',
zipCode: '1234567890',
isDefault: true,
};
const addressData = { title: 'Home', isDefault: true };
mockPrisma.userAddress.create.mockResolvedValue({
id: 'addr-id',
...addressData,
@ -81,16 +72,7 @@ describe('UsersService', () => {
});
it('should updateAddress', async () => {
const addressData = {
title: 'Work',
receptorName: 'Ali',
phone: '09123456789',
province: 'Tehran',
city: 'Tehran',
detail: 'Street 2',
zipCode: '1234567890',
isDefault: true,
};
const addressData = { title: 'Work', isDefault: true };
mockPrisma.userAddress.update.mockResolvedValue({
id: 'addr-id',
...addressData,

View File

@ -1,18 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export interface UserAddressInput {
title: string;
receptorName: string;
phone: string;
province: string;
city: string;
detail: string;
zipCode: string;
isDefault?: boolean;
}
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
@ -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({
where: { id },
data,
@ -68,7 +56,7 @@ export class UsersService {
});
}
async addAddress(userId: string, data: UserAddressInput) {
async addAddress(userId: string, data: any) {
if (data.isDefault) {
await this.prisma.userAddress.updateMany({
where: { userId },
@ -90,11 +78,7 @@ export class UsersService {
});
}
async updateAddress(
userId: string,
addressId: string,
data: UserAddressInput,
) {
async updateAddress(userId: string, addressId: string, data: any) {
if (data.isDefault) {
await this.prisma.userAddress.updateMany({
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';
import { VideosService } from './videos.service';
import { CreateVideoDto } from './dto/create-video.dto';
import { GetVideosQueryDto } from './dto/get-videos-query.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import {
ApiTags,
@ -31,7 +30,7 @@ export class VideosController {
@ApiQuery({ name: 'limit', required: false })
@ApiQuery({ name: 'search', required: false })
@ApiQuery({ name: 'featured', required: false })
findAll(@Query() query: GetVideosQueryDto) {
findAll(@Query() query: any) {
return this.videosService.findAll(query);
}

View File

@ -1,26 +1,21 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateVideoDto } from './dto/create-video.dto';
export class VideoQuery {
page?: number | string;
limit?: number | string;
search?: string;
featured?: boolean | string;
}
@Injectable()
export class VideosService {
constructor(private readonly prisma: PrismaService) {}
async findAll(query: VideoQuery = {}) {
private get video() {
return (this.prisma as any).video;
}
async findAll(query: any) {
const page = Number(query.page) || 1;
const limit = Number(query.limit) || 20;
const skip = (page - 1) * limit;
const where: Prisma.VideoWhereInput = {};
const where: any = {};
if (query.search) {
where.OR = [
{ title: { contains: query.search, mode: 'insensitive' } },
@ -28,71 +23,82 @@ export class VideosService {
{ description: { contains: query.search, mode: 'insensitive' } },
];
}
if (query.featured !== undefined) {
where.isFeatured = query.featured === 'true' || query.featured === true;
}
const [videos, total] = await Promise.all([
this.prisma.video.findMany({
const [data, total] = await Promise.all([
this.video.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
}),
this.prisma.video.count({ where }),
this.video.count({ where }),
]);
return {
videos,
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
lastPage: Math.ceil(total / limit),
},
};
}
async findOne(id: string) {
const video = await this.prisma.video.findUnique({
async findOne(id: string, incrementView = false) {
const video = await this.video.findUnique({
where: { id },
});
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;
}
async create(createVideoDto: CreateVideoDto) {
return this.prisma.video.create({
async create(dto: CreateVideoDto) {
return this.video.create({
data: {
title: createVideoDto.title,
doctor: createVideoDto.doctor,
duration: createVideoDto.duration || '00:00',
videoUrl: createVideoDto.videoUrl,
thumbnail: createVideoDto.thumbnail || '',
description: createVideoDto.description || null,
isFeatured: createVideoDto.isFeatured || false,
title: dto.title,
doctor: dto.doctor,
duration: dto.duration || '۰۲:۰۰',
thumbnail: dto.thumbnail,
videoUrl: dto.videoUrl,
description: dto.description,
isFeatured: dto.isFeatured ?? false,
},
});
}
async update(id: string, updateVideoDto: Partial<CreateVideoDto>) {
async update(id: string, dto: Partial<CreateVideoDto>) {
await this.findOne(id);
return this.prisma.video.update({
return this.video.update({
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) {
await this.findOne(id);
return this.prisma.video.delete({
return this.video.delete({
where: { id },
});
}

View File

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

View File

@ -1,5 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { PaginationDto } from '../common/dto/pagination.dto';
@ -18,7 +17,7 @@ export class WikiService {
const allowedSortFields = ['key', 'term', 'wikiId'];
const sortBy = allowedSortFields.includes(rawSortBy) ? rawSortBy : 'term';
const whereClause: Prisma.ScientificTermWhereInput = {};
const whereClause: any = {};
if (search) {
whereClause.OR = [
{ 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 { AppModule } from './../src/app.module';
process.env.JWT_ACCESS_SECRET =
process.env.JWT_ACCESS_SECRET ||
'test_access_secret_32_characters_minimum_entropy';
process.env.JWT_REFRESH_SECRET =
process.env.JWT_REFRESH_SECRET ||
'test_refresh_secret_32_characters_minimum_entropy';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
@ -20,12 +13,14 @@ describe('AppController (e2e)', () => {
}).compile();
app = moduleFixture.createNestApplication();
app.setGlobalPrefix('api');
await app.init();
});
it('/api/metrics (GET)', () => {
return request(app.getHttpServer()).get('/api/metrics').expect(200);
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
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