Compare commits

..

No commits in common. "a767e9a6b8bd6de609e62ffdc50d1fb3891415df" and "476d7759f4da1b7bcb2309cb2529598df9a4c260" have entirely different histories.

81 changed files with 806 additions and 1991 deletions

1
.gitignore vendored
View File

@ -11,4 +11,3 @@ coverage/
node_modules node_modules
.next .next
*.tsbuildinfo *.tsbuildinfo
test ci trigger

View File

@ -5,8 +5,6 @@ ENV NEXT_PUBLIC_API_URL=https://api.canina.ir/api
COPY frontend/application/package*.json ./ COPY frontend/application/package*.json ./
RUN npm ci --prefer-offline --no-audit RUN npm ci --prefer-offline --no-audit
COPY frontend/application ./ COPY frontend/application ./
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build RUN npm run build
FROM node:20-alpine AS admin-builder FROM node:20-alpine AS admin-builder
@ -15,8 +13,6 @@ ENV VITE_API_URL=https://api.canina.ir/api
COPY frontend/admin-panel/package*.json ./ COPY frontend/admin-panel/package*.json ./
RUN npm ci --prefer-offline --no-audit RUN npm ci --prefer-offline --no-audit
COPY frontend/admin-panel ./ COPY frontend/admin-panel ./
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build RUN npm run build
FROM node:20-alpine FROM node:20-alpine

View File

@ -4,21 +4,17 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci --prefer-offline --no-audit RUN npm ci --prefer-offline --no-audit
COPY . . COPY . .
ENV PRISMA_CLI_BINARY_TARGETS=linux-musl-openssl-3.0.x
RUN npx prisma generate && npm run build RUN npx prisma generate && npm run build
FROM node:20-alpine FROM node:20-alpine
RUN apk add --no-cache openssl 2>/dev/null || true RUN apk add --no-cache openssl 2>/dev/null || true
RUN mkdir -p /app/uploads && chown node:node /app/uploads RUN mkdir -p /app/uploads && chown node:node /app/uploads
WORKDIR /app WORKDIR /app
COPY --chown=node:node --from=builder /app/package*.json ./ COPY --from=builder /app/package*.json ./
COPY --chown=node:node --from=builder /app/tsconfig.json ./tsconfig.json COPY --from=builder /app/node_modules ./node_modules
COPY --chown=node:node --from=builder /app/node_modules ./node_modules COPY --from=builder /app/dist ./dist
COPY --chown=node:node --from=builder /app/dist ./dist COPY --from=builder /app/prisma ./prisma
COPY --chown=node:node --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma/scientificTerms.ts ./prisma/scientificTerms.ts
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 EXPOSE 3000
USER node USER node
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main"] CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main"]

View File

@ -88,6 +88,6 @@
"testEnvironment": "node" "testEnvironment": "node"
}, },
"prisma": { "prisma": {
"seed": "ts-node --project prisma/tsconfig.seed.json prisma/seed.ts" "seed": "ts-node prisma/seed.ts"
} }
} }

View File

@ -2,7 +2,7 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
export async function main() { async function main() {
console.log('Seeding Home Page Components...'); console.log('Seeding Home Page Components...');
// 1. Seed Hero Banners // 1. Seed Hero Banners

View File

@ -15,13 +15,8 @@ function slugify(text: string): string {
function determineSuitableFor(name: string, description: string): string { function determineSuitableFor(name: string, description: string): string {
const text = (name + ' ' + description).toLowerCase(); const text = (name + ' ' + description).toLowerCase();
const hasCat = const hasCat = text.includes('گربه') || text.includes('katzen') || text.includes('cat');
text.includes('گربه') || text.includes('katzen') || text.includes('cat'); const hasDog = text.includes('سگ') || text.includes('hund') || text.includes('توله') || text.includes('dog');
const hasDog =
text.includes('سگ') ||
text.includes('hund') ||
text.includes('توله') ||
text.includes('dog');
if (hasCat && hasDog) return 'هر دو'; if (hasCat && hasDog) return 'هر دو';
if (hasCat) return 'گربه'; if (hasCat) return 'گربه';
if (hasDog) return 'سگ'; if (hasDog) return 'سگ';
@ -40,81 +35,49 @@ function parseSize(size: string): { unit: string; packageSize: number } {
function getProductImageUrl(baseSlug: string): string { function getProductImageUrl(baseSlug: string): string {
const images: Record<string, string> = { const images: Record<string, string> = {
'canina-ballaststoff-mix': 'canina-ballaststoff-mix': 'https://www.canina.de/media/4c/32/38/1715077271/Ballaststoff-Mix-140108-V1-Canino-100g_600x600.webp',
'https://www.canina.de/media/4c/32/38/1715077271/Ballaststoff-Mix-140108-V1-Canino-100g_600x600.webp', 'canina-canhydrox-gag': 'https://www.canina.de/media/83/86/e1/123000_123005_Canhydrox_GAG_Canina-Pharma_1280x1280.png',
'canina-canhydrox-gag': 'canina-eierschalenpulver': 'https://www.canina.de/media/fb/d3/18/120208_Eierschalenpulver_Canina-Pharma_1280x1280.png',
'https://www.canina.de/media/83/86/e1/123000_123005_Canhydrox_GAG_Canina-Pharma_1280x1280.png', 'canina-flexan': 'https://www.canina.de/media/01/be/93/710003_Flexan_Canina-Pharma_1280x1280.png',
'canina-eierschalenpulver': 'canina-herz-vital': 'https://www.canina.de/media/b9/8b/4c/112036_Herz_Vital_Canina-Pharma_1280x1280.png',
'https://www.canina.de/media/fb/d3/18/120208_Eierschalenpulver_Canina-Pharma_1280x1280.png', 'canina-immun-booster-paste': 'https://www.canina.de/media/8c/fb/8e/311000_Canino_Immun_Booster_Paste_Abwehrkraefte_1280x1280.png',
'canina-flexan': 'canina-katzenmilch': 'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
'https://www.canina.de/media/01/be/93/710003_Flexan_Canina-Pharma_1280x1280.png', 'canina-lachs-l': 'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
'canina-herz-vital': 'canina-lachs-ol': 'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
'https://www.canina.de/media/b9/8b/4c/112036_Herz_Vital_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-immun-booster-paste': 'canina-moortrnke': 'https://www.canina.de/media/9d/b2/87/112708_Moortraenke_Canina-Pharma_1280x1280.png',
'https://www.canina.de/media/8c/fb/8e/311000_Canino_Immun_Booster_Paste_Abwehrkraefte_1280x1280.png', 'canina-petvital-arthro-tabletten': 'https://www.canina.de/media/90/a6/50/723003_PETVITAL_Arthro_Tabletten_Canina-Pharma_1280x1280.png',
'canina-katzenmilch': 'canina-petvital-bio-aktivator': 'https://www.canina.de/media/c9/2e/0f/712007_PETVITAL_Bio_Aktivator_Canina-Pharma_1280x1280.png',
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png', 'canina-petvital-biotin-tabs': 'https://www.canina.de/media/e1/9b/6c/702008_PETVITAL_Biotin_Tabs_Canina-Pharma_1280x1280.png',
'canina-lachs-l': 'canina-petvital-energy-gel': 'https://www.canina.de/media/7f/0f/06/712106_PETVITAL_Energy_Gel_Canina-Pharma_1280x1280.png',
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png', 'canina-petvital-mineral-tabs': 'https://www.canina.de/media/70/4e/f2/723102_PETVITAL_Mineral_Tabs_Canina-Pharma_1280x1280.png',
'canina-lachs-ol': 'canina-petvital-gag': 'https://www.canina.de/media/58/05/92/723201_723300_PETVITAL_GAG_Canina-Pharma_1280x1280.png',
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png', 'canina-petvital-vitamin-tabs': 'https://www.canina.de/media/2c/80/7e/712205_PETVITAL_Vitamin_Tabs_Canina-Pharma_1280x1280.png',
'canina-marine-lmischung-premium': 'canina-rinderblut-pulver': 'https://www.canina.de/media/14/d0/0d/792016_791514_Rinderblut_Pulver_Canina-Pharma_1280x1280.png',
'https://www.canina.de/media/ad/28/71/153008_Marine_Oelmischung_Premium_Canina-Pharma_1280x1280.png', 'canina-rinderfett-pulver': 'https://www.canina.de/media/9a/31/59/131235_Rinderfett_Pulver_Canina-Pharma_1280x1280.png',
'canina-moortrnke': 'canina-schwarz-kmmel-samen': 'https://www.canina.de/media/a9/c8/aa/131105_Schwarzkuemmelsamen_Canina-Pharma_1280x1280.png',
'https://www.canina.de/media/9d/b2/87/112708_Moortraenke_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-petvital-arthro-tabletten': 'canina-taurin-fr-katzen': 'https://www.canina.de/media/c6/aa/62/229505_Taurin_fuer_Katzen_Canina-Pharma_1280x1280.png',
'https://www.canina.de/media/90/a6/50/723003_PETVITAL_Arthro_Tabletten_Canina-Pharma_1280x1280.png', 'canina-velox-gelenkenergie': 'https://www.canina.de/media/1a/0c/33/701902_Velox_Gelenkenergie_Canina-Pharma_1280x1280.png',
'canina-petvital-bio-aktivator': 'canina-welpenbrei': 'https://www.canina.de/media/98/95/43/130603_Welpenbrei_Canina-Pharma_1280x1280.png',
'https://www.canina.de/media/c9/2e/0f/712007_PETVITAL_Bio_Aktivator_Canina-Pharma_1280x1280.png', 'canina-welpenmilch': 'https://www.canina.de/media/2c/e0/75/130702_Welpenmilch_Canina-Pharma_1280x1280.png',
'canina-petvital-biotin-tabs': 'canina-petvital-bio-insect-shocker': 'https://www.canina.de/media/76/a4/1c/741304_741311_PETVITAL_Bio_Insect_Shocker_Canina-Pharma_1280x1280.png',
'https://www.canina.de/media/e1/9b/6c/702008_PETVITAL_Biotin_Tabs_Canina-Pharma_1280x1280.png', 'canina-mikrosilber-zahngel': 'https://www.canina.de/media/9d/5e/54/131454_Mikrosilber_Zahngel_Canina-Pharma_1280x1280.png',
'canina-petvital-energy-gel': 'canina-novagard-green-augenpflege': 'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
'https://www.canina.de/media/7f/0f/06/712106_PETVITAL_Energy_Gel_Canina-Pharma_1280x1280.png', 'canina-novagard-green-pfotenpflege': 'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
'canina-petvital-mineral-tabs':
'https://www.canina.de/media/70/4e/f2/723102_PETVITAL_Mineral_Tabs_Canina-Pharma_1280x1280.png',
'canina-petvital-gag':
'https://www.canina.de/media/58/05/92/723201_723300_PETVITAL_GAG_Canina-Pharma_1280x1280.png',
'canina-petvital-vitamin-tabs':
'https://www.canina.de/media/2c/80/7e/712205_PETVITAL_Vitamin_Tabs_Canina-Pharma_1280x1280.png',
'canina-rinderblut-pulver':
'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-kmmel-samen':
'https://www.canina.de/media/a9/c8/aa/131105_Schwarzkuemmelsamen_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-fr-katzen':
'https://www.canina.de/media/c6/aa/62/229505_Taurin_fuer_Katzen_Canina-Pharma_1280x1280.png',
'canina-velox-gelenkenergie':
'https://www.canina.de/media/1a/0c/33/701902_Velox_Gelenkenergie_Canina-Pharma_1280x1280.png',
'canina-welpenbrei':
'https://www.canina.de/media/98/95/43/130603_Welpenbrei_Canina-Pharma_1280x1280.png',
'canina-welpenmilch':
'https://www.canina.de/media/2c/e0/75/130702_Welpenmilch_Canina-Pharma_1280x1280.png',
'canina-petvital-bio-insect-shocker':
'https://www.canina.de/media/76/a4/1c/741304_741311_PETVITAL_Bio_Insect_Shocker_Canina-Pharma_1280x1280.png',
'canina-mikrosilber-zahngel':
'https://www.canina.de/media/9d/5e/54/131454_Mikrosilber_Zahngel_Canina-Pharma_1280x1280.png',
'canina-novagard-green-augenpflege':
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
'canina-novagard-green-pfotenpflege':
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
}; };
return images[baseSlug] || `/products/${baseSlug}.png`; return images[baseSlug] || `/products/${baseSlug}.png`;
} }
async function findOrCreateCategory( async function findOrCreateCategory(slug: string): Promise<{ id: string; slug: string }> {
slug: string,
): Promise<{ id: string; slug: string }> {
const CATEGORY_NAMES: Record<string, string> = { const CATEGORY_NAMES: Record<string, string> = {
joints: 'مفاصل و استخوان', 'joints': 'مفاصل و استخوان',
immune: 'تقویت سیستم ایمنی و گوارش', 'immune': 'تقویت سیستم ایمنی و گوارش',
energy: 'ویتامین‌ها و انرژی‌بخش‌ها', 'energy': 'ویتامین‌ها و انرژی‌بخش‌ها',
'special-care': 'مراقبت‌های ویژه (پوست، دندان و چشم)', 'special-care': 'مراقبت‌های ویژه (پوست، دندان و چشم)',
general: 'تقویت عمومی', 'general': 'تقویت عمومی',
nutrition: 'تغذیه تخصصی', 'nutrition': 'تغذیه تخصصی',
supplements: 'مکمل‌های غذایی و درمانی', 'supplements': 'مکمل‌های غذایی و درمانی',
}; };
const name = CATEGORY_NAMES[slug] || slug; const name = CATEGORY_NAMES[slug] || slug;
return await prisma.category.upsert({ return await prisma.category.upsert({
@ -125,141 +88,80 @@ async function findOrCreateCategory(
} }
const PRODUCT_SYMPTOMS_MAP: Record<string, string[]> = { const PRODUCT_SYMPTOMS_MAP: Record<string, string[]> = {
'canina-ballaststoff-mix': [ 'canina-ballaststoff-mix': ["اسهال", "یبوست", "اسهال مزمن", "تنظیم فلور روده"],
'اسهال', 'canina-canhydrox-gag': ["درد مفاصل", "سختی در بلند شدن", "لنگیدن", "رشد سریع توله‌سگ", "تقویت رباط و تاندون"],
'یبوست', 'canina-eierschalenpulver': ["کمبود کلسیم", "رژیم خام گوشتی", "سلامت دندان‌ها"],
'اسهال مزمن', 'canina-flexan': ["درد مفاصل", "سختی در بلند شدن", "لنگیدن", "تخریب غضروف"],
'تنظیم فلور روده', 'canina-herz-vital': ["نارسایی قلبی", "بی‌حالی", "کاهش انرژی"],
], 'canina-immun-booster-paste': ["ضعف بعد از بیماری", "اسهال", "ضعف توله‌سگ", "کاهش ایمنی"],
'canina-canhydrox-gag': [ 'canina-katzenmilch': ["تغذیه بچه گربه", "بی‌مادری بچه گربه"],
'درد مفاصل', 'canina-lachs-ol': ["خشکی پوست", "ریزش مو", "خارش", "التهاب پوست"],
'سختی در بلند شدن', 'canina-marine-lmischung-premium': ["خشکی پوست", "ریزش مو", "التهاب"],
'لنگیدن', 'canina-moortrnke': ["اسهال", "اشتهای کم", "مسمومیت گوارشی"],
'رشد سریع توله‌سگ', 'canina-petvital-arthro-tabletten': ["درد مفاصل", "سختی در بلند شدن", "لنگیدن"],
'تقویت رباط و تاندون', 'canina-petvital-bio-aktivator': ["کاهش ایمنی", "خستگی", "دوران نقاهت"],
], 'canina-petvital-biotin-tabs': ["ریزش مو", "خشکی پوست", "شکنندگی ناخن"],
'canina-eierschalenpulver': [ 'canina-petvital-energy-gel': ["بی‌اشتهایی", "کاهش وزن", "کمبود انرژی"],
'کمبود کلسیم', 'canina-petvital-mineral-tabs': ["کمبود مواد معدنی", "ضعف استخوان"],
'رژیم خام گوشتی', 'canina-petvital-gag': ["سختی در بلند شدن", "لنگیدن"],
'سلامت دندان‌ها', 'canina-petvital-vitamin-tabs': ["کمبود ویتامین", "کاهش انرژی"],
], 'canina-rinderblut-pulver': ["کم‌خونی", "بی‌اشتهایی", "دوران رشد"],
'canina-flexan': ['درد مفاصل', 'سختی در بلند شدن', 'لنگیدن', 'تخریب غضروف'], 'canina-rinderfett-pulver': ["کمبود وزن", "اشتهای کم"],
'canina-herz-vital': ['نارسایی قلبی', 'بی‌حالی', 'کاهش انرژی'], 'canina-schwarz-kmmel-samen': ["انگل روده", "ضعف ایمنی"],
'canina-immun-booster-paste': [ 'canina-seealgen-bio-seealgenmehl': ["کاهش پیگمنت مو", "کمبود تیروئید"],
'ضعف بعد از بیماری', 'canina-taurin-fr-katzen': ["مشکل بینایی گربه", "نارسایی قلبی گربه"],
'اسهال', 'canina-velox-gelenkenergie': ["درد مفاصل", "لنگیدن"],
'ضعف توله‌سگ', 'canina-welpenbrei': ["تغذیه توله‌سگ", "از شیر گرفتن توله‌سگ"],
'کاهش ایمنی', 'canina-welpenmilch': ["تغذیه توله‌سگ", "بی‌مادری توله‌سگ"],
], 'canina-petvital-bio-insect-shocker': ["کک و کنه", "انگل‌های پوستی"],
'canina-katzenmilch': ['تغذیه بچه گربه', 'بی‌مادری بچه گربه'], 'canina-mikrosilber-zahngel': ["جرم دندان", "بوی بد دهان", "التهاب لثه"],
'canina-lachs-ol': ['خشکی پوست', 'ریزش مو', 'خارش', 'التهاب پوست'], 'canina-novagard-green-augenpflege': ["ترشحات چشم", "التهاب چشم", "کثیفی چشم"],
'canina-marine-lmischung-premium': ['خشکی پوست', 'ریزش مو', 'التهاب'], 'canina-novagard-green-pfotenpflege': ["ترک پنجه", "خشکی پنجه", "زخم پنجه"],
'canina-moortrnke': ['اسهال', 'اشتهای کم', 'مسمومیت گوارشی'],
'canina-petvital-arthro-tabletten': [
'درد مفاصل',
'سختی در بلند شدن',
'لنگیدن',
],
'canina-petvital-bio-aktivator': ['کاهش ایمنی', 'خستگی', 'دوران نقاهت'],
'canina-petvital-biotin-tabs': ['ریزش مو', 'خشکی پوست', 'شکنندگی ناخن'],
'canina-petvital-energy-gel': ['بی‌اشتهایی', 'کاهش وزن', 'کمبود انرژی'],
'canina-petvital-mineral-tabs': ['کمبود مواد معدنی', 'ضعف استخوان'],
'canina-petvital-gag': ['سختی در بلند شدن', 'لنگیدن'],
'canina-petvital-vitamin-tabs': ['کمبود ویتامین', 'کاهش انرژی'],
'canina-rinderblut-pulver': ['کم‌خونی', 'بی‌اشتهایی', 'دوران رشد'],
'canina-rinderfett-pulver': ['کمبود وزن', 'اشتهای کم'],
'canina-schwarz-kmmel-samen': ['انگل روده', 'ضعف ایمنی'],
'canina-seealgen-bio-seealgenmehl': ['کاهش پیگمنت مو', 'کمبود تیروئید'],
'canina-taurin-fr-katzen': ['مشکل بینایی گربه', 'نارسایی قلبی گربه'],
'canina-velox-gelenkenergie': ['درد مفاصل', 'لنگیدن'],
'canina-welpenbrei': ['تغذیه توله‌سگ', 'از شیر گرفتن توله‌سگ'],
'canina-welpenmilch': ['تغذیه توله‌سگ', 'بی‌مادری توله‌سگ'],
'canina-petvital-bio-insect-shocker': ['کک و کنه', 'انگل‌های پوستی'],
'canina-mikrosilber-zahngel': ['جرم دندان', 'بوی بد دهان', 'التهاب لثه'],
'canina-novagard-green-augenpflege': [
'ترشحات چشم',
'التهاب چشم',
'کثیفی چشم',
],
'canina-novagard-green-pfotenpflege': ['ترک پنجه', 'خشکی پنجه', 'زخم پنجه'],
}; };
const PRODUCT_TAGLINES_MAP: Record<string, string> = { const PRODUCT_TAGLINES_MAP: Record<string, string> = {
'canina-ballaststoff-mix': 'canina-ballaststoff-mix': "مکمل فیبر پری‌بیوتیک جهت تنظیم گوارش و پایداری فلور روده",
'مکمل فیبر پری‌بیوتیک جهت تنظیم گوارش و پایداری فلور روده', 'canina-canhydrox-gag': "فرمولاسیون ویژه دامپزشکی برای پایداری بافت‌های همبند، غضروف و استخوان‌ها",
'canina-canhydrox-gag': 'canina-eierschalenpulver': "کلسیم ارگانیک صد در صد طبیعی مناسب رژیم‌های غذایی خام (BARF)",
'فرمولاسیون ویژه دامپزشکی برای پایداری بافت‌های همبند، غضروف و استخوان‌ها', 'canina-flexan': "پپتیدهای کلاژن زیست‌فعال برای بهینه‌سازی دامنه حرکتی و بازسازی مفصلی",
'canina-eierschalenpulver': 'canina-herz-vital': "تقویت عملکرد فیزیولوژیک عضله قلب و افزایش نشاط حیوان",
'کلسیم ارگانیک صد در صد طبیعی مناسب رژیم‌های غذایی خام (BARF)', 'canina-immun-booster-paste': "تامین فوری ایمونوگلوبولین‌های آغوز و تثبیت فلور روده در زمان نقاهت",
'canina-flexan': 'canina-katzenmilch': "شیر خشک جایگزین بچه گربه حاوی تورین و فاقد لاکتوز مزاحم",
'پپتیدهای کلاژن زیست‌فعال برای بهینه‌سازی دامنه حرکتی و بازسازی مفصلی', 'canina-lachs-ol': "اسیدهای چرب ضروری امگا ۳ برای درخشش پوشش مویی و سلامت پوست",
'canina-herz-vital': 'تقویت عملکرد فیزیولوژیک عضله قلب و افزایش نشاط حیوان', 'canina-marine-lmischung-premium': "ترکیب روغن‌های ممتاز دریایی غنی از EPA و DHA جهت کاهش التهابات پوستی",
'canina-immun-booster-paste': 'canina-moortrnke': "عصاره پیت طبیعی برای جذب بیولوژیکی سموم و بهبود ترشحات گوارشی",
'تامین فوری ایمونوگلوبولین‌های آغوز و تثبیت فلور روده در زمان نقاهت', 'canina-petvital-arthro-tabletten': "فرمول گیاهی-معدنی برای کاهش دردهای حاد مفصلی و تسهیل در بلند شدن",
'canina-katzenmilch': 'canina-petvital-bio-aktivator': "آمینو اسیدها و آهن فعال جهت بازسازی قوای جسمی و بهبود اشتها",
'شیر خشک جایگزین بچه گربه حاوی تورین و فاقد لاکتوز مزاحم', 'canina-petvital-biotin-tabs': "دوز بالای بیوتین و ویتامین ب برای توقف سریع ریزش مو و بازسازی ناخن",
'canina-lachs-ol': 'canina-petvital-energy-gel': "کنسانتره انرژی بالا همراه با الکترولیت‌ها برای سگ‌ها و گربه‌های ضعیف",
'اسیدهای چرب ضروری امگا ۳ برای درخشش پوشش مویی و سلامت پوست', 'canina-petvital-mineral-tabs': "مواد معدنی و عناصر کمیاب جهت تراکم استخوانی و دوران بارداری و رشد",
'canina-marine-lmischung-premium': 'canina-petvital-gag': "ترکیب صدف لب‌سبز نیوزیلند و اسیدهای آمینه جهت تقویت رباط‌ها و تاندون‌ها",
'ترکیب روغن‌های ممتاز دریایی غنی از EPA و DHA جهت کاهش التهابات پوستی', 'canina-petvital-vitamin-tabs': "مولتی‌ویتامین کامل روزانه برای تقویت سیستم دفاعی و افزایش شادابی پت",
'canina-moortrnke': 'canina-rinderblut-pulver': "پودر خون گاو غنی از آهن طبیعی و هموگلوبین برای بهبود اشتها و رفع کم‌خونی",
'عصاره پیت طبیعی برای جذب بیولوژیکی سموم و بهبود ترشحات گوارشی', 'canina-rinderfett-pulver': "مکمل چربی طبیعی با طعم‌دهندگی بالا جهت جبران کمبود وزن و افزایش انرژی",
'canina-petvital-arthro-tabletten': 'canina-schwarz-kmmel-samen': "دانه‌های سیاه دانه مصری برای پشتیبانی متابولیک و دفع طبیعی انگل‌ها",
'فرمول گیاهی-معدنی برای کاهش دردهای حاد مفصلی و تسهیل در بلند شدن', 'canina-seealgen-bio-seealgenmehl': "جلبک دریایی ارگانیک سرشار از ید طبیعی جهت درخشش و تیره کردن پیگمنت‌های مو و بینی",
'canina-petvital-bio-aktivator': 'canina-taurin-fr-katzen': "اسید آمینه تورین خالص برای سلامت بینایی و پیشگیری از کاردیومیوپاتی گربه‌ها",
'آمینو اسیدها و آهن فعال جهت بازسازی قوای جسمی و بهبود اشتها', 'canina-velox-gelenkenergie': "پودر صد در صد صدف لب‌سبز نیوزیلند برای مفاصل، غضروف‌ها و رباط‌ها",
'canina-petvital-biotin-tabs': 'canina-welpenbrei': "غذای کمکی بچه سگ‌ها برای انتقال آسان از شیر مادر به غذای جامد",
'دوز بالای بیوتین و ویتامین ب برای توقف سریع ریزش مو و بازسازی ناخن', 'canina-welpenmilch': "شیر خشک تخصصی توله‌سگ غنی شده با ویتامین‌ها و املاح معدنی فاقد لاکتوز",
'canina-petvital-energy-gel': 'canina-petvital-bio-insect-shocker': "اسپری دافع انگل‌های خارجی کک، کنه و شپش صد در صد طبیعی و بدون سموم شیمیایی",
'کنسانتره انرژی بالا همراه با الکترولیت‌ها برای سگ‌ها و گربه‌های ضعیف', 'canina-mikrosilber-zahngel': "ژل دندان با نقره میکروسیلور جهت مبارزه با بوی بد دهان، پلاک و عفونت لثه",
'canina-petvital-mineral-tabs': 'canina-novagard-green-augenpflege': "محلول ملایم شستشوی چشم سگ و گربه برای پاک کردن ترشحات و کاهش التهابات",
'مواد معدنی و عناصر کمیاب جهت تراکم استخوانی و دوران بارداری و رشد', 'canina-novagard-green-pfotenpflege': "مومیایی محافظ پنجه‌ها برای التیام خشکی، قرمزی و ترک خوردگی پنجه‌ها در برف و سرما"
'canina-petvital-gag':
'ترکیب صدف لب‌سبز نیوزیلند و اسیدهای آمینه جهت تقویت رباط‌ها و تاندون‌ها',
'canina-petvital-vitamin-tabs':
'مولتی‌ویتامین کامل روزانه برای تقویت سیستم دفاعی و افزایش شادابی پت',
'canina-rinderblut-pulver':
'پودر خون گاو غنی از آهن طبیعی و هموگلوبین برای بهبود اشتها و رفع کم‌خونی',
'canina-rinderfett-pulver':
'مکمل چربی طبیعی با طعم‌دهندگی بالا جهت جبران کمبود وزن و افزایش انرژی',
'canina-schwarz-kmmel-samen':
'دانه‌های سیاه دانه مصری برای پشتیبانی متابولیک و دفع طبیعی انگل‌ها',
'canina-seealgen-bio-seealgenmehl':
'جلبک دریایی ارگانیک سرشار از ید طبیعی جهت درخشش و تیره کردن پیگمنت‌های مو و بینی',
'canina-taurin-fr-katzen':
'اسید آمینه تورین خالص برای سلامت بینایی و پیشگیری از کاردیومیوپاتی گربه‌ها',
'canina-velox-gelenkenergie':
'پودر صد در صد صدف لب‌سبز نیوزیلند برای مفاصل، غضروف‌ها و رباط‌ها',
'canina-welpenbrei':
'غذای کمکی بچه سگ‌ها برای انتقال آسان از شیر مادر به غذای جامد',
'canina-welpenmilch':
'شیر خشک تخصصی توله‌سگ غنی شده با ویتامین‌ها و املاح معدنی فاقد لاکتوز',
'canina-petvital-bio-insect-shocker':
'اسپری دافع انگل‌های خارجی کک، کنه و شپش صد در صد طبیعی و بدون سموم شیمیایی',
'canina-mikrosilber-zahngel':
'ژل دندان با نقره میکروسیلور جهت مبارزه با بوی بد دهان، پلاک و عفونت لثه',
'canina-novagard-green-augenpflege':
'محلول ملایم شستشوی چشم سگ و گربه برای پاک کردن ترشحات و کاهش التهابات',
'canina-novagard-green-pfotenpflege':
'مومیایی محافظ پنجه‌ها برای التیام خشکی، قرمزی و ترک خوردگی پنجه‌ها در برف و سرما',
}; };
export async function main() { async function main() {
console.log('Seeding products from seed-products-data.json...'); console.log('Seeding products from seed-products-data.json...');
// 2. Read JSON data // 2. Read JSON data
const dataPath = path.join( const dataPath = path.join(process.cwd() + "/prisma", 'seed-products-data.json');
process.cwd() + '/prisma',
'seed-products-data.json',
);
if (!fs.existsSync(dataPath)) { if (!fs.existsSync(dataPath)) {
console.error('seed-products-data.json not found at', dataPath); console.error('seed-products-data.json not found at', dataPath);
process.exit(1); process.exit(1);
} }
const productsData = JSON.parse( const productsData = JSON.parse(fs.readFileSync(dataPath, 'utf-8').replace(/^\ufeff/, ''));
fs.readFileSync(dataPath, 'utf-8').replace(/^\ufeff/, ''),
);
let variantCount = 0; let variantCount = 0;
for (const item of productsData) { for (const item of productsData) {
@ -299,116 +201,82 @@ export async function main() {
const nameEn = `${baseNameEn} - ${sizeLabel}`; const nameEn = `${baseNameEn} - ${sizeLabel}`;
const priceDisplay = `${sellPrice.toLocaleString()} تومان`; const priceDisplay = `${sellPrice.toLocaleString()} تومان`;
const RX_PRODUCTS = [ const RX_PRODUCTS = ['canina-canhydrox-gag', 'canina-petvital-gag', 'canina-herz-vital', 'canina-petvital-arthro-tabletten', 'canina-immun-booster', 'canina-petvital-bio-aktivator', 'canina-rinderblut-pulver', 'canina-velox-gelenkenergie'];
'canina-canhydrox-gag',
'canina-petvital-gag',
'canina-herz-vital',
'canina-petvital-arthro-tabletten',
'canina-immun-booster',
'canina-petvital-bio-aktivator',
'canina-rinderblut-pulver',
'canina-velox-gelenkenergie',
];
const requiresRx = RX_PRODUCTS.includes(baseSlug); const requiresRx = RX_PRODUCTS.includes(baseSlug);
try { const product = await prisma.product.upsert({
const product = await prisma.product.upsert({ where: { artNo },
where: { artNo }, update: {
update: { barcode,
barcode, slug,
slug, productGroup: baseSlug,
productGroup: baseSlug, nameFa,
nameFa, nameEn,
nameEn, scientificTagline: PRODUCT_TAGLINES_MAP[baseSlug] || null,
scientificTagline: PRODUCT_TAGLINES_MAP[baseSlug] || null, description: cleanDesc,
description: cleanDesc, shortDescription: cleanShortDesc,
shortDescription: cleanShortDesc, ingredients: cleanIngredients,
ingredients: cleanIngredients, categoryId: category.id,
categoryId: category.id, categorySlug: category.slug,
categorySlug: category.slug, priceValue: sellPrice,
priceValue: sellPrice, wholesalePrice: Math.round(sellPrice * 0.7),
wholesalePrice: Math.round(sellPrice * 0.7), requiresRx,
requiresRx, buyPrice,
buyPrice, priceDisplay,
priceDisplay, unit,
unit, packageSize,
packageSize, dosageLogic: cleanDosage,
dosageLogic: cleanDosage, suitableFor,
suitableFor, imageUrl: getProductImageUrl(baseSlug),
imageUrl: getProductImageUrl(baseSlug), },
}, create: {
create: { artNo,
artNo, barcode,
barcode, slug,
slug, productGroup: baseSlug,
productGroup: baseSlug, nameFa,
nameFa, nameEn,
nameEn, scientificTagline: PRODUCT_TAGLINES_MAP[baseSlug] || null,
scientificTagline: PRODUCT_TAGLINES_MAP[baseSlug] || null, description: cleanDesc,
description: cleanDesc, shortDescription: cleanShortDesc,
shortDescription: cleanShortDesc, ingredients: cleanIngredients,
ingredients: cleanIngredients, categoryId: category.id,
categoryId: category.id, categorySlug: category.slug,
categorySlug: category.slug, priceValue: sellPrice,
priceValue: sellPrice, wholesalePrice: Math.round(sellPrice * 0.7),
wholesalePrice: Math.round(sellPrice * 0.7), requiresRx,
requiresRx, buyPrice,
buyPrice, priceDisplay,
priceDisplay, unit,
unit, packageSize,
packageSize, dosageLogic: cleanDosage,
dosageLogic: cleanDosage, suitableFor,
suitableFor, imageUrl: getProductImageUrl(baseSlug),
imageUrl: getProductImageUrl(baseSlug),
},
});
// Seed ingredients safely
await prisma.productIngredient.deleteMany({
where: { productId: product.id },
});
if (cleanIngredients) {
const parts = cleanIngredients
.split(/[،,]/)
.map((s: string) => s.trim())
.filter(Boolean);
const uniqueIngredients: string[] = Array.from(
new Set<string>(parts.map((ing: string) => ing.substring(0, 150))),
).slice(0, 20);
for (const item of uniqueIngredients) {
const truncated: string = String(item);
if (truncated.length > 0) {
try {
await prisma.productIngredient.create({
data: { productId: product.id, ingredient: truncated },
});
} catch {
// Ignore duplicate ingredient error
}
}
}
} }
});
// Seed symptoms safely // Seed ingredients
await prisma.productSymptom.deleteMany({ await prisma.productIngredient.deleteMany({ where: { productId: product.id } });
where: { productId: product.id }, if (cleanIngredients) {
}); const parts = cleanIngredients.split(/[،,]/).map((s: string) => s.trim()).filter(Boolean);
const symptomsList: string[] = Array.from( const meaningful = parts.slice(0, 20);
new Set<string>(PRODUCT_SYMPTOMS_MAP[baseSlug] || []), for (const ing of meaningful) {
); const truncated = ing.substring(0, 150);
for (const item of symptomsList) { if (truncated.length > 0) {
const sym: string = String(item); await prisma.productIngredient.create({
try { data: { productId: product.id, ingredient: truncated }
await prisma.productSymptom.create({
data: { productId: product.id, symptom: sym },
}); });
} catch {
// Ignore duplicate symptom error
} }
} }
} catch (err: unknown) { }
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[Seed] Warning upserting product ${artNo}:`, msg); // Seed symptoms
await prisma.productSymptom.deleteMany({ where: { productId: product.id } });
const symptomsList = PRODUCT_SYMPTOMS_MAP[baseSlug] || [];
for (const sym of symptomsList) {
await prisma.productSymptom.create({
data: { productId: product.id, symptom: sym }
});
} }
variantCount++; variantCount++;
@ -428,8 +296,8 @@ export async function main() {
email: 'admin@canino-iran.com', email: 'admin@canino-iran.com',
firstName: 'Admin', firstName: 'Admin',
lastName: 'User', lastName: 'User',
role: 'ADMIN', role: 'ADMIN'
}, }
}); });
// 3. Seed some Blogs // 3. Seed some Blogs
@ -454,7 +322,7 @@ export async function main() {
'<li>روغن سیاه دانه در بارداری و شیردهی ممنوع است</li>', '<li>روغن سیاه دانه در بارداری و شیردهی ممنوع است</li>',
'<li>روغن‌های با غلظت بالا باید تدریجاً به رژیم اضافه شوند تا از پانکراتیت جلوگیری شود</li>', '<li>روغن‌های با غلظت بالا باید تدریجاً به رژیم اضافه شوند تا از پانکراتیت جلوگیری شود</li>',
'<li>تورین برای گربه‌ها اسید آمینه ضروری است و بدن آن‌ها قادر به سنتز آن نیست</li>', '<li>تورین برای گربه‌ها اسید آمینه ضروری است و بدن آن‌ها قادر به سنتز آن نیست</li>',
'</ul>', '</ul>'
].join('\n'); ].join('\n');
const blog2Content = [ const blog2Content = [
@ -478,7 +346,7 @@ export async function main() {
'<p><strong>قلب و انرژی:</strong> Herz Vital (ال-کارنیتین و زالزالک)، Energy-Gel (ویتامین‌های گروه B)</p>', '<p><strong>قلب و انرژی:</strong> Herz Vital (ال-کارنیتین و زالزالک)، Energy-Gel (ویتامین‌های گروه B)</p>',
'', '',
'<h2>تعهد به سلامت حیوانات</h2>', '<h2>تعهد به سلامت حیوانات</h2>',
'<p>کانینا به عنوان نماینده رسمی کانینا در ایران، متعهد به ارائه محصولات اصل با ضمانت اصالت و پروانه دامپزشکی است. تمام محصولات دارای بسته‌بندی اصلی آلمان و بارکد اختصاصی هستند.</p>', '<p>کانینا به عنوان نماینده رسمی کانینا در ایران، متعهد به ارائه محصولات اصل با ضمانت اصالت و پروانه دامپزشکی است. تمام محصولات دارای بسته‌بندی اصلی آلمان و بارکد اختصاصی هستند.</p>'
].join('\n'); ].join('\n');
const blogs = [ const blogs = [
@ -495,30 +363,21 @@ export async function main() {
content: blog2Content, content: blog2Content,
authorId: adminId, authorId: adminId,
isPublished: true, isPublished: true,
}, }
]; ];
for (const b of blogs) { for (const b of blogs) {
try { await prisma.blog.upsert({
await prisma.blog.upsert({ where: { slug: b.slug },
where: { slug: b.slug }, update: {},
update: { create: b
title: b.title, });
content: b.content,
isPublished: b.isPublished,
},
create: b,
});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[Seed] Warning upserting blog ${b.slug}:`, msg);
}
} }
console.log('Seeded blogs.'); console.log('Seeded blogs.');
} }
main() main()
.catch((e) => { .catch(e => {
console.error(e); console.error(e);
process.exit(1); process.exit(1);
}) })

View File

@ -296,10 +296,8 @@ async function main() {
// 3. Seed Products & Categories // 3. Seed Products & Categories
console.log('Seeding Products & Categories...'); console.log('Seeding Products & Categories...');
try { try {
const seedProducts = require('./seed-products'); const { execSync } = require('child_process');
if (seedProducts && typeof seedProducts.main === 'function') { execSync('npx ts-node prisma/seed-products.ts', { stdio: 'inherit' });
await seedProducts.main();
}
} catch (err) { } catch (err) {
console.error('Failed to seed products:', err); console.error('Failed to seed products:', err);
} }
@ -307,10 +305,8 @@ async function main() {
// 4. Seed Home Components & Testimonials // 4. Seed Home Components & Testimonials
console.log('Seeding Home Components...'); console.log('Seeding Home Components...');
try { try {
const seedHome = require('./seed-home'); const { execSync } = require('child_process');
if (seedHome && typeof seedHome.main === 'function') { execSync('npx ts-node prisma/seed-home.ts', { stdio: 'inherit' });
await seedHome.main();
}
} catch (err) { } catch (err) {
console.error('Failed to seed home components:', err); console.error('Failed to seed home components:', err);
} }

View File

@ -1,8 +0,0 @@
{
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true,
"skipLibCheck": true
}
}

View File

@ -1,22 +1,7 @@
import { import { Controller, Get, UseGuards, Param, Put, Post, Body, Delete, Query } from '@nestjs/common';
Controller,
Get,
UseGuards,
Param,
Put,
Post,
Body,
Delete,
Query,
} from '@nestjs/common';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiQuery,
} from '@nestjs/swagger';
@ApiTags('Admin - پنل مدیریت') @ApiTags('Admin - پنل مدیریت')
@ApiBearerAuth() @ApiBearerAuth()
@ -31,7 +16,7 @@ export class AdminController {
const stats = await this.adminService.getDashboardStats(); const stats = await this.adminService.getDashboardStats();
return { return {
success: true, success: true,
data: stats, data: stats
}; };
} }
@ -40,28 +25,15 @@ export class AdminController {
@ApiOperation({ summary: 'لیست کاربران' }) @ApiOperation({ summary: 'لیست کاربران' })
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' }) @ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' }) @ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' })
@ApiQuery({ @ApiQuery({ name: 'search', required: false, description: 'جستجو در نام یا ایمیل' })
name: 'search', @ApiQuery({ name: 'role', required: false, description: 'فیلتر بر اساس نقش کاربر' })
required: false,
description: 'جستجو در نام یا ایمیل',
})
@ApiQuery({
name: 'role',
required: false,
description: 'فیلتر بر اساس نقش کاربر',
})
async getUsers( async getUsers(
@Query('page') page?: string, @Query('page') page?: string,
@Query('limit') limit?: string, @Query('limit') limit?: string,
@Query('search') search?: string, @Query('search') search?: string,
@Query('role') role?: string, @Query('role') role?: string
) { ) {
const data = await this.adminService.getUsers({ const data = await this.adminService.getUsers({ page, limit, search, role });
page,
limit,
search,
role,
});
return { success: true, ...data }; return { success: true, ...data };
} }
@ -72,7 +44,7 @@ export class AdminController {
const user = await this.adminService.updateUserRole(id, role); const user = await this.adminService.updateUserRole(id, role);
return { return {
success: true, success: true,
data: user, data: user
}; };
} }
@ -82,23 +54,14 @@ export class AdminController {
@ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' }) @ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' })
@ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' }) @ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' })
@ApiQuery({ name: 'search', required: false, description: 'جستجو' }) @ApiQuery({ name: 'search', required: false, description: 'جستجو' })
@ApiQuery({ @ApiQuery({ name: 'categoryId', required: false, description: 'شناسه دسته‌بندی' })
name: 'categoryId',
required: false,
description: 'شناسه دسته‌بندی',
})
async getProducts( async getProducts(
@Query('page') page?: string, @Query('page') page?: string,
@Query('limit') limit?: string, @Query('limit') limit?: string,
@Query('search') search?: string, @Query('search') search?: string,
@Query('categoryId') categoryId?: string, @Query('categoryId') categoryId?: string
) { ) {
const data = await this.adminService.getProducts({ const data = await this.adminService.getProducts({ page, limit, search, categoryId });
page,
limit,
search,
categoryId,
});
return { success: true, ...data }; return { success: true, ...data };
} }
@ -117,7 +80,7 @@ export class AdminController {
const product = await this.adminService.updateProduct(id, data); const product = await this.adminService.updateProduct(id, data);
return { return {
success: true, success: true,
data: product, data: product
}; };
} }
@ -128,7 +91,7 @@ export class AdminController {
await this.adminService.deleteProduct(id); await this.adminService.deleteProduct(id);
return { return {
success: true, success: true,
message: 'Product deleted', message: 'Product deleted'
}; };
} }
@ -143,14 +106,9 @@ export class AdminController {
@Query('page') page?: string, @Query('page') page?: string,
@Query('limit') limit?: string, @Query('limit') limit?: string,
@Query('search') search?: string, @Query('search') search?: string,
@Query('status') status?: string, @Query('status') status?: string
) { ) {
const data = await this.adminService.getOrders({ const data = await this.adminService.getOrders({ page, limit, search, status });
page,
limit,
search,
status,
});
return { success: true, ...data }; return { success: true, ...data };
} }
@ -160,16 +118,12 @@ export class AdminController {
async updateOrderStatus( async updateOrderStatus(
@Param('id') id: string, @Param('id') id: string,
@Body('status') status: string, @Body('status') status: string,
@Body('trackingNumber') trackingNumber?: string, @Body('trackingNumber') trackingNumber?: string
) { ) {
const order = await this.adminService.updateOrderStatus( const order = await this.adminService.updateOrderStatus(id, status, trackingNumber);
id,
status,
trackingNumber,
);
return { return {
success: true, success: true,
data: order, data: order
}; };
} }
@ -204,10 +158,7 @@ export class AdminController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Put('coupons/:id/toggle') @Put('coupons/:id/toggle')
@ApiOperation({ summary: 'فعال/غیرفعال کردن کد تخفیف' }) @ApiOperation({ summary: 'فعال/غیرفعال کردن کد تخفیف' })
async toggleCoupon( async toggleCoupon(@Param('id') id: string, @Body('isActive') isActive: boolean) {
@Param('id') id: string,
@Body('isActive') isActive: boolean,
) {
const coupon = await this.adminService.toggleCoupon(id, isActive); const coupon = await this.adminService.toggleCoupon(id, isActive);
return { success: true, data: coupon }; return { success: true, data: coupon };
} }

View File

@ -18,23 +18,7 @@ import { RedisModule } from '../redis/redis.module';
@Module({ @Module({
imports: [PrismaModule, RedisModule], imports: [PrismaModule, RedisModule],
controllers: [ controllers: [AdminController, ReportsController, MediaController, CategoriesController, BlogsController, WikiController, PetsController],
AdminController, providers: [AdminService, ReportsService, MediaService, CategoriesService, BlogsService, WikiService, PetsService],
ReportsController,
MediaController,
CategoriesController,
BlogsController,
WikiController,
PetsController,
],
providers: [
AdminService,
ReportsService,
MediaService,
CategoriesService,
BlogsService,
WikiService,
PetsService,
],
}) })
export class AdminModule {} export class AdminModule {}

View File

@ -13,14 +13,14 @@ export class AdminService {
// total revenue // total revenue
const orders = await this.prisma.order.findMany({ const orders = await this.prisma.order.findMany({
where: { status: { not: 'failed' } }, where: { status: { not: 'failed' } },
select: { totalAmount: true }, select: { totalAmount: true }
}); });
const revenue = orders.reduce((sum, o) => sum + Number(o.totalAmount), 0); const revenue = orders.reduce((sum, o) => sum + Number(o.totalAmount), 0);
// new orders count (processing status) // new orders count (processing status)
const newOrders = await this.prisma.order.count({ const newOrders = await this.prisma.order.count({
where: { status: 'processing' }, where: { status: 'processing' }
}); });
// active users count // active users count
@ -40,7 +40,7 @@ export class AdminService {
revenue, revenue,
newOrders, newOrders,
users, users,
todayVisits, todayVisits
}; };
} }
@ -55,7 +55,7 @@ export class AdminService {
{ firstName: { contains: query.search, mode: 'insensitive' } }, { firstName: { contains: query.search, mode: 'insensitive' } },
{ lastName: { contains: query.search, mode: 'insensitive' } }, { lastName: { contains: query.search, mode: 'insensitive' } },
{ email: { contains: query.search, mode: 'insensitive' } }, { email: { contains: query.search, mode: 'insensitive' } },
{ mobile: { contains: query.search } }, { mobile: { contains: query.search } }
]; ];
} }
if (query.role) { if (query.role) {
@ -67,25 +67,17 @@ export class AdminService {
} }
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
this.prisma.user.findMany({ this.prisma.user.findMany({ where, skip, take: limit, orderBy: { createdAt: 'desc' } }),
where, this.prisma.user.count({ where })
skip,
take: limit,
orderBy: { createdAt: 'desc' },
}),
this.prisma.user.count({ where }),
]); ]);
return { return { data, meta: { total, page, limit, lastPage: Math.ceil(total / limit) } };
data,
meta: { total, page, limit, lastPage: Math.ceil(total / limit) },
};
} }
async updateUserRole(id: string, role: string) { async updateUserRole(id: string, role: string) {
return this.prisma.user.update({ return this.prisma.user.update({
where: { id }, where: { id },
data: { role }, data: { role }
}); });
} }
@ -99,7 +91,7 @@ export class AdminService {
if (query.search) { if (query.search) {
where.OR = [ where.OR = [
{ name: { contains: query.search, mode: 'insensitive' } }, { name: { contains: query.search, mode: 'insensitive' } },
{ artNo: { contains: query.search } }, { artNo: { contains: query.search } }
]; ];
} }
if (query.categoryId) { if (query.categoryId) {
@ -107,22 +99,16 @@ export class AdminService {
} }
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
this.prisma.product.findMany({ this.prisma.product.findMany({
where, where, skip, take: limit, orderBy: { createdAt: 'desc' },
skip, include: { category: true, symptoms: true }
take: limit,
orderBy: { createdAt: 'desc' },
include: { category: true, symptoms: true },
}), }),
this.prisma.product.count({ where }), this.prisma.product.count({ where })
]); ]);
return { return { data, meta: { total, page, limit, lastPage: Math.ceil(total / limit) } };
data,
meta: { total, page, limit, lastPage: Math.ceil(total / limit) },
};
} catch (error) { } catch (error) {
console.error('[AdminService] getProducts error:', error); console.error("[AdminService] getProducts error:", error);
throw new HttpException(error.message || 'Error fetching products', 500); throw new HttpException(error.message || 'Error fetching products', 500);
} }
} }
@ -150,17 +136,15 @@ export class AdminService {
keywords: data.keywords, keywords: data.keywords,
canonicalUrl: data.canonicalUrl, canonicalUrl: data.canonicalUrl,
slug: data.slug || data.artNo, slug: data.slug || data.artNo,
}, }
}); });
if (data.symptoms && Array.isArray(data.symptoms)) { if (data.symptoms && Array.isArray(data.symptoms)) {
await this.prisma.productSymptom.createMany({ await this.prisma.productSymptom.createMany({
data: data.symptoms data: data.symptoms.map((s: string) => ({
.map((s: string) => ({ productId: product.id,
productId: product.id, symptom: s.trim()
symptom: s.trim(), })).filter((s: any) => s.symptom.length > 0)
}))
.filter((s: any) => s.symptom.length > 0),
}); });
} }
@ -191,19 +175,17 @@ export class AdminService {
keywords: data.keywords, keywords: data.keywords,
canonicalUrl: data.canonicalUrl, canonicalUrl: data.canonicalUrl,
slug: data.slug || data.artNo, slug: data.slug || data.artNo,
}, }
}); });
if (data.symptoms !== undefined && Array.isArray(data.symptoms)) { if (data.symptoms !== undefined && Array.isArray(data.symptoms)) {
await this.prisma.productSymptom.deleteMany({ where: { productId: id } }); await this.prisma.productSymptom.deleteMany({ where: { productId: id } });
if (data.symptoms.length > 0) { if (data.symptoms.length > 0) {
await this.prisma.productSymptom.createMany({ await this.prisma.productSymptom.createMany({
data: data.symptoms data: data.symptoms.map((s: string) => ({
.map((s: string) => ({ productId: id,
productId: id, symptom: s.trim()
symptom: s.trim(), })).filter((s: any) => s.symptom.length > 0)
}))
.filter((s: any) => s.symptom.length > 0),
}); });
} }
} }
@ -227,11 +209,9 @@ export class AdminService {
where.OR = [ where.OR = [
{ id: { contains: query.search } }, { id: { contains: query.search } },
{ trackingNumber: { contains: query.search, mode: 'insensitive' } }, { trackingNumber: { contains: query.search, mode: 'insensitive' } },
{ { user: { firstName: { contains: query.search, mode: 'insensitive' } } },
user: { firstName: { contains: query.search, mode: 'insensitive' } },
},
{ user: { lastName: { contains: query.search, mode: 'insensitive' } } }, { user: { lastName: { contains: query.search, mode: 'insensitive' } } },
{ user: { phone: { contains: query.search } } }, { user: { phone: { contains: query.search } } }
]; ];
} }
if (query.status) { if (query.status) {
@ -239,28 +219,25 @@ export class AdminService {
} }
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
this.prisma.order.findMany({ this.prisma.order.findMany({
where, where,
skip, skip,
take: limit, take: limit,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { include: {
user: true, user: true,
orderItems: { orderItems: {
include: { include: {
product: true, product: true
}, }
}, },
coupon: true, coupon: true
}, }
}), }),
this.prisma.order.count({ where }), this.prisma.order.count({ where })
]); ]);
return { return { data, meta: { total, page, limit, lastPage: Math.ceil(total / limit) } };
data,
meta: { total, page, limit, lastPage: Math.ceil(total / limit) },
};
} }
async updateOrderStatus(id: string, status: string, trackingNumber?: string) { async updateOrderStatus(id: string, status: string, trackingNumber?: string) {
@ -275,10 +252,10 @@ export class AdminService {
user: true, user: true,
orderItems: { orderItems: {
include: { include: {
product: true, product: true
}, }
}, }
}, }
}); });
} }
@ -286,30 +263,23 @@ export class AdminService {
async getCoupons(query: any) { async getCoupons(query: any) {
const { page = 1, limit = 10, search = '' } = query; const { page = 1, limit = 10, search = '' } = query;
const skip = (Number(page) - 1) * Number(limit); const skip = (Number(page) - 1) * Number(limit);
const where = search const where = search ? { code: { contains: search, mode: 'insensitive' as any } } : {};
? { code: { contains: search, mode: 'insensitive' as any } }
: {};
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
this.prisma.coupon.findMany({ this.prisma.coupon.findMany({
where, where,
skip, skip,
take: Number(limit), take: Number(limit),
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { targets: true }, // Include polymorphic targets include: { targets: true } // Include polymorphic targets
}), }),
this.prisma.coupon.count({ where }), this.prisma.coupon.count({ where })
]); ]);
return { return {
data, data,
meta: { meta: { total, page: Number(page), limit: Number(limit), lastPage: Math.ceil(total / Number(limit)) }
total,
page: Number(page),
limit: Number(limit),
lastPage: Math.ceil(total / Number(limit)),
},
}; };
} }
@ -324,19 +294,16 @@ export class AdminService {
maxUses: data.maxUses || null, maxUses: data.maxUses || null,
expiresAt: data.expiresAt ? new Date(data.expiresAt) : null, expiresAt: data.expiresAt ? new Date(data.expiresAt) : null,
isActive: data.isActive !== undefined ? data.isActive : true, isActive: data.isActive !== undefined ? data.isActive : true,
targets: targets: data.targets && data.targets.length > 0 ? {
data.targets && data.targets.length > 0 create: data.targets.map((t: any) => ({
? { targetType: t.targetType,
create: data.targets.map((t: any) => ({ targetId: t.targetId,
targetType: t.targetType, modifierType: t.modifierType || 'override',
targetId: t.targetId, modifierValue: t.modifierValue || null
modifierType: t.modifierType || 'override', }))
modifierValue: t.modifierValue || null, } : undefined
})),
}
: undefined,
}, },
include: { targets: true }, include: { targets: true }
}); });
} }
@ -355,52 +322,41 @@ export class AdminService {
maxUses: data.maxUses, maxUses: data.maxUses,
expiresAt: data.expiresAt ? new Date(data.expiresAt) : null, expiresAt: data.expiresAt ? new Date(data.expiresAt) : null,
isActive: data.isActive, isActive: data.isActive,
targets: targets: data.targets && data.targets.length > 0 ? {
data.targets && data.targets.length > 0 create: data.targets.map((t: any) => ({
? { targetType: t.targetType,
create: data.targets.map((t: any) => ({ targetId: t.targetId,
targetType: t.targetType, modifierType: t.modifierType || 'override',
targetId: t.targetId, modifierValue: t.modifierValue || null
modifierType: t.modifierType || 'override', }))
modifierValue: t.modifierValue || null, } : undefined
})),
}
: undefined,
}, },
include: { targets: true }, include: { targets: true }
}); });
} }
async toggleCoupon(id: string, isActive: boolean) { async toggleCoupon(id: string, isActive: boolean) {
return this.prisma.coupon.update({ return this.prisma.coupon.update({
where: { id }, where: { id },
data: { isActive }, data: { isActive }
}); });
} }
async deleteCoupon(id: string) { async deleteCoupon(id: string) {
return this.prisma.coupon.delete({ return this.prisma.coupon.delete({
where: { id }, where: { id }
}); });
} }
// --- Settings --- // --- Settings ---
async getSettings() { async getSettings() {
const keys = [ const keys = ['SHIPPING_FEE', 'MIN_ORDER_AMOUNT', 'B2B_DISCOUNT_PERCENT', 'MAINTENANCE_MODE'];
'SHIPPING_FEE',
'MIN_ORDER_AMOUNT',
'B2B_DISCOUNT_PERCENT',
'MAINTENANCE_MODE',
];
const settings = await this.prisma.uiText.findMany({ const settings = await this.prisma.uiText.findMany({
where: { key: { in: keys } }, where: { key: { in: keys } }
}); });
// Transform to an object { SHIPPING_FEE: '50000', ... } // Transform to an object { SHIPPING_FEE: '50000', ... }
return settings.reduce( return settings.reduce((acc, curr) => ({ ...acc, [curr.key]: curr.value }), {});
(acc, curr) => ({ ...acc, [curr.key]: curr.value }),
{},
);
} }
async updateSettings(data: Record<string, string>) { async updateSettings(data: Record<string, string>) {
@ -409,10 +365,10 @@ export class AdminService {
return this.prisma.uiText.upsert({ return this.prisma.uiText.upsert({
where: { key }, where: { key },
update: { value: String(value) }, update: { value: String(value) },
create: { key, value: String(value) }, create: { key, value: String(value) }
}); });
}); });
await this.prisma.$transaction(operations); await this.prisma.$transaction(operations);
return this.getSettings(); return this.getSettings();
} }

View File

@ -1,23 +1,7 @@
import { import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request } from '@nestjs/common';
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { BlogsService } from './blogs.service'; import { BlogsService } from './blogs.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiQuery,
} from '@nestjs/swagger';
@ApiTags('Admin - مدیریت مقالات (بلاگ)') @ApiTags('Admin - مدیریت مقالات (بلاگ)')
@ApiBearerAuth() @ApiBearerAuth()

View File

@ -8,36 +8,29 @@ export class BlogsService {
async getBlogs(query: any) { async getBlogs(query: any) {
const { page = 1, limit = 10, search = '' } = query; const { page = 1, limit = 10, search = '' } = query;
const skip = (Number(page) - 1) * Number(limit); const skip = (Number(page) - 1) * Number(limit);
const where = search const where = search ? { title: { contains: search, mode: 'insensitive' as any } } : {};
? { title: { contains: search, mode: 'insensitive' as any } }
: {};
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
this.prisma.blog.findMany({ this.prisma.blog.findMany({
where, where,
skip, skip,
take: Number(limit), take: Number(limit),
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { author: { select: { firstName: true, lastName: true } } }, include: { author: { select: { firstName: true, lastName: true } } }
}), }),
this.prisma.blog.count({ where }), this.prisma.blog.count({ where })
]); ]);
return { return {
data, data,
meta: { meta: { total, page: Number(page), limit: Number(limit), lastPage: Math.ceil(total / Number(limit)) }
total,
page: Number(page),
limit: Number(limit),
lastPage: Math.ceil(total / Number(limit)),
},
}; };
} }
async createBlog(data: any, authorId: string) { async createBlog(data: any, authorId: string) {
return this.prisma.blog.create({ return this.prisma.blog.create({
data: { ...data, authorId }, data: { ...data, authorId }
}); });
} }

View File

@ -1,22 +1,7 @@
import { import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { CategoriesService } from './categories.service'; import { CategoriesService } from './categories.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiQuery,
} from '@nestjs/swagger';
@ApiTags('Admin - مدیریت دسته‌بندی‌ها') @ApiTags('Admin - مدیریت دسته‌بندی‌ها')
@ApiBearerAuth() @ApiBearerAuth()

View File

@ -8,29 +8,22 @@ export class CategoriesService {
async getCategories(query: any) { async getCategories(query: any) {
const { page = 1, limit = 10, search = '' } = query; const { page = 1, limit = 10, search = '' } = query;
const skip = (Number(page) - 1) * Number(limit); const skip = (Number(page) - 1) * Number(limit);
const where = search const where = search ? { name: { contains: search, mode: 'insensitive' as any } } : {};
? { name: { contains: search, mode: 'insensitive' as any } }
: {};
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
this.prisma.category.findMany({ this.prisma.category.findMany({
where, where,
skip, skip,
take: Number(limit), take: Number(limit),
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' }
}), }),
this.prisma.category.count({ where }), this.prisma.category.count({ where })
]); ]);
return { return {
data, data,
meta: { meta: { total, page: Number(page), limit: Number(limit), lastPage: Math.ceil(total / Number(limit)) }
total,
page: Number(page),
limit: Number(limit),
lastPage: Math.ceil(total / Number(limit)),
},
}; };
} }
@ -40,22 +33,21 @@ export class CategoriesService {
async createCategory(data: any) { async createCategory(data: any) {
const cleanData = { ...data }; const cleanData = { ...data };
Object.keys(cleanData).forEach((k) => { Object.keys(cleanData).forEach(k => {
if (cleanData[k] === '') cleanData[k] = null; if (cleanData[k] === '') cleanData[k] = null;
}); });
// Ensure required fields // Ensure required fields
if (!cleanData.slug) if (!cleanData.slug) cleanData.slug = cleanData.name.replace(/\s+/g, '-').toLowerCase();
cleanData.slug = cleanData.name.replace(/\s+/g, '-').toLowerCase();
return this.prisma.category.create({ data: cleanData }); return this.prisma.category.create({ data: cleanData });
} }
async updateCategory(id: string, data: any) { async updateCategory(id: string, data: any) {
const category = await this.prisma.category.findUnique({ where: { id } }); const category = await this.prisma.category.findUnique({ where: { id } });
if (!category) throw new NotFoundException('Category not found'); if (!category) throw new NotFoundException('Category not found');
const cleanData = { ...data }; const cleanData = { ...data };
Object.keys(cleanData).forEach((k) => { Object.keys(cleanData).forEach(k => {
if (cleanData[k] === '') cleanData[k] = null; if (cleanData[k] === '') cleanData[k] = null;
}); });

View File

@ -1,14 +1,4 @@
import { import { Controller, Get, Post, Delete, Param, UseGuards, UseInterceptors, UploadedFile, BadRequestException } from '@nestjs/common';
Controller,
Get,
Post,
Delete,
Param,
UseGuards,
UseInterceptors,
UploadedFile,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { MediaService } from './media.service'; import { MediaService } from './media.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';

View File

@ -9,7 +9,7 @@ export class MediaService {
async getAllMedia() { async getAllMedia() {
return this.prisma.media.findMany({ return this.prisma.media.findMany({
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' }
}); });
} }
@ -39,7 +39,7 @@ export class MediaService {
url, url,
mimetype: file.mimetype, mimetype: file.mimetype,
size: file.size, size: file.size,
}, }
}); });
return media; return media;
@ -49,11 +49,7 @@ export class MediaService {
const media = await this.prisma.media.findUnique({ where: { id } }); const media = await this.prisma.media.findUnique({ where: { id } });
if (!media) throw new BadRequestException('Media not found'); if (!media) throw new BadRequestException('Media not found');
const filePath = path.join( const filePath = path.join(process.cwd(), 'uploads', path.basename(media.url));
process.cwd(),
'uploads',
path.basename(media.url),
);
if (fs.existsSync(filePath)) { if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath); fs.unlinkSync(filePath);
} }

View File

@ -1,19 +1,7 @@
import { import { Controller, Get, Delete, Param, Query, UseGuards } from '@nestjs/common';
Controller,
Get,
Delete,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { PetsService } from './pets.service'; import { PetsService } from './pets.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiQuery,
} from '@nestjs/swagger';
@ApiTags('Admin - مدیریت حیوانات خانگی') @ApiTags('Admin - مدیریت حیوانات خانگی')
@ApiBearerAuth() @ApiBearerAuth()

View File

@ -8,39 +8,23 @@ export class PetsService {
async getPets(query: any) { async getPets(query: any) {
const { page = 1, limit = 10, search = '' } = query; const { page = 1, limit = 10, search = '' } = query;
const skip = (Number(page) - 1) * Number(limit); const skip = (Number(page) - 1) * Number(limit);
const where = search const where = search ? { name: { contains: search, mode: 'insensitive' as any } } : {};
? { name: { contains: search, mode: 'insensitive' as any } }
: {};
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
this.prisma.pet.findMany({ this.prisma.pet.findMany({
where, where,
skip, skip,
take: Number(limit), take: Number(limit),
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { include: { user: { select: { firstName: true, lastName: true, email: true, mobile: true } } }
user: {
select: {
firstName: true,
lastName: true,
email: true,
mobile: true,
},
},
},
}), }),
this.prisma.pet.count({ where }), this.prisma.pet.count({ where })
]); ]);
return { return {
data, data,
meta: { meta: { total, page: Number(page), limit: Number(limit), lastPage: Math.ceil(total / Number(limit)) }
total,
page: Number(page),
limit: Number(limit),
lastPage: Math.ceil(total / Number(limit)),
},
}; };
} }

View File

@ -9,12 +9,7 @@ export class ReportsService {
// 1. Total revenue and charity // 1. Total revenue and charity
const orders = await this.prisma.order.findMany({ const orders = await this.prisma.order.findMany({
where: { status: { not: 'cancelled' } }, where: { status: { not: 'cancelled' } },
select: { select: { totalAmount: true, charityDonation: true, createdAt: true, user: { select: { role: true } } }
totalAmount: true,
charityDonation: true,
createdAt: true,
user: { select: { role: true } },
},
}); });
let totalRevenue = 0; let totalRevenue = 0;
@ -28,7 +23,7 @@ export class ReportsService {
const amount = Number(order.totalAmount); const amount = Number(order.totalAmount);
totalRevenue += amount; totalRevenue += amount;
totalCharity += Number(order.charityDonation); totalCharity += Number(order.charityDonation);
if (order.user?.role === 'B2B') { if (order.user?.role === 'B2B') {
b2bRevenue += amount; b2bRevenue += amount;
} else { } else {
@ -50,28 +45,23 @@ export class ReportsService {
const orderItems = await this.prisma.orderItem.groupBy({ const orderItems = await this.prisma.orderItem.groupBy({
by: ['productId'], by: ['productId'],
_sum: { quantity: true }, _sum: { quantity: true },
where: { where: { order: { status: { not: 'cancelled' } }, productId: { not: null } },
order: { status: { not: 'cancelled' } },
productId: { not: null },
},
orderBy: { _sum: { quantity: 'desc' } }, orderBy: { _sum: { quantity: 'desc' } },
take: 5, take: 5
}); });
// Fetch product names for top 5 // Fetch product names for top 5
const topProductsIds = orderItems const topProductsIds = orderItems.map(item => item.productId).filter(Boolean) as string[];
.map((item) => item.productId)
.filter(Boolean) as string[];
const productsInfo = await this.prisma.product.findMany({ const productsInfo = await this.prisma.product.findMany({
where: { id: { in: topProductsIds } }, where: { id: { in: topProductsIds } },
select: { id: true, nameFa: true, nameEn: true }, select: { id: true, nameFa: true, nameEn: true }
}); });
const bestSellers = orderItems.map((item) => { const bestSellers = orderItems.map(item => {
const p = productsInfo.find((prod) => prod.id === item.productId); const p = productsInfo.find(prod => prod.id === item.productId);
return { return {
name: p ? `${p.nameFa} (${p.nameEn})` : 'محصول نامشخص', name: p ? `${p.nameFa} (${p.nameEn})` : 'محصول نامشخص',
quantity: item._sum.quantity || 0, quantity: item._sum.quantity || 0
}; };
}); });
@ -82,24 +72,18 @@ export class ReportsService {
// Since we didn't fetch category id in the grouping, we'll approximate with full product list query // Since we didn't fetch category id in the grouping, we'll approximate with full product list query
} }
} }
const categories = await this.prisma.category.findMany({ const categories = await this.prisma.category.findMany({ include: { products: { select: { id: true } } } });
include: { products: { select: { id: true } } }, const catSalesDist = categories.map(cat => {
}); const pIds = cat.products.map(p => p.id);
const catSalesDist = categories const catSales = orderItems.filter(oi => oi.productId && pIds.includes(oi.productId)).reduce((sum, oi) => sum + (oi._sum.quantity || 0), 0);
.map((cat) => { return { name: cat.name, value: catSales };
const pIds = cat.products.map((p) => p.id); }).filter(c => c.value > 0);
const catSales = orderItems
.filter((oi) => oi.productId && pIds.includes(oi.productId))
.reduce((sum, oi) => sum + (oi._sum.quantity || 0), 0);
return { name: cat.name, value: catSales };
})
.filter((c) => c.value > 0);
// 4. Coupons Usage // 4. Coupons Usage
const topCoupons = await this.prisma.coupon.findMany({ const topCoupons = await this.prisma.coupon.findMany({
orderBy: { usedCount: 'desc' }, orderBy: { usedCount: 'desc' },
take: 5, take: 5,
select: { code: true, usedCount: true }, select: { code: true, usedCount: true }
}); });
return { return {
@ -113,7 +97,7 @@ export class ReportsService {
salesTimeline, salesTimeline,
bestSellers, bestSellers,
categoryDistribution: catSalesDist, categoryDistribution: catSalesDist,
topCoupons: topCoupons.map((c) => ({ name: c.code, value: c.usedCount })), topCoupons: topCoupons.map(c => ({ name: c.code, value: c.usedCount }))
}; };
} }
} }

View File

@ -1,22 +1,7 @@
import { import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { WikiService } from './wiki.service'; import { WikiService } from './wiki.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiQuery,
} from '@nestjs/swagger';
@ApiTags('Admin - مدیریت دانشنامه (اصطلاحات علمی)') @ApiTags('Admin - مدیریت دانشنامه (اصطلاحات علمی)')
@ApiBearerAuth() @ApiBearerAuth()

View File

@ -8,29 +8,22 @@ export class WikiService {
async getTerms(query: any) { async getTerms(query: any) {
const { page = 1, limit = 10, search = '' } = query; const { page = 1, limit = 10, search = '' } = query;
const skip = (Number(page) - 1) * Number(limit); const skip = (Number(page) - 1) * Number(limit);
const where = search const where = search ? { term: { contains: search, mode: 'insensitive' as any } } : {};
? { term: { contains: search, mode: 'insensitive' as any } }
: {};
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
this.prisma.scientificTerm.findMany({ this.prisma.scientificTerm.findMany({
where, where,
skip, skip,
take: Number(limit), take: Number(limit),
orderBy: { term: 'asc' }, orderBy: { term: 'asc' }
}), }),
this.prisma.scientificTerm.count({ where }), this.prisma.scientificTerm.count({ where })
]); ]);
return { return {
data, data,
meta: { meta: { total, page: Number(page), limit: Number(limit), lastPage: Math.ceil(total / Number(limit)) }
total,
page: Number(page),
limit: Number(limit),
lastPage: Math.ceil(total / Number(limit)),
},
}; };
} }
@ -39,17 +32,13 @@ export class WikiService {
} }
async updateTerm(key: string, data: any) { async updateTerm(key: string, data: any) {
const term = await this.prisma.scientificTerm.findUnique({ const term = await this.prisma.scientificTerm.findUnique({ where: { key } });
where: { key },
});
if (!term) throw new NotFoundException('Scientific Term not found'); if (!term) throw new NotFoundException('Scientific Term not found');
return this.prisma.scientificTerm.update({ where: { key }, data }); return this.prisma.scientificTerm.update({ where: { key }, data });
} }
async deleteTerm(key: string) { async deleteTerm(key: string) {
const term = await this.prisma.scientificTerm.findUnique({ const term = await this.prisma.scientificTerm.findUnique({ where: { key } });
where: { key },
});
if (!term) throw new NotFoundException('Scientific Term not found'); if (!term) throw new NotFoundException('Scientific Term not found');
return this.prisma.scientificTerm.delete({ where: { key } }); return this.prisma.scientificTerm.delete({ where: { key } });
} }

View File

@ -30,12 +30,10 @@ import { VideosModule } from './videos/videos.module';
PetsModule, PetsModule,
OrdersModule, OrdersModule,
SettingsModule, SettingsModule,
ThrottlerModule.forRoot([ ThrottlerModule.forRoot([{
{ ttl: 60000,
ttl: 60000, limit: 100,
limit: 100, }]),
},
]),
AdminModule, AdminModule,
HomeModule, HomeModule,
BlogsModule, BlogsModule,

View File

@ -7,18 +7,16 @@ describe('AuthController', () => {
let service: AuthService; let service: AuthService;
const mockAuthService = { const mockAuthService = {
sendOtp: jest sendOtp: jest.fn().mockResolvedValue({ success: true, message: 'کد تایید ارسال شد' }),
.fn() verifyOtp: jest.fn().mockResolvedValue({ success: true, data: { accessToken: 'token' } }),
.mockResolvedValue({ success: true, message: 'کد تایید ارسال شد' }),
verifyOtp: jest
.fn()
.mockResolvedValue({ success: true, data: { accessToken: 'token' } }),
}; };
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController], controllers: [AuthController],
providers: [{ provide: AuthService, useValue: mockAuthService }], providers: [
{ provide: AuthService, useValue: mockAuthService },
],
}).compile(); }).compile();
controller = module.get<AuthController>(AuthController); controller = module.get<AuthController>(AuthController);

View File

@ -4,13 +4,7 @@ import { SendOtpDto } from './dto/send-otp.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto'; import { VerifyOtpDto } from './dto/verify-otp.dto';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse, ApiBadRequestResponse } from '@nestjs/swagger';
ApiTags,
ApiOperation,
ApiResponse,
ApiOkResponse,
ApiBadRequestResponse,
} from '@nestjs/swagger';
@ApiTags('Auth - احراز هویت') @ApiTags('Auth - احراز هویت')
@Controller('auth') @Controller('auth')
@ -22,9 +16,9 @@ import {
success: false, success: false,
message: 'خطای ناشناخته در سرور رخ داده است', message: 'خطای ناشناخته در سرور رخ داده است',
code: 'SERVER_ERROR', code: 'SERVER_ERROR',
details: {}, details: {}
}, }
}, }
}) })
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
@ -38,9 +32,9 @@ export class AuthController {
example: { example: {
success: true, success: true,
message: 'کد تایید ارسال شد', message: 'کد تایید ارسال شد',
code: '12345', code: '12345'
}, }
}, }
}) })
@ApiBadRequestResponse({ @ApiBadRequestResponse({
description: 'فرمت شماره تلفن همراه نامعتبر است', description: 'فرمت شماره تلفن همراه نامعتبر است',
@ -50,10 +44,10 @@ export class AuthController {
message: 'شماره موبایل نامعتبر است', message: 'شماره موبایل نامعتبر است',
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
details: { details: {
message: ['شماره موبایل نامعتبر است'], message: ['شماره موبایل نامعتبر است']
}, }
}, }
}, }
}) })
sendOtp(@Body() sendOtpDto: SendOtpDto) { sendOtp(@Body() sendOtpDto: SendOtpDto) {
return this.authService.sendOtp(sendOtpDto); return this.authService.sendOtp(sendOtpDto);
@ -78,12 +72,12 @@ export class AuthController {
walletBalance: '0.00', walletBalance: '0.00',
charityDonationTotal: '0.00', charityDonationTotal: '0.00',
createdAt: '2026-05-26T15:20:00.000Z', createdAt: '2026-05-26T15:20:00.000Z',
updatedAt: '2026-05-26T15:20:00.000Z', updatedAt: '2026-05-26T15:20:00.000Z'
}, },
accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
}, }
}, }
}, }
}) })
@ApiBadRequestResponse({ @ApiBadRequestResponse({
description: 'کد تایید اشتباه یا منقضی شده است', description: 'کد تایید اشتباه یا منقضی شده است',
@ -92,9 +86,9 @@ export class AuthController {
success: false, success: false,
message: 'کد تایید اشتباه است', message: 'کد تایید اشتباه است',
code: 'OTP_INVALID', code: 'OTP_INVALID',
details: {}, details: {}
}, }
}, }
}) })
verifyOtp(@Body() verifyOtpDto: VerifyOtpDto) { verifyOtp(@Body() verifyOtpDto: VerifyOtpDto) {
return this.authService.verifyOtp(verifyOtpDto); return this.authService.verifyOtp(verifyOtpDto);
@ -104,9 +98,7 @@ export class AuthController {
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'ثبت‌نام با ایمیل/موبایل و رمز عبور' }) @ApiOperation({ summary: 'ثبت‌نام با ایمیل/موبایل و رمز عبور' })
@ApiOkResponse({ description: 'کاربر با موفقیت ثبت‌نام شد' }) @ApiOkResponse({ description: 'کاربر با موفقیت ثبت‌نام شد' })
@ApiBadRequestResponse({ @ApiBadRequestResponse({ description: 'اطلاعات ثبت‌نام نامعتبر است یا کاربر از قبل وجود دارد' })
description: 'اطلاعات ثبت‌نام نامعتبر است یا کاربر از قبل وجود دارد',
})
register(@Body() registerDto: RegisterDto) { register(@Body() registerDto: RegisterDto) {
return this.authService.register(registerDto); return this.authService.register(registerDto);
} }

View File

@ -88,19 +88,11 @@ describe('AuthService', () => {
const mockUser = { id: 'user-id', mobile: '09123456789' }; const mockUser = { id: 'user-id', mobile: '09123456789' };
mockPrisma.user.findUnique.mockResolvedValue(mockUser); mockPrisma.user.findUnique.mockResolvedValue(mockUser);
const result = await service.verifyOtp({ const result = await service.verifyOtp({ phoneNumber: '09123456789', code: '12345' });
phoneNumber: '09123456789',
code: '12345',
});
expect(redis.del).toHaveBeenCalledWith('otp:09123456789'); expect(redis.del).toHaveBeenCalledWith('otp:09123456789');
expect(prisma.user.findUnique).toHaveBeenCalledWith({ expect(prisma.user.findUnique).toHaveBeenCalledWith({ where: { mobile: '09123456789' } });
where: { mobile: '09123456789' }, expect(jwt.sign).toHaveBeenCalledWith({ sub: 'user-id', phoneNumber: '09123456789' });
});
expect(jwt.sign).toHaveBeenCalledWith({
sub: 'user-id',
phoneNumber: '09123456789',
});
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.data.accessToken).toBe('mock-jwt-token'); expect(result.data.accessToken).toBe('mock-jwt-token');
expect(result.data.user).toEqual(mockUser); expect(result.data.user).toEqual(mockUser);
@ -112,10 +104,7 @@ describe('AuthService', () => {
const newUser = { id: 'new-user-id', mobile: '09123456789' }; const newUser = { id: 'new-user-id', mobile: '09123456789' };
mockPrisma.user.create.mockResolvedValue(newUser); mockPrisma.user.create.mockResolvedValue(newUser);
const result = await service.verifyOtp({ const result = await service.verifyOtp({ phoneNumber: '09123456789', code: '12345' });
phoneNumber: '09123456789',
code: '12345',
});
expect(prisma.user.create).toHaveBeenCalled(); expect(prisma.user.create).toHaveBeenCalled();
expect(result.data.user).toEqual(newUser); expect(result.data.user).toEqual(newUser);
}); });

View File

@ -33,28 +33,20 @@ export class AuthService {
const savedCode = await this.redisService.get(`otp:${phoneNumber}`); const savedCode = await this.redisService.get(`otp:${phoneNumber}`);
if (!savedCode) { if (!savedCode) {
throw new BadRequestException({ throw new BadRequestException({ message: 'کد تایید منقضی شده است', error: 'OTP_EXPIRED' });
message: 'کد تایید منقضی شده است',
error: 'OTP_EXPIRED',
});
} }
if (savedCode !== code) { if (savedCode !== code) {
throw new BadRequestException({ throw new BadRequestException({ message: 'کد تایید اشتباه است', error: 'OTP_INVALID' });
message: 'کد تایید اشتباه است',
error: 'OTP_INVALID',
});
} }
await this.redisService.del(`otp:${phoneNumber}`); await this.redisService.del(`otp:${phoneNumber}`);
let user = await this.prisma.user.findUnique({ let user = await this.prisma.user.findUnique({ where: { mobile: phoneNumber } });
where: { mobile: phoneNumber },
});
if (!user) { if (!user) {
user = await this.prisma.user.create({ user = await this.prisma.user.create({
data: { data: {
mobile: phoneNumber, mobile: phoneNumber,
firstName: 'کاربر', firstName: 'کاربر',
lastName: 'جدید', lastName: 'جدید',
@ -78,25 +70,15 @@ export class AuthService {
async register(registerDto: any) { async register(registerDto: any) {
const { firstName, lastName, email, mobile, password } = registerDto; const { firstName, lastName, email, mobile, password } = registerDto;
const existingUser = await this.prisma.user.findUnique({ const existingUser = await this.prisma.user.findUnique({ where: { mobile } });
where: { mobile },
});
if (existingUser) { if (existingUser) {
throw new BadRequestException({ throw new BadRequestException({ message: 'کاربری با این شماره موبایل قبلا ثبت نام کرده است', error: 'MOBILE_EXISTS' });
message: 'کاربری با این شماره موبایل قبلا ثبت نام کرده است',
error: 'MOBILE_EXISTS',
});
} }
if (email) { if (email) {
const existingEmail = await this.prisma.user.findUnique({ const existingEmail = await this.prisma.user.findUnique({ where: { email } });
where: { email },
});
if (existingEmail) { if (existingEmail) {
throw new BadRequestException({ throw new BadRequestException({ message: 'کاربری با این ایمیل قبلا ثبت نام کرده است', error: 'EMAIL_EXISTS' });
message: 'کاربری با این ایمیل قبلا ثبت نام کرده است',
error: 'EMAIL_EXISTS',
});
} }
} }
@ -129,25 +111,16 @@ export class AuthService {
const user = await this.prisma.user.findUnique({ where: { mobile } }); const user = await this.prisma.user.findUnique({ where: { mobile } });
if (!user) { if (!user) {
throw new BadRequestException({ throw new BadRequestException({ message: 'نام کاربری یا رمز عبور اشتباه است', error: 'INVALID_CREDENTIALS' });
message: 'نام کاربری یا رمز عبور اشتباه است',
error: 'INVALID_CREDENTIALS',
});
} }
if (!user.password) { if (!user.password) {
throw new BadRequestException({ throw new BadRequestException({ message: 'شما با رمز عبور ثبت نام نکرده‌اید. لطفاً با موبایل وارد شوید', error: 'NO_PASSWORD' });
message: 'شما با رمز عبور ثبت نام نکرده‌اید. لطفاً با موبایل وارد شوید',
error: 'NO_PASSWORD',
});
} }
const isMatch = await bcrypt.compare(password, user.password); const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) { if (!isMatch) {
throw new BadRequestException({ throw new BadRequestException({ message: 'نام کاربری یا رمز عبور اشتباه است', error: 'INVALID_CREDENTIALS' });
message: 'نام کاربری یا رمز عبور اشتباه است',
error: 'INVALID_CREDENTIALS',
});
} }
const payload = { sub: user.id, phoneNumber: user.mobile }; const payload = { sub: user.id, phoneNumber: user.mobile };
@ -163,30 +136,16 @@ export class AuthService {
} }
async adminLogin(body: any) { async adminLogin(body: any) {
if ( if (body.email === 'admin@canino-iran.com' && body.password === 'admin123') {
body.email === 'admin@canino-iran.com' && const payload = { sub: '12345678-1234-1234-1234-123456789012', email: body.email, role: 'Admin' };
body.password === 'admin123'
) {
const payload = {
sub: '12345678-1234-1234-1234-123456789012',
email: body.email,
role: 'Admin',
};
return { return {
success: true, success: true,
data: { data: {
user: { user: { id: '12345678-1234-1234-1234-123456789012', email: body.email, role: 'Admin' },
id: '12345678-1234-1234-1234-123456789012',
email: body.email,
role: 'Admin',
},
accessToken: this.jwtService.sign(payload), accessToken: this.jwtService.sign(payload),
}, },
}; };
} }
throw new BadRequestException({ throw new BadRequestException({ message: 'ایمیل یا رمز عبور اشتباه است', error: 'INVALID_CREDENTIALS' });
message: 'ایمیل یا رمز عبور اشتباه است',
error: 'INVALID_CREDENTIALS',
});
} }
} }

View File

@ -1,10 +1,4 @@
import { import { IsEmail, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
MinLength,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class RegisterDto { export class RegisterDto {
@ -18,10 +12,7 @@ export class RegisterDto {
@IsString() @IsString()
lastName: string; lastName: string;
@ApiPropertyOptional({ @ApiPropertyOptional({ description: 'ایمیل (اختیاری)', example: 'ali@example.com' })
description: 'ایمیل (اختیاری)',
example: 'ali@example.com',
})
@IsOptional() @IsOptional()
@IsEmail({}, { message: 'ایمیل نامعتبر است' }) @IsEmail({}, { message: 'ایمیل نامعتبر است' })
email?: string; email?: string;

View File

@ -5,9 +5,7 @@ import { AuthGuard } from '@nestjs/passport';
export class JwtAuthGuard extends AuthGuard('jwt') { export class JwtAuthGuard extends AuthGuard('jwt') {
handleRequest(err: any, user: any, info: any) { handleRequest(err: any, user: any, info: any) {
if (err || !user) { if (err || !user) {
throw ( throw err || new UnauthorizedException('لطفا ابتدا وارد حساب کاربری خود شوید');
err || new UnauthorizedException('لطفا ابتدا وارد حساب کاربری خود شوید')
);
} }
return user; return user;
} }

View File

@ -1,9 +1,4 @@
import { import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from './roles.decorator'; import { ROLES_KEY } from './roles.decorator';
@ -12,11 +7,11 @@ export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {} constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>( const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
ROLES_KEY, context.getHandler(),
[context.getHandler(), context.getClass()], context.getClass(),
); ]);
if (!requiredRoles || requiredRoles.length === 0) { if (!requiredRoles || requiredRoles.length === 0) {
return true; return true;
} }

View File

@ -1,20 +1,13 @@
import { Controller, Get, Param, HttpStatus, Query } from '@nestjs/common'; import { Controller, Get, Param, HttpStatus, Query } from '@nestjs/common';
import { BlogsService } from './blogs.service'; import { BlogsService } from './blogs.service';
import { import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse, ApiNotFoundResponse, ApiQuery } from '@nestjs/swagger';
ApiTags,
ApiOperation,
ApiResponse,
ApiOkResponse,
ApiNotFoundResponse,
ApiQuery,
} from '@nestjs/swagger';
import { PaginationDto } from '../common/dto/pagination.dto'; import { PaginationDto } from '../common/dto/pagination.dto';
@ApiTags('Blogs - مجله سلامت') @ApiTags('Blogs - مجله سلامت')
@Controller('blogs') @Controller('blogs')
@ApiResponse({ @ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR, status: HttpStatus.INTERNAL_SERVER_ERROR,
description: 'خطای داخلی سرور', description: 'خطای داخلی سرور'
}) })
export class BlogsController { export class BlogsController {
constructor(private readonly blogsService: BlogsService) {} constructor(private readonly blogsService: BlogsService) {}

View File

@ -7,14 +7,8 @@ export class BlogsService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
async findAll(filters: PaginationDto) { async findAll(filters: PaginationDto) {
const { const { search, page = 1, limit = 10, sortBy = 'createdAt', sortOrder = 'desc' } = filters;
search,
page = 1,
limit = 10,
sortBy = 'createdAt',
sortOrder = 'desc',
} = filters;
const whereClause: any = { isPublished: true }; const whereClause: any = { isPublished: true };
if (search) { if (search) {
whereClause.OR = [ whereClause.OR = [
@ -33,11 +27,11 @@ export class BlogsService {
orderBy: { [sortBy]: sortOrder }, orderBy: { [sortBy]: sortOrder },
include: { include: {
author: { author: {
select: { firstName: true, lastName: true }, select: { firstName: true, lastName: true }
}, }
}, }
}), }),
this.prisma.blog.count({ where: whereClause }), this.prisma.blog.count({ where: whereClause })
]); ]);
return { return {
@ -47,7 +41,7 @@ export class BlogsService {
page, page,
lastPage: Math.ceil(total / limit), lastPage: Math.ceil(total / limit),
limit, limit,
}, }
}; };
} }
@ -56,15 +50,15 @@ export class BlogsService {
where: { slug, isPublished: true }, where: { slug, isPublished: true },
include: { include: {
author: { author: {
select: { firstName: true, lastName: true }, select: { firstName: true, lastName: true }
}, }
}, }
}); });
if (!blog) { if (!blog) {
throw new NotFoundException('مقاله یافت نشد'); throw new NotFoundException('مقاله یافت نشد');
} }
return blog; return blog;
} }
} }

View File

@ -1,19 +1,6 @@
import { import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
} from '@nestjs/common';
import { CmsService } from './cms.service'; import { CmsService } from './cms.service';
import { import { CreateHeroBannerDto, CreateVetTestimonialDto, CreateSmartAdvisorRuleDto } from './dto/cms.dto';
CreateHeroBannerDto,
CreateVetTestimonialDto,
CreateSmartAdvisorRuleDto,
} from './dto/cms.dto';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard'; import { RolesGuard } from '../auth/roles.guard';
@ -42,10 +29,7 @@ export class CmsController {
@Put('hero-banners/:id') @Put('hero-banners/:id')
@ApiOperation({ summary: 'ویرایش بنر هیرو' }) @ApiOperation({ summary: 'ویرایش بنر هیرو' })
updateHeroBanner( updateHeroBanner(@Param('id') id: string, @Body() dto: Partial<CreateHeroBannerDto>) {
@Param('id') id: string,
@Body() dto: Partial<CreateHeroBannerDto>,
) {
return this.cmsService.updateHeroBanner(id, dto); return this.cmsService.updateHeroBanner(id, dto);
} }
@ -70,10 +54,7 @@ export class CmsController {
@Put('vet-testimonials/:id') @Put('vet-testimonials/:id')
@ApiOperation({ summary: 'ویرایش نظر دامپزشک' }) @ApiOperation({ summary: 'ویرایش نظر دامپزشک' })
updateVetTestimonial( updateVetTestimonial(@Param('id') id: string, @Body() dto: Partial<CreateVetTestimonialDto>) {
@Param('id') id: string,
@Body() dto: Partial<CreateVetTestimonialDto>,
) {
return this.cmsService.updateVetTestimonial(id, dto); return this.cmsService.updateVetTestimonial(id, dto);
} }
@ -98,10 +79,7 @@ export class CmsController {
@Put('smart-advisor-rules/:id') @Put('smart-advisor-rules/:id')
@ApiOperation({ summary: 'ویرایش قانون مشاوره هوشمند' }) @ApiOperation({ summary: 'ویرایش قانون مشاوره هوشمند' })
updateSmartAdvisorRule( updateSmartAdvisorRule(@Param('id') id: string, @Body() dto: Partial<CreateSmartAdvisorRuleDto>) {
@Param('id') id: string,
@Body() dto: Partial<CreateSmartAdvisorRuleDto>,
) {
return this.cmsService.updateSmartAdvisorRule(id, dto); return this.cmsService.updateSmartAdvisorRule(id, dto);
} }

View File

@ -1,10 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { import { CreateHeroBannerDto, CreateVetTestimonialDto, CreateSmartAdvisorRuleDto } from './dto/cms.dto';
CreateHeroBannerDto,
CreateVetTestimonialDto,
CreateSmartAdvisorRuleDto,
} from './dto/cms.dto';
@Injectable() @Injectable()
export class CmsService { export class CmsService {
@ -42,13 +38,8 @@ export class CmsService {
return this.prisma.vetTestimonial.create({ data: dto }); return this.prisma.vetTestimonial.create({ data: dto });
} }
async updateVetTestimonial( async updateVetTestimonial(id: string, dto: Partial<CreateVetTestimonialDto>) {
id: string, const exists = await this.prisma.vetTestimonial.findUnique({ where: { id } });
dto: Partial<CreateVetTestimonialDto>,
) {
const exists = await this.prisma.vetTestimonial.findUnique({
where: { id },
});
if (!exists) throw new NotFoundException('Vet testimonial not found'); if (!exists) throw new NotFoundException('Vet testimonial not found');
return this.prisma.vetTestimonial.update({ where: { id }, data: dto }); return this.prisma.vetTestimonial.update({ where: { id }, data: dto });
} }
@ -68,13 +59,8 @@ export class CmsService {
return this.prisma.smartAdvisorRule.create({ data: dto }); return this.prisma.smartAdvisorRule.create({ data: dto });
} }
async updateSmartAdvisorRule( async updateSmartAdvisorRule(id: string, dto: Partial<CreateSmartAdvisorRuleDto>) {
id: string, const exists = await this.prisma.smartAdvisorRule.findUnique({ where: { id } });
dto: Partial<CreateSmartAdvisorRuleDto>,
) {
const exists = await this.prisma.smartAdvisorRule.findUnique({
where: { id },
});
if (!exists) throw new NotFoundException('Smart advisor rule not found'); if (!exists) throw new NotFoundException('Smart advisor rule not found');
return this.prisma.smartAdvisorRule.update({ where: { id }, data: dto }); return this.prisma.smartAdvisorRule.update({ where: { id }, data: dto });
} }

View File

@ -1,10 +1,4 @@
import { import { IsString, IsOptional, IsBoolean, IsNumber, IsUUID } from 'class-validator';
IsString,
IsOptional,
IsBoolean,
IsNumber,
IsUUID,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateHeroBannerDto { export class CreateHeroBannerDto {

View File

@ -8,41 +8,26 @@ export enum SortOrder {
} }
export class PaginationDto { export class PaginationDto {
@ApiPropertyOptional({ @ApiPropertyOptional({ description: 'شماره صفحه (شروع از ۱)', minimum: 1, default: 1 })
description: 'شماره صفحه (شروع از ۱)',
minimum: 1,
default: 1,
})
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(1)
page?: number = 1; page?: number = 1;
@ApiPropertyOptional({ @ApiPropertyOptional({ description: 'تعداد آیتم‌ها در هر صفحه', minimum: 1, default: 10 })
description: 'تعداد آیتم‌ها در هر صفحه',
minimum: 1,
default: 10,
})
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(1)
limit?: number = 10; limit?: number = 10;
@ApiPropertyOptional({ @ApiPropertyOptional({ description: 'فیلد برای مرتب‌سازی', default: 'createdAt' })
description: 'فیلد برای مرتب‌سازی',
default: 'createdAt',
})
@IsOptional() @IsOptional()
@IsString() @IsString()
sortBy?: string = 'createdAt'; sortBy?: string = 'createdAt';
@ApiPropertyOptional({ @ApiPropertyOptional({ description: 'جهت مرتب‌سازی (asc/desc)', enum: SortOrder, default: SortOrder.DESC })
description: 'جهت مرتب‌سازی (asc/desc)',
enum: SortOrder,
default: SortOrder.DESC,
})
@IsOptional() @IsOptional()
@IsEnum(SortOrder) @IsEnum(SortOrder)
sortOrder?: SortOrder = SortOrder.DESC; sortOrder?: SortOrder = SortOrder.DESC;

View File

@ -1,9 +1,4 @@
import { import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
} from '@nestjs/common';
import { Response } from 'express'; import { Response } from 'express';
@Catch(HttpException) @Catch(HttpException)
@ -14,17 +9,9 @@ export class HttpExceptionFilter implements ExceptionFilter {
const status = exception.getStatus(); const status = exception.getStatus();
const exceptionResponse: any = exception.getResponse(); const exceptionResponse: any = exception.getResponse();
let message = let message = typeof exceptionResponse === 'string' ? exceptionResponse : (exceptionResponse.message || 'خطای سرور');
typeof exceptionResponse === 'string' let code = typeof exceptionResponse === 'object' && exceptionResponse.error ? exceptionResponse.error : (status === 400 ? 'BAD_REQUEST' : 'ERROR');
? exceptionResponse
: exceptionResponse.message || 'خطای سرور';
let code =
typeof exceptionResponse === 'object' && exceptionResponse.error
? exceptionResponse.error
: status === 400
? 'BAD_REQUEST'
: 'ERROR';
// Convert array of class-validator errors to a generic Farsi message if it's a 400 // Convert array of class-validator errors to a generic Farsi message if it's a 400
if (Array.isArray(message) && status === 400) { if (Array.isArray(message) && status === 400) {
message = 'اطلاعات وارد شده نامعتبر است. لطفاً فرم را بررسی کنید.'; message = 'اطلاعات وارد شده نامعتبر است. لطفاً فرم را بررسی کنید.';
@ -43,7 +30,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
success: false, success: false,
message, message,
code, code,
details: typeof exceptionResponse === 'object' ? exceptionResponse : {}, details: typeof exceptionResponse === 'object' ? exceptionResponse : {}
}); });
} }
} }

View File

@ -18,7 +18,7 @@ export class MetricsController {
async getMetrics(@Res() res: express.Response) { async getMetrics(@Res() res: express.Response) {
const memory = process.memoryUsage(); const memory = process.memoryUsage();
const cpu = process.cpuUsage(); const cpu = process.cpuUsage();
let dbStatus = 1; let dbStatus = 1;
try { try {
await this.prisma.$queryRaw`SELECT 1`; await this.prisma.$queryRaw`SELECT 1`;

View File

@ -4,19 +4,12 @@ export class ApiErrorResponse {
@ApiProperty({ description: 'موفقیت‌آمیز بودن درخواست', example: false }) @ApiProperty({ description: 'موفقیت‌آمیز بودن درخواست', example: false })
success: boolean; success: boolean;
@ApiProperty({ @ApiProperty({ description: 'پیام خطا', example: 'اطلاعات وارد شده نامعتبر است. لطفاً فرم را بررسی کنید.' })
description: 'پیام خطا',
example: 'اطلاعات وارد شده نامعتبر است. لطفاً فرم را بررسی کنید.',
})
message: string; message: string;
@ApiProperty({ description: 'کد خطا', example: 'BAD_REQUEST' }) @ApiProperty({ description: 'کد خطا', example: 'BAD_REQUEST' })
code: string; code: string;
@ApiProperty({ @ApiProperty({ description: 'جزئیات خطا (در صورت وجود)', required: false, example: {} })
description: 'جزئیات خطا (در صورت وجود)',
required: false,
example: {},
})
details?: any; details?: any;
} }

View File

@ -1,26 +1,19 @@
import { Controller, Get, HttpStatus } from '@nestjs/common'; import { Controller, Get, HttpStatus } from '@nestjs/common';
import { HomeService } from './home.service'; import { HomeService } from './home.service';
import { import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse } from '@nestjs/swagger';
ApiTags,
ApiOperation,
ApiResponse,
ApiOkResponse,
} from '@nestjs/swagger';
@ApiTags('Home - صفحه اصلی') @ApiTags('Home - صفحه اصلی')
@Controller('home') @Controller('home')
@ApiResponse({ @ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR, status: HttpStatus.INTERNAL_SERVER_ERROR,
description: 'خطای داخلی سرور', description: 'خطای داخلی سرور'
}) })
export class HomeController { export class HomeController {
constructor(private readonly homeService: HomeService) {} constructor(private readonly homeService: HomeService) {}
@Get() @Get()
@ApiOperation({ summary: 'دریافت اطلاعات صفحه اصلی' }) @ApiOperation({ summary: 'دریافت اطلاعات صفحه اصلی' })
@ApiOkResponse({ @ApiOkResponse({ description: 'اطلاعات ویترین، بنرها، پرفروش‌ترین‌ها، وبلاگ و غیره' })
description: 'اطلاعات ویترین، بنرها، پرفروش‌ترین‌ها، وبلاگ و غیره',
})
getHomeData() { getHomeData() {
return this.homeService.getHomeData(); return this.homeService.getHomeData();
} }

View File

@ -31,10 +31,10 @@ export class HomeService {
priceValue: true, priceValue: true,
priceDisplay: true, priceDisplay: true,
categorySlug: true, categorySlug: true,
categoryId: true, categoryId: true
}, }
}, }
}, }
}); });
// We can also fetch featured products here if needed, or rely on a separate endpoint // We can also fetch featured products here if needed, or rely on a separate endpoint
@ -47,7 +47,7 @@ export class HomeService {
heroBanners, heroBanners,
vetTestimonials, vetTestimonials,
smartAdvisorRules, smartAdvisorRules,
featuredProducts, featuredProducts
}; };
} }
} }

View File

@ -9,46 +9,41 @@ import helmet from 'helmet';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Serve static uploads folder // Serve static uploads folder
app.useStaticAssets(join(process.cwd(), 'uploads'), { app.useStaticAssets(join(process.cwd(), 'uploads'), {
prefix: '/uploads/', prefix: '/uploads/',
}); });
app.use( app.use(helmet({
helmet({ contentSecurityPolicy: false, // Avoid blocking Swagger UI scripts and assets
contentSecurityPolicy: false, // Avoid blocking Swagger UI scripts and assets }));
}),
);
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
app.enableCors({ app.enableCors({
origin: true, origin: true,
credentials: true, credentials: true,
}); });
app.useGlobalPipes( app.useGlobalPipes(new ValidationPipe({
new ValidationPipe({ transform: true,
transform: true, whitelist: true,
whitelist: true, forbidNonWhitelisted: true,
forbidNonWhitelisted: true, }));
}),
);
app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalFilters(new HttpExceptionFilter());
const config = new DocumentBuilder() const config = new DocumentBuilder()
.setTitle('Canina Iran API') .setTitle('Canina Iran API')
.setDescription( .setDescription('API Documentation for Canina Iran Pet Health & Supplement Platform')
'API Documentation for Canina Iran Pet Health & Supplement Platform',
)
.setVersion('1.0.0') .setVersion('1.0.0')
.addBearerAuth() .addBearerAuth()
.build(); .build();
const document = SwaggerModule.createDocument(app, config); const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document); SwaggerModule.setup('api/docs', app, document);
await app.listen(process.env.PORT ?? 4001); await app.listen(process.env.PORT ?? 4001);
} }
bootstrap(); bootstrap();

View File

@ -1,12 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { import { IsNotEmpty, IsArray, ValidateNested, IsNumber, IsOptional, IsString } from 'class-validator';
IsNotEmpty,
IsArray,
ValidateNested,
IsNumber,
IsOptional,
IsString,
} from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
class OrderItemDto { class OrderItemDto {
@ -32,16 +25,12 @@ export class CreateOrderDto {
@IsString() @IsString()
couponCode?: string; couponCode?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({ description: 'آدرس/شناسه تصویر نسخه پزشکی (برای داروهای نیازمند نسخه)' })
description: 'آدرس/شناسه تصویر نسخه پزشکی (برای داروهای نیازمند نسخه)',
})
@IsOptional() @IsOptional()
@IsString() @IsString()
prescriptionUrl?: string; prescriptionUrl?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({ description: 'مبلغ کمک به پناهگاه حیوانات (ردپای مهربانی)' })
description: 'مبلغ کمک به پناهگاه حیوانات (ردپای مهربانی)',
})
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
charityDonation?: number; charityDonation?: number;

View File

@ -8,16 +8,16 @@ describe('OrdersController', () => {
const mockOrdersService = { const mockOrdersService = {
create: jest.fn().mockResolvedValue({ id: 'order-id', totalAmount: 1000 }), create: jest.fn().mockResolvedValue({ id: 'order-id', totalAmount: 1000 }),
findAllByUser: jest findAllByUser: jest.fn().mockResolvedValue([{ id: 'order-id', totalAmount: 1000 }]),
.fn()
.mockResolvedValue([{ id: 'order-id', totalAmount: 1000 }]),
findOne: jest.fn().mockResolvedValue({ id: 'order-id', totalAmount: 1000 }), findOne: jest.fn().mockResolvedValue({ id: 'order-id', totalAmount: 1000 }),
}; };
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [OrdersController], controllers: [OrdersController],
providers: [{ provide: OrdersService, useValue: mockOrdersService }], providers: [
{ provide: OrdersService, useValue: mockOrdersService },
],
}).compile(); }).compile();
controller = module.get<OrdersController>(OrdersController); controller = module.get<OrdersController>(OrdersController);

View File

@ -1,29 +1,8 @@
import { import { Controller, Get, Post, Patch, Body, Param, UseGuards, Req, HttpStatus, Query } from '@nestjs/common';
Controller,
Get,
Post,
Patch,
Body,
Param,
UseGuards,
Req,
HttpStatus,
Query,
} from '@nestjs/common';
import { OrdersService } from './orders.service'; import { OrdersService } from './orders.service';
import { CreateOrderDto } from './dto/create-order.dto'; import { CreateOrderDto } from './dto/create-order.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiOkResponse, ApiCreatedResponse, ApiBadRequestResponse, ApiNotFoundResponse, ApiBody } from '@nestjs/swagger';
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiOkResponse,
ApiCreatedResponse,
ApiBadRequestResponse,
ApiNotFoundResponse,
ApiBody,
} from '@nestjs/swagger';
import { PaginationDto } from '../common/dto/pagination.dto'; import { PaginationDto } from '../common/dto/pagination.dto';
import { IsString, IsNumber, IsNotEmpty } from 'class-validator'; import { IsString, IsNumber, IsNotEmpty } from 'class-validator';
@ -48,9 +27,9 @@ class ValidateCouponDto {
success: false, success: false,
message: 'Unauthorized', message: 'Unauthorized',
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
details: {}, details: {}
}, }
}, }
}) })
export class OrdersController { export class OrdersController {
constructor(private readonly ordersService: OrdersService) {} constructor(private readonly ordersService: OrdersService) {}
@ -68,9 +47,9 @@ export class OrdersController {
charityDonation: '10000.00', charityDonation: '10000.00',
status: 'processing', status: 'processing',
trackingNumber: null, trackingNumber: null,
createdAt: '2026-05-26T18:10:00.000Z', createdAt: '2026-05-26T18:10:00.000Z'
}, }
}, }
}) })
@ApiBadRequestResponse({ @ApiBadRequestResponse({
description: 'اعتبارسنجی اقلام سبد خرید با خطا مواجه شد', description: 'اعتبارسنجی اقلام سبد خرید با خطا مواجه شد',
@ -79,9 +58,9 @@ export class OrdersController {
success: false, success: false,
message: 'سبد خرید نمی‌تواند خالی باشد', message: 'سبد خرید نمی‌تواند خالی باشد',
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
details: {}, details: {}
}, }
}, }
}) })
create(@Req() req: any, @Body() createOrderDto: CreateOrderDto) { create(@Req() req: any, @Body() createOrderDto: CreateOrderDto) {
return this.ordersService.create(req.user.id, createOrderDto); return this.ordersService.create(req.user.id, createOrderDto);
@ -98,19 +77,13 @@ export class OrdersController {
code: 'CANINO10', code: 'CANINO10',
type: 'percent', type: 'percent',
discountValue: 175000, discountValue: 175000,
message: 'کد تخفیف اعمال شد — ۱۷۵,۰۰۰ تومان تخفیف', message: 'کد تخفیف اعمال شد — ۱۷۵,۰۰۰ تومان تخفیف'
}, }
}, }
})
@ApiBadRequestResponse({
description: 'کد تخفیف نامعتبر، منقضی، یا شرایط آن برقرار نیست',
}) })
@ApiBadRequestResponse({ description: 'کد تخفیف نامعتبر، منقضی، یا شرایط آن برقرار نیست' })
validateCoupon(@Req() req: any, @Body() body: ValidateCouponDto) { validateCoupon(@Req() req: any, @Body() body: ValidateCouponDto) {
return this.ordersService.validateCoupon( return this.ordersService.validateCoupon(body.code, body.cartTotal, req.user.id);
body.code,
body.cartTotal,
req.user.id,
);
} }
@Get() @Get()
@ -126,17 +99,17 @@ export class OrdersController {
totalAmount: '3500000.00', totalAmount: '3500000.00',
charityDonation: '10000.00', charityDonation: '10000.00',
status: 'processing', status: 'processing',
createdAt: '2026-05-26T18:10:00.000Z', createdAt: '2026-05-26T18:10:00.000Z'
}, }
], ],
meta: { meta: {
total: 1, total: 1,
page: 1, page: 1,
lastPage: 1, lastPage: 1,
limit: 10, limit: 10
}, }
}, }
}, }
}) })
findAll(@Req() req: any, @Query() query: PaginationDto) { findAll(@Req() req: any, @Query() query: PaginationDto) {
return this.ordersService.findAllByUser(req.user.id, query); return this.ordersService.findAllByUser(req.user.id, query);
@ -163,12 +136,12 @@ export class OrdersController {
id: 'a1b2c3d4-1234-5678-abcd-ef1234567890', id: 'a1b2c3d4-1234-5678-abcd-ef1234567890',
name: 'Canhydrox GAG (کنهیدروکس)', name: 'Canhydrox GAG (کنهیدروکس)',
priceDisplay: '۱,۷۵۰,۰۰۰ تومان', priceDisplay: '۱,۷۵۰,۰۰۰ تومان',
imageUrl: 'https://example.com/canhydrox.png', imageUrl: 'https://example.com/canhydrox.png'
}, }
}, }
], ]
}, }
}, }
}) })
@ApiNotFoundResponse({ @ApiNotFoundResponse({
description: 'سفارش یافت نشد یا متعلق به کاربر فعلی نیست', description: 'سفارش یافت نشد یا متعلق به کاربر فعلی نیست',
@ -177,9 +150,9 @@ export class OrdersController {
success: false, success: false,
message: 'Order not found', message: 'Order not found',
code: 'NOT_FOUND', code: 'NOT_FOUND',
details: {}, details: {}
}, }
}, }
}) })
findOne(@Req() req: any, @Param('id') id: string) { findOne(@Req() req: any, @Param('id') id: string) {
return this.ordersService.findOne(id, req.user.id); return this.ordersService.findOne(id, req.user.id);

View File

@ -43,32 +43,23 @@ describe('OrdersService', () => {
it('should throw NotFoundException if product does not exist', async () => { it('should throw NotFoundException if product does not exist', async () => {
mockPrisma.product.findUnique.mockResolvedValue(null); mockPrisma.product.findUnique.mockResolvedValue(null);
const dto = { items: [{ productId: 'invalid-prod', quantity: 2 }] }; const dto = { items: [{ productId: 'invalid-prod', quantity: 2 }] };
await expect(service.create('user-id', dto)).rejects.toThrow( await expect(service.create('user-id', dto)).rejects.toThrow(NotFoundException);
NotFoundException,
);
}); });
it('should throw BadRequestException if items are empty', async () => { it('should throw BadRequestException if items are empty', async () => {
const dto = { items: [] }; const dto = { items: [] };
await expect(service.create('user-id', dto)).rejects.toThrow( await expect(service.create('user-id', dto)).rejects.toThrow(BadRequestException);
BadRequestException,
);
}); });
it('should successfully create order and sum amounts', async () => { it('should successfully create order and sum amounts', async () => {
const prod = { id: 'prod-1', priceValue: 1000 }; const prod = { id: 'prod-1', priceValue: 1000 };
mockPrisma.product.findUnique.mockResolvedValue(prod); mockPrisma.product.findUnique.mockResolvedValue(prod);
mockPrisma.order.create.mockResolvedValue({ mockPrisma.order.create.mockResolvedValue({ id: 'order-1', totalAmount: 2000 });
id: 'order-1',
totalAmount: 2000,
});
const dto = { items: [{ productId: 'prod-1', quantity: 2 }] }; const dto = { items: [{ productId: 'prod-1', quantity: 2 }] };
const result = await service.create('user-id', dto); const result = await service.create('user-id', dto);
expect(prisma.product.findUnique).toHaveBeenCalledWith({ expect(prisma.product.findUnique).toHaveBeenCalledWith({ where: { id: 'prod-1' } });
where: { id: 'prod-1' },
});
expect(prisma.order.create).toHaveBeenCalledWith({ expect(prisma.order.create).toHaveBeenCalledWith({
data: { data: {
userId: 'user-id', userId: 'user-id',
@ -101,9 +92,7 @@ describe('OrdersService', () => {
describe('findOne', () => { describe('findOne', () => {
it('should throw NotFoundException if order does not exist', async () => { it('should throw NotFoundException if order does not exist', async () => {
mockPrisma.order.findFirst.mockResolvedValue(null); mockPrisma.order.findFirst.mockResolvedValue(null);
await expect(service.findOne('order-id', 'user-id')).rejects.toThrow( await expect(service.findOne('order-id', 'user-id')).rejects.toThrow(NotFoundException);
NotFoundException,
);
}); });
it('should return order if found', async () => { it('should return order if found', async () => {

View File

@ -1,8 +1,4 @@
import { import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { PaginationDto } from '../common/dto/pagination.dto'; import { PaginationDto } from '../common/dto/pagination.dto';
import { CreateOrderDto } from './dto/create-order.dto'; import { CreateOrderDto } from './dto/create-order.dto';
@ -26,43 +22,28 @@ export class OrdersService {
}); });
if (!coupon || !coupon.isActive) { if (!coupon || !coupon.isActive) {
throw new BadRequestException({ throw new BadRequestException({ message: 'کد تخفیف معتبر نیست یا منقضی شده است', error: 'COUPON_INVALID' });
message: 'کد تخفیف معتبر نیست یا منقضی شده است',
error: 'COUPON_INVALID',
});
} }
if (coupon.expiresAt && new Date() > coupon.expiresAt) { if (coupon.expiresAt && new Date() > coupon.expiresAt) {
throw new BadRequestException({ throw new BadRequestException({ message: 'کد تخفیف منقضی شده است', error: 'COUPON_EXPIRED' });
message: 'کد تخفیف منقضی شده است',
error: 'COUPON_EXPIRED',
});
} }
if (coupon.maxUses && coupon.usedCount >= coupon.maxUses) { if (coupon.maxUses && coupon.usedCount >= coupon.maxUses) {
throw new BadRequestException({ throw new BadRequestException({ message: 'ظرفیت استفاده از این کد تخفیف تکمیل شده است', error: 'COUPON_LIMIT_REACHED' });
message: 'ظرفیت استفاده از این کد تخفیف تکمیل شده است',
error: 'COUPON_LIMIT_REACHED',
});
} }
if (coupon.minCartValue && cartTotal < Number(coupon.minCartValue)) { if (coupon.minCartValue && cartTotal < Number(coupon.minCartValue)) {
throw new BadRequestException({ throw new BadRequestException({
message: `حداقل مبلغ سبد خرید برای استفاده از این کد ${Number(coupon.minCartValue).toLocaleString('fa-IR')} تومان است`, message: `حداقل مبلغ سبد خرید برای استفاده از این کد ${Number(coupon.minCartValue).toLocaleString('fa-IR')} تومان است`,
error: 'COUPON_MIN_CART', error: 'COUPON_MIN_CART'
}); });
} }
// Check user-specific targets // Check user-specific targets
const userTargets = coupon.targets.filter((t) => t.targetType === 'USER'); const userTargets = coupon.targets.filter(t => t.targetType === 'USER');
if ( if (userTargets.length > 0 && !userTargets.some(t => t.targetId === userId)) {
userTargets.length > 0 && throw new BadRequestException({ message: 'این کد تخفیف برای حساب شما معتبر نیست', error: 'COUPON_NOT_FOR_USER' });
!userTargets.some((t) => t.targetId === userId)
) {
throw new BadRequestException({
message: 'این کد تخفیف برای حساب شما معتبر نیست',
error: 'COUPON_NOT_FOR_USER',
});
} }
let discountValue: number; let discountValue: number;
@ -93,13 +74,9 @@ export class OrdersService {
const orderItems = []; const orderItems = [];
for (const item of createOrderDto.items) { for (const item of createOrderDto.items) {
const product = await this.prisma.product.findUnique({ const product = await this.prisma.product.findUnique({ where: { id: item.productId } });
where: { id: item.productId },
});
if (!product) { if (!product) {
throw new NotFoundException( throw new NotFoundException(`محصول با شناسه ${item.productId} یافت نشد`);
`محصول با شناسه ${item.productId} یافت نشد`,
);
} }
totalAmount += Number(product.priceValue) * item.quantity; totalAmount += Number(product.priceValue) * item.quantity;
orderItems.push({ orderItems.push({
@ -116,11 +93,7 @@ export class OrdersService {
let discountAmount = 0; let discountAmount = 0;
if (createOrderDto.couponCode) { if (createOrderDto.couponCode) {
try { try {
const couponResult = await this.validateCoupon( const couponResult = await this.validateCoupon(createOrderDto.couponCode, totalAmount, userId);
createOrderDto.couponCode,
totalAmount,
userId,
);
discountAmount = couponResult.discountValue; discountAmount = couponResult.discountValue;
couponId = couponResult.couponId; couponId = couponResult.couponId;
// Increment usedCount // Increment usedCount
@ -134,10 +107,7 @@ export class OrdersService {
} }
const charityAmount = Number(createOrderDto.charityDonation || 0); const charityAmount = Number(createOrderDto.charityDonation || 0);
const finalAmount = Math.max( const finalAmount = Math.max(totalAmount - discountAmount + charityAmount, 0);
totalAmount - discountAmount + charityAmount,
0,
);
const trackingNumber = this.generateTrackingNumber(); const trackingNumber = this.generateTrackingNumber();
// Deduct user wallet balance if payment method is wallet // Deduct user wallet balance if payment method is wallet
@ -148,15 +118,13 @@ export class OrdersService {
} }
const userBalance = Number(user.walletBalance || 0); const userBalance = Number(user.walletBalance || 0);
if (userBalance < finalAmount) { if (userBalance < finalAmount) {
throw new BadRequestException( throw new BadRequestException('موجودی کیف پول برای پرداخت این سفارش کافی نیست');
'موجودی کیف پول برای پرداخت این سفارش کافی نیست',
);
} }
await this.prisma.user.update({ await this.prisma.user.update({
where: { id: userId }, where: { id: userId },
data: { data: {
walletBalance: { decrement: finalAmount }, walletBalance: { decrement: finalAmount }
}, }
}); });
await this.prisma.walletTransaction.create({ await this.prisma.walletTransaction.create({
data: { data: {
@ -164,8 +132,8 @@ export class OrdersService {
amount: finalAmount, amount: finalAmount,
type: 'withdrawal', type: 'withdrawal',
status: 'completed', status: 'completed',
description: `پرداخت سفارش ${trackingNumber}`, description: `پرداخت سفارش ${trackingNumber}`
}, }
}); });
} }
@ -174,7 +142,7 @@ export class OrdersService {
try { try {
await this.prisma.user.update({ await this.prisma.user.update({
where: { id: userId }, where: { id: userId },
data: { charityDonationTotal: { increment: charityAmount } }, data: { charityDonationTotal: { increment: charityAmount } }
}); });
} catch { } catch {
// Ignore if user not found or guest // Ignore if user not found or guest
@ -197,19 +165,14 @@ export class OrdersService {
} as any, } as any,
include: { include: {
orderItems: { orderItems: {
include: { product: true }, include: { product: true }
}, }
}, }
}); });
} }
async findAllByUser(userId: string, filters: PaginationDto) { async findAllByUser(userId: string, filters: PaginationDto) {
const { const { page = 1, limit = 10, sortBy = 'createdAt', sortOrder = 'desc' } = filters;
page = 1,
limit = 10,
sortBy = 'createdAt',
sortOrder = 'desc',
} = filters;
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
@ -222,7 +185,7 @@ export class OrdersService {
orderItems: { include: { product: true } }, orderItems: { include: { product: true } },
}, },
}), }),
this.prisma.order.count({ where: { userId } }), this.prisma.order.count({ where: { userId } })
]); ]);
return { return {
@ -232,7 +195,7 @@ export class OrdersService {
page, page,
lastPage: Math.ceil(total / limit), lastPage: Math.ceil(total / limit),
limit, limit,
}, }
}; };
} }

View File

@ -17,10 +17,7 @@ export class CreateHealthLogDto {
@IsString() @IsString()
digestion: string; digestion: string;
@ApiPropertyOptional({ @ApiPropertyOptional({ description: 'یادداشت یا توضیح اضافی', example: 'امروز فعالیت خوبی داشت.' })
description: 'یادداشت یا توضیح اضافی',
example: 'امروز فعالیت خوبی داشت.',
})
@IsOptional() @IsOptional()
@IsString() @IsString()
note?: string; note?: string;

View File

@ -1,11 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { import { IsNotEmpty, IsString, IsOptional, IsNumber, IsArray } from 'class-validator';
IsNotEmpty,
IsString,
IsOptional,
IsNumber,
IsArray,
} from 'class-validator';
export class CreatePetDto { export class CreatePetDto {
@ApiProperty({ description: 'نام حیوان خانگی', example: 'بادی' }) @ApiProperty({ description: 'نام حیوان خانگی', example: 'بادی' })

View File

@ -12,18 +12,12 @@ export class CreateReminderDto {
@IsString() @IsString()
time: string; time: string;
@ApiProperty({ @ApiProperty({ description: 'دوره زمانی (روزانه / هفتگی)', example: 'روزانه' })
description: 'دوره زمانی (روزانه / هفتگی)',
example: 'روزانه',
})
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
frequency: string; frequency: string;
@ApiPropertyOptional({ @ApiPropertyOptional({ description: 'شناسه محصول مربوطه', example: 'a1b2c3d4-1234-5678-abcd-ef1234567890' })
description: 'شناسه محصول مربوطه',
example: 'a1b2c3d4-1234-5678-abcd-ef1234567890',
})
@IsOptional() @IsOptional()
@IsString() @IsString()
productId?: string; productId?: string;

View File

@ -8,9 +8,7 @@ describe('PetsController', () => {
const mockPetsService = { const mockPetsService = {
create: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy' }), create: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy' }),
findAllByUser: jest findAllByUser: jest.fn().mockResolvedValue([{ id: 'pet-id', name: 'Buddy' }]),
.fn()
.mockResolvedValue([{ id: 'pet-id', name: 'Buddy' }]),
findOne: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy' }), findOne: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy' }),
update: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy New' }), update: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy New' }),
remove: jest.fn().mockResolvedValue({ success: true }), remove: jest.fn().mockResolvedValue({ success: true }),
@ -19,7 +17,9 @@ describe('PetsController', () => {
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [PetsController], controllers: [PetsController],
providers: [{ provide: PetsService, useValue: mockPetsService }], providers: [
{ provide: PetsService, useValue: mockPetsService },
],
}).compile(); }).compile();
controller = module.get<PetsController>(PetsController); controller = module.get<PetsController>(PetsController);

View File

@ -1,32 +1,11 @@
import { import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Req, HttpStatus, Query } from '@nestjs/common';
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards,
Req,
HttpStatus,
Query,
} from '@nestjs/common';
import { PetsService } from './pets.service'; import { PetsService } from './pets.service';
import { CreatePetDto } from './dto/create-pet.dto'; import { CreatePetDto } from './dto/create-pet.dto';
import { UpdatePetDto } from './dto/update-pet.dto'; import { UpdatePetDto } from './dto/update-pet.dto';
import { CreateReminderDto } from './dto/create-reminder.dto'; import { CreateReminderDto } from './dto/create-reminder.dto';
import { CreateHealthLogDto } from './dto/create-health-log.dto'; import { CreateHealthLogDto } from './dto/create-health-log.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiOkResponse, ApiCreatedResponse, ApiBadRequestResponse, ApiNotFoundResponse } from '@nestjs/swagger';
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiOkResponse,
ApiCreatedResponse,
ApiBadRequestResponse,
ApiNotFoundResponse,
} from '@nestjs/swagger';
import { PaginationDto } from '../common/dto/pagination.dto'; import { PaginationDto } from '../common/dto/pagination.dto';
@ApiTags('Pets - مدیریت حیوانات خانگی') @ApiTags('Pets - مدیریت حیوانات خانگی')
@ -41,9 +20,9 @@ import { PaginationDto } from '../common/dto/pagination.dto';
success: false, success: false,
message: 'Unauthorized', message: 'Unauthorized',
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
details: {}, details: {}
}, }
}, }
}) })
export class PetsController { export class PetsController {
constructor(private readonly petsService: PetsService) {} constructor(private readonly petsService: PetsService) {}
@ -63,9 +42,9 @@ export class PetsController {
weight: '25.50', weight: '25.50',
activityLevel: 'متوسط', activityLevel: 'متوسط',
imageUrl: null, imageUrl: null,
createdAt: '2026-05-26T18:10:00.000Z', createdAt: '2026-05-26T18:10:00.000Z'
}, }
}, }
}) })
@ApiBadRequestResponse({ @ApiBadRequestResponse({
description: 'خطا در صحت‌سنجی فیلدهای ورودی', description: 'خطا در صحت‌سنجی فیلدهای ورودی',
@ -74,9 +53,9 @@ export class PetsController {
success: false, success: false,
message: 'نوع حیوان خانگی اجباری است', message: 'نوع حیوان خانگی اجباری است',
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
details: {}, details: {}
}, }
}, }
}) })
create(@Req() req: any, @Body() createPetDto: CreatePetDto) { create(@Req() req: any, @Body() createPetDto: CreatePetDto) {
return this.petsService.create(req.user.id, createPetDto); return this.petsService.create(req.user.id, createPetDto);
@ -99,17 +78,17 @@ export class PetsController {
weight: '25.50', weight: '25.50',
activityLevel: 'متوسط', activityLevel: 'متوسط',
imageUrl: null, imageUrl: null,
createdAt: '2026-05-26T18:10:00.000Z', createdAt: '2026-05-26T18:10:00.000Z'
}, }
], ],
meta: { meta: {
total: 1, total: 1,
page: 1, page: 1,
lastPage: 1, lastPage: 1,
limit: 10, limit: 10
}, }
}, }
}, }
}) })
findAll(@Req() req: any, @Query() query: PaginationDto) { findAll(@Req() req: any, @Query() query: PaginationDto) {
return this.petsService.findAllByUser(req.user.id, query); return this.petsService.findAllByUser(req.user.id, query);
@ -133,9 +112,9 @@ export class PetsController {
medicalConditions: [], medicalConditions: [],
reminders: [], reminders: [],
healthLogs: [], healthLogs: [],
createdAt: '2026-05-26T18:10:00.000Z', createdAt: '2026-05-26T18:10:00.000Z'
}, }
}, }
}) })
@ApiNotFoundResponse({ @ApiNotFoundResponse({
description: 'حیوان خانگی پیدا نشد یا متعلق به کاربر جاری نیست', description: 'حیوان خانگی پیدا نشد یا متعلق به کاربر جاری نیست',
@ -144,9 +123,9 @@ export class PetsController {
success: false, success: false,
message: 'Pet not found or unauthorized', message: 'Pet not found or unauthorized',
code: 'NOT_FOUND', code: 'NOT_FOUND',
details: {}, details: {}
}, }
}, }
}) })
findOne(@Req() req: any, @Param('id') id: string) { findOne(@Req() req: any, @Param('id') id: string) {
return this.petsService.findOne(id, req.user.id); return this.petsService.findOne(id, req.user.id);
@ -164,9 +143,9 @@ export class PetsController {
breed: 'ژرمن شپرد', breed: 'ژرمن شپرد',
age: 4, age: 4,
weight: '26.00', weight: '26.00',
activityLevel: 'زیاد', activityLevel: 'زیاد'
}, }
}, }
}) })
@ApiNotFoundResponse({ @ApiNotFoundResponse({
description: 'حیوان خانگی یافت نشد', description: 'حیوان خانگی یافت نشد',
@ -175,15 +154,11 @@ export class PetsController {
success: false, success: false,
message: 'Pet not found', message: 'Pet not found',
code: 'NOT_FOUND', code: 'NOT_FOUND',
details: {}, details: {}
}, }
}, }
}) })
update( update(@Req() req: any, @Param('id') id: string, @Body() updatePetDto: UpdatePetDto) {
@Req() req: any,
@Param('id') id: string,
@Body() updatePetDto: UpdatePetDto,
) {
return this.petsService.update(id, req.user.id, updatePetDto); return this.petsService.update(id, req.user.id, updatePetDto);
} }
@ -194,9 +169,9 @@ export class PetsController {
schema: { schema: {
example: { example: {
success: true, success: true,
message: 'حیوان خانگی با موفقیت حذف شد', message: 'حیوان خانگی با موفقیت حذف شد'
}, }
}, }
}) })
@ApiNotFoundResponse({ @ApiNotFoundResponse({
description: 'حیوان خانگی یافت نشد', description: 'حیوان خانگی یافت نشد',
@ -205,9 +180,9 @@ export class PetsController {
success: false, success: false,
message: 'Pet not found', message: 'Pet not found',
code: 'NOT_FOUND', code: 'NOT_FOUND',
details: {}, details: {}
}, }
}, }
}) })
remove(@Req() req: any, @Param('id') id: string) { remove(@Req() req: any, @Param('id') id: string) {
return this.petsService.remove(id, req.user.id); return this.petsService.remove(id, req.user.id);
@ -231,12 +206,7 @@ export class PetsController {
@Param('reminderId') reminderId: string, @Param('reminderId') reminderId: string,
@Body('date') date: string, @Body('date') date: string,
) { ) {
return this.petsService.toggleReminder( return this.petsService.toggleReminder(req.user.id, petId, reminderId, date);
req.user.id,
petId,
reminderId,
date,
);
} }
@Post(':petId/health-logs') @Post(':petId/health-logs')
@ -246,10 +216,6 @@ export class PetsController {
@Param('petId') petId: string, @Param('petId') petId: string,
@Body() createHealthLogDto: CreateHealthLogDto, @Body() createHealthLogDto: CreateHealthLogDto,
) { ) {
return this.petsService.addHealthLog( return this.petsService.addHealthLog(req.user.id, petId, createHealthLogDto);
req.user.id,
petId,
createHealthLogDto,
);
} }
} }

View File

@ -58,9 +58,7 @@ describe('PetsService', () => {
describe('findOne', () => { describe('findOne', () => {
it('should throw NotFoundException if pet not found', async () => { it('should throw NotFoundException if pet not found', async () => {
mockPrisma.pet.findFirst.mockResolvedValue(null); mockPrisma.pet.findFirst.mockResolvedValue(null);
await expect(service.findOne('pet-id', 'user-id')).rejects.toThrow( await expect(service.findOne('pet-id', 'user-id')).rejects.toThrow(NotFoundException);
NotFoundException,
);
}); });
it('should return pet if found', async () => { it('should return pet if found', async () => {

View File

@ -9,8 +9,7 @@ export class PetsService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
async create(userId: string, createPetDto: CreatePetDto) { async create(userId: string, createPetDto: CreatePetDto) {
const { name, type, breed, weight, age, activityLevel, medicalConditions } = const { name, type, breed, weight, age, activityLevel, medicalConditions } = createPetDto;
createPetDto;
return this.prisma.pet.create({ return this.prisma.pet.create({
data: { data: {
@ -21,24 +20,16 @@ export class PetsService {
age: age || 1, age: age || 1,
weight: weight || 0, weight: weight || 0,
userId, userId,
medicalConditions: medicalConditions?.length medicalConditions: medicalConditions?.length ? {
? { create: medicalConditions.map(condition => ({ condition }))
create: medicalConditions.map((condition) => ({ condition })), } : undefined,
}
: undefined,
}, },
include: { medicalConditions: true, reminders: true, healthLogs: true }, include: { medicalConditions: true, reminders: true, healthLogs: true },
}); });
} }
async findAllByUser(userId: string, filters: PaginationDto) { async findAllByUser(userId: string, filters: PaginationDto) {
const { const { search, page = 1, limit = 10, sortBy = 'createdAt', sortOrder = 'desc' } = filters;
search,
page = 1,
limit = 10,
sortBy = 'createdAt',
sortOrder = 'desc',
} = filters;
const whereClause: any = { userId }; const whereClause: any = { userId };
if (search) { if (search) {
@ -57,7 +48,7 @@ export class PetsService {
take: limit, take: limit,
orderBy: { [sortBy]: sortOrder }, orderBy: { [sortBy]: sortOrder },
}), }),
this.prisma.pet.count({ where: whereClause }), this.prisma.pet.count({ where: whereClause })
]); ]);
return { return {
@ -67,7 +58,7 @@ export class PetsService {
page, page,
lastPage: Math.ceil(total / limit), lastPage: Math.ceil(total / limit),
limit, limit,
}, }
}; };
} }
@ -86,9 +77,7 @@ export class PetsService {
await this.findOne(id, userId); // Ensure it exists and belongs to user await this.findOne(id, userId); // Ensure it exists and belongs to user
if (updatePetDto.medicalConditions) { if (updatePetDto.medicalConditions) {
await this.prisma.petMedicalCondition.deleteMany({ await this.prisma.petMedicalCondition.deleteMany({ where: { petId: id } });
where: { petId: id },
});
} }
return this.prisma.pet.update({ return this.prisma.pet.update({
@ -100,13 +89,9 @@ export class PetsService {
weight: updatePetDto.weight, weight: updatePetDto.weight,
age: updatePetDto.age, age: updatePetDto.age,
activityLevel: updatePetDto.activityLevel, activityLevel: updatePetDto.activityLevel,
medicalConditions: updatePetDto.medicalConditions medicalConditions: updatePetDto.medicalConditions ? {
? { create: updatePetDto.medicalConditions.map(condition => ({ condition }))
create: updatePetDto.medicalConditions.map((condition) => ({ } : undefined,
condition,
})),
}
: undefined,
}, },
include: { medicalConditions: true, reminders: true, healthLogs: true }, include: { medicalConditions: true, reminders: true, healthLogs: true },
}); });
@ -132,12 +117,7 @@ export class PetsService {
}); });
} }
async toggleReminder( async toggleReminder(userId: string, petId: string, reminderId: string, dateStr: string) {
userId: string,
petId: string,
reminderId: string,
dateStr: string,
) {
await this.findOne(petId, userId); await this.findOne(petId, userId);
const reminder = await this.prisma.reminder.findUnique({ const reminder = await this.prisma.reminder.findUnique({
@ -148,13 +128,7 @@ export class PetsService {
} }
const dateParts = dateStr.split('-'); const dateParts = dateStr.split('-');
const completedDate = new Date( const completedDate = new Date(Date.UTC(Number(dateParts[0]), Number(dateParts[1]) - 1, Number(dateParts[2])));
Date.UTC(
Number(dateParts[0]),
Number(dateParts[1]) - 1,
Number(dateParts[2]),
),
);
const existingCompletion = await this.prisma.reminderCompletion.findUnique({ const existingCompletion = await this.prisma.reminderCompletion.findUnique({
where: { where: {

View File

@ -2,10 +2,7 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
@Injectable() @Injectable()
export class PrismaService export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() { async onModuleInit() {
await this.$connect(); await this.$connect();
} }

View File

@ -8,10 +8,7 @@ export class GetProductsDto extends PaginationDto {
@IsString() @IsString()
category?: string; category?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({ description: 'فیلتر بر اساس نوع حیوان', enum: ['سگ', 'گربه', 'all'] })
description: 'فیلتر بر اساس نوع حیوان',
enum: ['سگ', 'گربه', 'all'],
})
@IsOptional() @IsOptional()
@IsEnum(['سگ', 'گربه', 'all']) @IsEnum(['سگ', 'گربه', 'all'])
petType?: string; petType?: string;

View File

@ -8,16 +8,16 @@ describe('ProductsController', () => {
let service: ProductsService; let service: ProductsService;
const mockProductsService = { const mockProductsService = {
findAll: jest findAll: jest.fn().mockResolvedValue([{ id: 'prod-id', name: 'Product 1' }]),
.fn()
.mockResolvedValue([{ id: 'prod-id', name: 'Product 1' }]),
findOne: jest.fn(), findOne: jest.fn(),
}; };
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [ProductsController], controllers: [ProductsController],
providers: [{ provide: ProductsService, useValue: mockProductsService }], providers: [
{ provide: ProductsService, useValue: mockProductsService },
],
}).compile(); }).compile();
controller = module.get<ProductsController>(ProductsController); controller = module.get<ProductsController>(ProductsController);
@ -42,9 +42,7 @@ describe('ProductsController', () => {
describe('findOne', () => { describe('findOne', () => {
it('should throw NotFoundException if product not found', async () => { it('should throw NotFoundException if product not found', async () => {
mockProductsService.findOne.mockResolvedValue(null); mockProductsService.findOne.mockResolvedValue(null);
await expect(controller.findOne('invalid-id')).rejects.toThrow( await expect(controller.findOne('invalid-id')).rejects.toThrow(NotFoundException);
NotFoundException,
);
}); });
it('should return product details if found', async () => { it('should return product details if found', async () => {

View File

@ -1,20 +1,7 @@
import { import { Controller, Get, Query, Param, NotFoundException, HttpStatus } from '@nestjs/common';
Controller,
Get,
Query,
Param,
NotFoundException,
HttpStatus,
} from '@nestjs/common';
import { ProductsService } from './products.service'; import { ProductsService } from './products.service';
import { GetProductsDto } from './dto/get-products.dto'; import { GetProductsDto } from './dto/get-products.dto';
import { import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse, ApiNotFoundResponse } from '@nestjs/swagger';
ApiTags,
ApiOperation,
ApiResponse,
ApiOkResponse,
ApiNotFoundResponse,
} from '@nestjs/swagger';
@ApiTags('Products - مدیریت محصولات دارویی') @ApiTags('Products - مدیریت محصولات دارویی')
@Controller('products') @Controller('products')
@ -26,9 +13,9 @@ import {
success: false, success: false,
message: 'خطای داخلی سرور', message: 'خطای داخلی سرور',
code: 'SERVER_ERROR', code: 'SERVER_ERROR',
details: {}, details: {}
}, }
}, }
}) })
export class ProductsController { export class ProductsController {
constructor(private readonly productsService: ProductsService) {} constructor(private readonly productsService: ProductsService) {}
@ -45,8 +32,7 @@ export class ProductsController {
artNo: 'canhydrox-gag', artNo: 'canhydrox-gag',
name: 'Canhydrox GAG (کنهیدروکس)', name: 'Canhydrox GAG (کنهیدروکس)',
scientificTagline: 'برای تقویت مفاصل و استخوان‌ها', scientificTagline: 'برای تقویت مفاصل و استخوان‌ها',
description: description: 'کنهیدروکس محصولی بی‌نظیر برای مفاصل و سیستم حرکتی سگ‌ها...',
'کنهیدروکس محصولی بی‌نظیر برای مفاصل و سیستم حرکتی سگ‌ها...',
shortDescription: 'تقویت مفاصل و غضروف‌ها', shortDescription: 'تقویت مفاصل و غضروف‌ها',
category: 'سیستم حرکتی و مفاصل', category: 'سیستم حرکتی و مفاصل',
categorySlug: 'joints', categorySlug: 'joints',
@ -59,44 +45,40 @@ export class ProductsController {
imageUrl: 'https://example.com/canhydrox.png', imageUrl: 'https://example.com/canhydrox.png',
createdAt: '2026-05-26T18:10:00.000Z', createdAt: '2026-05-26T18:10:00.000Z',
ingredients: [], ingredients: [],
symptoms: [], symptoms: []
}, }
], ],
meta: { meta: {
total: 1, total: 1,
page: 1, page: 1,
lastPage: 1, lastPage: 1,
limit: 10, limit: 10
}, }
}, }
}, }
}) })
findAll(@Query() query: GetProductsDto) { findAll(@Query() query: GetProductsDto) {
return this.productsService.findAll(query); return this.productsService.findAll(query);
} }
@Get('filters') @Get('filters')
@ApiOperation({ @ApiOperation({ summary: 'دریافت فیلترهای فعال محصولات (دسته، علائم، نوع پت)' })
summary: 'دریافت فیلترهای فعال محصولات (دسته، علائم، نوع پت)',
})
@ApiOkResponse({ @ApiOkResponse({
description: 'لیست فیلترهای پویا استخراج شده از دیتابیس', description: 'لیست فیلترهای پویا استخراج شده از دیتابیس',
schema: { schema: {
example: { example: {
categories: [{ id: '1', name: 'مفاصل و استخوان', slug: 'joints' }], categories: [{ id: '1', name: 'مفاصل و استخوان', slug: 'joints' }],
symptoms: ['لنگش', 'ریزش مو'], symptoms: ['لنگش', 'ریزش مو'],
petTypes: ['سگ', 'گربه', 'هر دو'], petTypes: ['سگ', 'گربه', 'هر دو']
}, }
}, }
}) })
getActiveFilters() { getActiveFilters() {
return this.productsService.getActiveFilters(); return this.productsService.getActiveFilters();
} }
@Get('navigation-filters') @Get('navigation-filters')
@ApiOperation({ @ApiOperation({ summary: 'دریافت فیلترها و راهکارهای ناوبری (دسته با علائم مربوطه)' })
summary: 'دریافت فیلترها و راهکارهای ناوبری (دسته با علائم مربوطه)',
})
@ApiOkResponse({ @ApiOkResponse({
description: 'ساختار درختی فیلترها و تگ‌های درمانی واقعی متصل به محصولات', description: 'ساختار درختی فیلترها و تگ‌های درمانی واقعی متصل به محصولات',
schema: { schema: {
@ -105,10 +87,10 @@ export class ProductsController {
id: '1', id: '1',
name: 'مفاصل و استخوان', name: 'مفاصل و استخوان',
slug: 'joints', slug: 'joints',
symptoms: ['درد مفاصل', 'لنگش'], symptoms: ['درد مفاصل', 'لنگش']
}, }
], ]
}, }
}) })
getNavigationFilters() { getNavigationFilters() {
return this.productsService.getNavigationFilters(); return this.productsService.getNavigationFilters();
@ -137,31 +119,24 @@ export class ProductsController {
imageUrl: 'https://example.com/canhydrox.png', imageUrl: 'https://example.com/canhydrox.png',
createdAt: '2026-05-26T18:10:00.000Z', createdAt: '2026-05-26T18:10:00.000Z',
ingredients: [ ingredients: [
{ { productId: 'a1b2c3d4-1234-5678-abcd-ef1234567890', ingredient: 'صدف لب‌سبز' }
productId: 'a1b2c3d4-1234-5678-abcd-ef1234567890',
ingredient: 'صدف لب‌سبز',
},
], ],
symptoms: [ symptoms: [
{ { productId: 'a1b2c3d4-1234-5678-abcd-ef1234567890', symptom: 'لنگیدن' }
productId: 'a1b2c3d4-1234-5678-abcd-ef1234567890', ]
symptom: 'لنگیدن', }
}, }
],
},
},
}) })
@ApiNotFoundResponse({ @ApiNotFoundResponse({
description: 'محصول با شناسه ارسال شده پیدا نشد', description: 'محصول با شناسه ارسال شده پیدا نشد',
schema: { schema: {
example: { example: {
success: false, success: false,
message: message: 'Product with ID a1b2c3d4-1234-5678-abcd-ef1234567890 not found',
'Product with ID a1b2c3d4-1234-5678-abcd-ef1234567890 not found',
code: 'NOT_FOUND', code: 'NOT_FOUND',
details: {}, details: {}
}, }
}, }
}) })
async findOne(@Param('id') id: string) { async findOne(@Param('id') id: string) {
const product = await this.productsService.findOne(id); const product = await this.productsService.findOne(id);

View File

@ -7,17 +7,7 @@ export class ProductsService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
async findAll(filters: GetProductsDto, userRole?: string) { async findAll(filters: GetProductsDto, userRole?: string) {
const { const { category, petType, search, symptom, requiresRx, page = 1, limit = 10, sortBy = 'createdAt', sortOrder = 'desc' } = filters;
category,
petType,
search,
symptom,
requiresRx,
page = 1,
limit = 10,
sortBy = 'createdAt',
sortOrder = 'desc',
} = filters;
const whereClause: any = {}; const whereClause: any = {};
@ -37,8 +27,8 @@ export class ProductsService {
if (symptom) { if (symptom) {
whereClause.symptoms = { whereClause.symptoms = {
some: { some: {
symptom: { contains: symptom, mode: 'insensitive' }, symptom: { contains: symptom, mode: 'insensitive' }
}, }
}; };
} }
@ -55,9 +45,9 @@ export class ProductsService {
{ {
symptoms: { symptoms: {
some: { some: {
symptom: { contains: search, mode: 'insensitive' }, symptom: { contains: search, mode: 'insensitive' }
}, }
}, }
}, },
]; ];
@ -65,7 +55,7 @@ export class ProductsService {
// Already have a symptom filter; combine with AND // Already have a symptom filter; combine with AND
whereClause.AND = [ whereClause.AND = [
{ symptoms: whereClause.symptoms }, { symptoms: whereClause.symptoms },
{ OR: searchConditions.filter((c) => !('symptoms' in c)) }, { OR: searchConditions.filter(c => !('symptoms' in c)) },
]; ];
delete whereClause.symptoms; delete whereClause.symptoms;
} else { } else {
@ -89,9 +79,8 @@ export class ProductsService {
this.prisma.product.count({ where: whereClause }), this.prisma.product.count({ where: whereClause }),
]); ]);
const isWholesaleOrAdmin = const isWholesaleOrAdmin = userRole === 'User_Wholesale' || userRole === 'ADMIN';
userRole === 'User_Wholesale' || userRole === 'ADMIN'; const data = rawProducts.map(p => {
const data = rawProducts.map((p) => {
if (!isWholesaleOrAdmin) { if (!isWholesaleOrAdmin) {
const { wholesalePrice, ...rest } = p; const { wholesalePrice, ...rest } = p;
return rest; return rest;
@ -111,10 +100,7 @@ export class ProductsService {
} }
async findOne(idOrSlug: string, userRole?: string) { async findOne(idOrSlug: string, userRole?: string) {
const isUuid = 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);
/^[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 product = await this.prisma.product.findFirst({ const product = await this.prisma.product.findFirst({
where: isUuid ? { id: idOrSlug } : { slug: idOrSlug }, where: isUuid ? { id: idOrSlug } : { slug: idOrSlug },
include: { include: {
@ -124,8 +110,7 @@ export class ProductsService {
}); });
if (!product) return null; if (!product) return null;
const isWholesaleOrAdmin = const isWholesaleOrAdmin = userRole === 'User_Wholesale' || userRole === 'ADMIN';
userRole === 'User_Wholesale' || userRole === 'ADMIN';
if (!isWholesaleOrAdmin) { if (!isWholesaleOrAdmin) {
const { wholesalePrice, ...rest } = product; const { wholesalePrice, ...rest } = product;
return rest; return rest;
@ -138,33 +123,33 @@ export class ProductsService {
this.prisma.category.findMany({ this.prisma.category.findMany({
where: { where: {
products: { products: {
some: {}, some: {}
}, }
}, },
select: { select: {
id: true, id: true,
name: true, name: true,
slug: true, slug: true,
}, }
}), }),
this.prisma.productSymptom.findMany({ this.prisma.productSymptom.findMany({
distinct: ['symptom'], distinct: ['symptom'],
select: { select: {
symptom: true, symptom: true,
}, }
}), }),
this.prisma.product.findMany({ this.prisma.product.findMany({
distinct: ['suitableFor'], distinct: ['suitableFor'],
select: { select: {
suitableFor: true, suitableFor: true,
}, }
}), })
]); ]);
return { return {
categories, categories,
symptoms: symptomsDb.map((s) => s.symptom).filter(Boolean), symptoms: symptomsDb.map(s => s.symptom).filter(Boolean),
petTypes: petTypesDb.map((p) => p.suitableFor).filter(Boolean), petTypes: petTypesDb.map(p => p.suitableFor).filter(Boolean),
}; };
} }
@ -172,22 +157,22 @@ export class ProductsService {
const categories = await this.prisma.category.findMany({ const categories = await this.prisma.category.findMany({
where: { where: {
products: { products: {
some: {}, some: {}
}, }
}, },
include: { include: {
products: { products: {
include: { include: {
symptoms: true, symptoms: true
}, }
}, }
}, }
}); });
return categories.map((cat) => { return categories.map(cat => {
const symptomSet = new Set<string>(); const symptomSet = new Set<string>();
cat.products.forEach((p) => { cat.products.forEach(p => {
p.symptoms.forEach((s) => { p.symptoms.forEach(s => {
if (s.symptom) { if (s.symptom) {
symptomSet.add(s.symptom); symptomSet.add(s.symptom);
} }
@ -198,7 +183,7 @@ export class ProductsService {
id: cat.id, id: cat.id,
name: cat.name, name: cat.name,
slug: cat.slug, slug: cat.slug,
symptoms: Array.from(symptomSet), symptoms: Array.from(symptomSet)
}; };
}); });
} }

View File

@ -14,9 +14,7 @@ export class SeoController {
} }
@Get('product-schema/:idOrSlug') @Get('product-schema/:idOrSlug')
@ApiOperation({ @ApiOperation({ summary: 'دریافت متادیتای ساختاریافته Schema.org JSON-LD محصول' })
summary: 'دریافت متادیتای ساختاریافته Schema.org JSON-LD محصول',
})
async getProductSchema(@Param('idOrSlug') idOrSlug: string) { async getProductSchema(@Param('idOrSlug') idOrSlug: string) {
const schema = await this.seoService.getProductSchema(idOrSlug); const schema = await this.seoService.getProductSchema(idOrSlug);
if (!schema) { if (!schema) {

View File

@ -8,36 +8,19 @@ export class SeoService {
async getSitemapUrls() { async getSitemapUrls() {
const [products, categories, blogs] = await Promise.all([ const [products, categories, blogs] = await Promise.all([
this.prisma.product.findMany({ select: { slug: true, createdAt: true } }), this.prisma.product.findMany({ select: { slug: true, createdAt: true } }),
this.prisma.category.findMany({ this.prisma.category.findMany({ select: { slug: true, createdAt: true } }),
select: { slug: true, createdAt: true }, this.prisma.blog.findMany({ where: { isPublished: true }, select: { slug: true, updatedAt: true } }),
}),
this.prisma.blog.findMany({
where: { isPublished: true },
select: { slug: true, updatedAt: true },
}),
]); ]);
return { return {
products: products.map((p) => ({ products: products.map(p => ({ url: `/shop/${p.slug}`, lastmod: p.createdAt })),
url: `/shop/${p.slug}`, categories: categories.map(c => ({ url: `/shop?category=${c.slug}`, lastmod: c.createdAt })),
lastmod: p.createdAt, blogs: blogs.map(b => ({ url: `/blog/${b.slug}`, lastmod: b.updatedAt })),
})),
categories: categories.map((c) => ({
url: `/shop?category=${c.slug}`,
lastmod: c.createdAt,
})),
blogs: blogs.map((b) => ({
url: `/blog/${b.slug}`,
lastmod: b.updatedAt,
})),
}; };
} }
async getProductSchema(idOrSlug: string) { async getProductSchema(idOrSlug: string) {
const isUuid = 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);
/^[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 product = await this.prisma.product.findFirst({ const product = await this.prisma.product.findFirst({
where: isUuid ? { id: idOrSlug } : { slug: idOrSlug }, where: isUuid ? { id: idOrSlug } : { slug: idOrSlug },
}); });
@ -45,26 +28,26 @@ export class SeoService {
if (!product) return null; if (!product) return null;
return { return {
'@context': 'https://schema.org/', "@context": "https://schema.org/",
'@type': 'Product', "@type": "Product",
name: product.nameFa, "name": product.nameFa,
alternateName: product.nameEn, "alternateName": product.nameEn,
image: [product.imageUrl], "image": [product.imageUrl],
description: product.description, "description": product.description,
sku: product.artNo, "sku": product.artNo,
gtin: product.barcode || undefined, "gtin": product.barcode || undefined,
brand: { "brand": {
'@type': 'Brand', "@type": "Brand",
name: 'Canina Pharma', "name": "Canina Pharma"
},
offers: {
'@type': 'Offer',
url: `https://canino-iran.com/shop/${product.slug}`,
priceCurrency: 'IRR',
price: Number(product.priceValue) * 10, // Toman to Rial conversion if needed
availability: 'https://schema.org/InStock',
itemCondition: 'https://schema.org/NewCondition',
}, },
"offers": {
"@type": "Offer",
"url": `https://canino-iran.com/shop/${product.slug}`,
"priceCurrency": "IRR",
"price": Number(product.priceValue) * 10, // Toman to Rial conversion if needed
"availability": "https://schema.org/InStock",
"itemCondition": "https://schema.org/NewCondition"
}
}; };
} }
} }

View File

@ -17,7 +17,9 @@ describe('SettingsController', () => {
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [SettingsController], controllers: [SettingsController],
providers: [{ provide: SettingsService, useValue: mockSettingsService }], providers: [
{ provide: SettingsService, useValue: mockSettingsService },
],
}).compile(); }).compile();
controller = module.get<SettingsController>(SettingsController); controller = module.get<SettingsController>(SettingsController);

View File

@ -1,24 +1,7 @@
import { import { Controller, Get, Patch, Put, Delete, Body, Param, UseGuards, HttpStatus } from '@nestjs/common';
Controller,
Get,
Patch,
Put,
Delete,
Body,
Param,
UseGuards,
HttpStatus,
} from '@nestjs/common';
import { SettingsService } from './settings.service'; import { SettingsService } from './settings.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiOkResponse, ApiUnauthorizedResponse } from '@nestjs/swagger';
ApiTags,
ApiOperation,
ApiBearerAuth,
ApiResponse,
ApiOkResponse,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
@ApiTags('Settings - تنظیمات متون پویا و واژه‌نامه علمی') @ApiTags('Settings - تنظیمات متون پویا و واژه‌نامه علمی')
@Controller('settings') @Controller('settings')
@ -28,15 +11,14 @@ export class SettingsController {
@Get('ui-texts') @Get('ui-texts')
@ApiOperation({ summary: 'دریافت تمامی متون و پیکربندی‌های رابط کاربری' }) @ApiOperation({ summary: 'دریافت تمامی متون و پیکربندی‌های رابط کاربری' })
@ApiOkResponse({ @ApiOkResponse({
description: description: 'یک آبجکت کلید-مقدار حاوی تمامی متون، شعارها و برچسب‌های دکمه‌ها',
'یک آبجکت کلید-مقدار حاوی تمامی متون، شعارها و برچسب‌های دکمه‌ها',
schema: { schema: {
example: { example: {
hero_badge: 'تخصص دارویی از آلمان', hero_badge: "تخصص دارویی از آلمان",
hero_title: 'تخصص آلمانی در خدمت سلامت پت‌های خانگی', hero_title: "تخصص آلمانی در خدمت سلامت پت‌های خانگی",
hero_desc: 'بیش از ۴۰ سال تجربه نوآورانه...', hero_desc: "بیش از ۴۰ سال تجربه نوآورانه..."
}, }
}, }
}) })
getUiTexts() { getUiTexts() {
return this.settingsService.getUiTexts(); return this.settingsService.getUiTexts();
@ -51,19 +33,13 @@ export class SettingsController {
schema: { schema: {
example: { example: {
key: 'hero_badge', key: 'hero_badge',
value: 'تخصص دارویی ممتاز از آلمان', value: 'تخصص دارویی ممتاز از آلمان'
}, }
}, }
}) })
@ApiUnauthorizedResponse({ @ApiUnauthorizedResponse({
description: 'عدم دسترسی به دلیل عدم احراز هویت', description: 'عدم دسترسی به دلیل عدم احراز هویت',
schema: { schema: { example: { success: false, message: 'Unauthorized', code: 'UNAUTHORIZED' } }
example: {
success: false,
message: 'Unauthorized',
code: 'UNAUTHORIZED',
},
},
}) })
updateUiText(@Param('key') key: string, @Body('value') value: string) { updateUiText(@Param('key') key: string, @Body('value') value: string) {
return this.settingsService.updateUiText(key, value); return this.settingsService.updateUiText(key, value);
@ -79,10 +55,10 @@ export class SettingsController {
key: 'green-mussel', key: 'green-mussel',
term: 'صدف لب‌سبز (Perna Canaliculus)', term: 'صدف لب‌سبز (Perna Canaliculus)',
definition: 'این صدف بومی سواحل بکر نیوزیلند است...', definition: 'این صدف بومی سواحل بکر نیوزیلند است...',
wikiId: 'general', wikiId: 'general'
}, }
], ]
}, }
}) })
getScientificTerms() { getScientificTerms() {
return this.settingsService.getScientificTerms(); return this.settingsService.getScientificTerms();
@ -99,19 +75,13 @@ export class SettingsController {
key: 'green-mussel', key: 'green-mussel',
term: 'صدف لب‌سبز اصل نیوزیلند', term: 'صدف لب‌سبز اصل نیوزیلند',
definition: 'این صدف بومی سواحل بکر نیوزیلند است...', definition: 'این صدف بومی سواحل بکر نیوزیلند است...',
wikiId: 'general', wikiId: 'general'
}, }
}, }
}) })
@ApiUnauthorizedResponse({ @ApiUnauthorizedResponse({
description: 'عدم دسترسی', description: 'عدم دسترسی',
schema: { schema: { example: { success: false, message: 'Unauthorized', code: 'UNAUTHORIZED' } }
example: {
success: false,
message: 'Unauthorized',
code: 'UNAUTHORIZED',
},
},
}) })
upsertScientificTerm(@Param('key') key: string, @Body() data: any) { upsertScientificTerm(@Param('key') key: string, @Body() data: any) {
return this.settingsService.upsertScientificTerm(key, data); return this.settingsService.upsertScientificTerm(key, data);
@ -126,19 +96,13 @@ export class SettingsController {
schema: { schema: {
example: { example: {
success: true, success: true,
message: 'Scientific term successfully deleted', message: 'Scientific term successfully deleted'
}, }
}, }
}) })
@ApiUnauthorizedResponse({ @ApiUnauthorizedResponse({
description: 'عدم دسترسی', description: 'عدم دسترسی',
schema: { schema: { example: { success: false, message: 'Unauthorized', code: 'UNAUTHORIZED' } }
example: {
success: false,
message: 'Unauthorized',
code: 'UNAUTHORIZED',
},
},
}) })
deleteScientificTerm(@Param('key') key: string) { deleteScientificTerm(@Param('key') key: string) {
return this.settingsService.deleteScientificTerm(key); return this.settingsService.deleteScientificTerm(key);

View File

@ -8,13 +8,9 @@ describe('UsersController', () => {
const mockUsersService = { const mockUsersService = {
findById: jest.fn().mockResolvedValue({ id: 'user-id', firstName: 'Test' }), findById: jest.fn().mockResolvedValue({ id: 'user-id', firstName: 'Test' }),
update: jest update: jest.fn().mockResolvedValue({ id: 'user-id', firstName: 'Updated' }),
.fn()
.mockResolvedValue({ id: 'user-id', firstName: 'Updated' }),
addAddress: jest.fn().mockResolvedValue({ id: 'addr-id', title: 'Home' }), addAddress: jest.fn().mockResolvedValue({ id: 'addr-id', title: 'Home' }),
updateAddress: jest updateAddress: jest.fn().mockResolvedValue({ id: 'addr-id', title: 'Work' }),
.fn()
.mockResolvedValue({ id: 'addr-id', title: 'Work' }),
deleteAddress: jest.fn().mockResolvedValue({ success: true }), deleteAddress: jest.fn().mockResolvedValue({ success: true }),
setDefaultAddress: jest.fn().mockResolvedValue({ success: true }), setDefaultAddress: jest.fn().mockResolvedValue({ success: true }),
}; };
@ -22,7 +18,9 @@ describe('UsersController', () => {
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController], controllers: [UsersController],
providers: [{ provide: UsersService, useValue: mockUsersService }], providers: [
{ provide: UsersService, useValue: mockUsersService },
],
}).compile(); }).compile();
controller = module.get<UsersController>(UsersController); controller = module.get<UsersController>(UsersController);
@ -80,11 +78,7 @@ describe('UsersController', () => {
zipCode: '123', zipCode: '123',
}; };
const result = await controller.updateAddress(req, 'addr-id', dto); const result = await controller.updateAddress(req, 'addr-id', dto);
expect(service.updateAddress).toHaveBeenCalledWith( expect(service.updateAddress).toHaveBeenCalledWith('user-id', 'addr-id', dto);
'user-id',
'addr-id',
dto,
);
expect(result.title).toBe('Work'); expect(result.title).toBe('Work');
}); });
@ -98,10 +92,7 @@ describe('UsersController', () => {
it('should setDefaultAddress', async () => { it('should setDefaultAddress', async () => {
const req = { user: { id: 'user-id' } }; const req = { user: { id: 'user-id' } };
const result = await controller.setDefaultAddress(req, 'addr-id'); const result = await controller.setDefaultAddress(req, 'addr-id');
expect(service.setDefaultAddress).toHaveBeenCalledWith( expect(service.setDefaultAddress).toHaveBeenCalledWith('user-id', 'addr-id');
'user-id',
'addr-id',
);
expect(result).toBeDefined(); expect(result).toBeDefined();
}); });
}); });

View File

@ -1,27 +1,7 @@
import { import { Controller, Get, Patch, Post, Delete, Body, Param, UseGuards, Req, HttpStatus, BadRequestException } from '@nestjs/common';
Controller,
Get,
Patch,
Post,
Delete,
Body,
Param,
UseGuards,
Req,
HttpStatus,
BadRequestException,
} from '@nestjs/common';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiOkResponse, ApiCreatedResponse, ApiBadRequestResponse } from '@nestjs/swagger';
ApiTags,
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiOkResponse,
ApiCreatedResponse,
ApiBadRequestResponse,
} from '@nestjs/swagger';
import { UpdateProfileDto } from './dto/update-profile.dto'; import { UpdateProfileDto } from './dto/update-profile.dto';
import { AddressDto } from './dto/address.dto'; import { AddressDto } from './dto/address.dto';
@ -36,9 +16,9 @@ import { AddressDto } from './dto/address.dto';
success: false, success: false,
message: 'Unauthorized', message: 'Unauthorized',
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
details: {}, details: {}
}, }
}, }
}) })
export class UsersController { export class UsersController {
constructor(private readonly usersService: UsersService) {} constructor(private readonly usersService: UsersService) {}
@ -61,9 +41,9 @@ export class UsersController {
addresses: [], addresses: [],
pets: [], pets: [],
createdAt: '2026-05-26T15:20:00.000Z', createdAt: '2026-05-26T15:20:00.000Z',
updatedAt: '2026-05-26T15:20:00.000Z', updatedAt: '2026-05-26T15:20:00.000Z'
}, }
}, }
}) })
getProfile(@Req() req: any) { getProfile(@Req() req: any) {
return this.usersService.findById(req.user.id); return this.usersService.findById(req.user.id);
@ -83,9 +63,9 @@ export class UsersController {
mobile: '09123456789', mobile: '09123456789',
role: 'User_PetOwner', role: 'User_PetOwner',
walletBalance: '1500000.00', walletBalance: '1500000.00',
charityDonationTotal: '25000.00', charityDonationTotal: '25000.00'
}, }
}, }
}) })
@ApiBadRequestResponse({ @ApiBadRequestResponse({
description: 'خطا در صحت‌سنجی فیلدهای ورودی', description: 'خطا در صحت‌سنجی فیلدهای ورودی',
@ -95,10 +75,10 @@ export class UsersController {
message: 'ایمیل نامعتبر است', message: 'ایمیل نامعتبر است',
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
details: { details: {
message: ['ایمیل نامعتبر است'], message: ['ایمیل نامعتبر است']
}, }
}, }
}, }
}) })
updateProfile(@Req() req: any, @Body() updateProfileDto: UpdateProfileDto) { updateProfile(@Req() req: any, @Body() updateProfileDto: UpdateProfileDto) {
return this.usersService.update(req.user.id, updateProfileDto); return this.usersService.update(req.user.id, updateProfileDto);
@ -121,9 +101,9 @@ export class UsersController {
detail: 'خیابان آزادی، کوچه مریم، پلاک ۱۰', detail: 'خیابان آزادی، کوچه مریم، پلاک ۱۰',
zipCode: '1456789012', zipCode: '1456789012',
isDefault: false, isDefault: false,
createdAt: '2026-05-26T18:00:00.000Z', createdAt: '2026-05-26T18:00:00.000Z'
}, }
}, }
}) })
addAddress(@Req() req: any, @Body() addressDto: AddressDto) { addAddress(@Req() req: any, @Body() addressDto: AddressDto) {
return this.usersService.addAddress(req.user.id, addressDto); return this.usersService.addAddress(req.user.id, addressDto);
@ -146,9 +126,9 @@ export class UsersController {
detail: 'خیابان ولیعصر، برج سپهر، طبقه ۴', detail: 'خیابان ولیعصر، برج سپهر، طبقه ۴',
zipCode: '1456789012', zipCode: '1456789012',
isDefault: false, isDefault: false,
createdAt: '2026-05-26T18:00:00.000Z', createdAt: '2026-05-26T18:00:00.000Z'
}, }
}, }
}) })
updateAddress( updateAddress(
@Req() req: any, @Req() req: any,
@ -166,9 +146,9 @@ export class UsersController {
schema: { schema: {
example: { example: {
success: true, success: true,
message: 'آدرس با موفقیت حذف شد', message: 'آدرس با موفقیت حذف شد'
}, }
}, }
}) })
deleteAddress(@Req() req: any, @Param('addressId') addressId: string) { deleteAddress(@Req() req: any, @Param('addressId') addressId: string) {
return this.usersService.deleteAddress(req.user.id, addressId); return this.usersService.deleteAddress(req.user.id, addressId);
@ -182,9 +162,9 @@ export class UsersController {
schema: { schema: {
example: { example: {
success: true, success: true,
message: 'آدرس پیش‌فرض با موفقیت تغییر کرد', message: 'آدرس پیش‌فرض با موفقیت تغییر کرد'
}, }
}, }
}) })
setDefaultAddress(@Req() req: any, @Param('addressId') addressId: string) { setDefaultAddress(@Req() req: any, @Param('addressId') addressId: string) {
return this.usersService.setDefaultAddress(req.user.id, addressId); return this.usersService.setDefaultAddress(req.user.id, addressId);
@ -203,9 +183,9 @@ export class UsersController {
type: 'deposit', type: 'deposit',
status: 'completed', status: 'completed',
description: 'شارژ کیف پول', description: 'شارژ کیف پول',
createdAt: '2026-07-11T08:00:00.000Z', createdAt: '2026-07-11T08:00:00.000Z'
}, }
}, }
}) })
@ApiBadRequestResponse({ description: 'مبلغ نامعتبر است' }) @ApiBadRequestResponse({ description: 'مبلغ نامعتبر است' })
async topUpWallet(@Req() req: any, @Body() body: { amount: number }) { async topUpWallet(@Req() req: any, @Body() body: { amount: number }) {

View File

@ -57,11 +57,8 @@ describe('UsersService', () => {
it('should addAddress', async () => { it('should addAddress', async () => {
const addressData = { title: 'Home', isDefault: true }; const addressData = { title: 'Home', isDefault: true };
mockPrisma.userAddress.create.mockResolvedValue({ mockPrisma.userAddress.create.mockResolvedValue({ id: 'addr-id', ...addressData });
id: 'addr-id',
...addressData,
});
const result = await service.addAddress('user-id', addressData); const result = await service.addAddress('user-id', addressData);
expect(prisma.userAddress.updateMany).toHaveBeenCalledWith({ expect(prisma.userAddress.updateMany).toHaveBeenCalledWith({
where: { userId: 'user-id' }, where: { userId: 'user-id' },
@ -73,16 +70,9 @@ describe('UsersService', () => {
it('should updateAddress', async () => { it('should updateAddress', async () => {
const addressData = { title: 'Work', isDefault: true }; const addressData = { title: 'Work', isDefault: true };
mockPrisma.userAddress.update.mockResolvedValue({ mockPrisma.userAddress.update.mockResolvedValue({ id: 'addr-id', ...addressData });
id: 'addr-id',
...addressData,
});
const result = await service.updateAddress( const result = await service.updateAddress('user-id', 'addr-id', addressData);
'user-id',
'addr-id',
addressData,
);
expect(prisma.userAddress.updateMany).toHaveBeenCalled(); expect(prisma.userAddress.updateMany).toHaveBeenCalled();
expect(prisma.userAddress.update).toHaveBeenCalled(); expect(prisma.userAddress.update).toHaveBeenCalled();
expect(result.title).toBe('Work'); expect(result.title).toBe('Work');
@ -96,10 +86,7 @@ describe('UsersService', () => {
}); });
it('should setDefaultAddress', async () => { it('should setDefaultAddress', async () => {
mockPrisma.userAddress.update.mockResolvedValue({ mockPrisma.userAddress.update.mockResolvedValue({ id: 'addr-id', isDefault: true });
id: 'addr-id',
isDefault: true,
});
const result = await service.setDefaultAddress('user-id', 'addr-id'); const result = await service.setDefaultAddress('user-id', 'addr-id');
expect(prisma.userAddress.updateMany).toHaveBeenCalledWith({ expect(prisma.userAddress.updateMany).toHaveBeenCalledWith({
where: { userId: 'user-id' }, where: { userId: 'user-id' },

View File

@ -8,32 +8,32 @@ export class UsersService {
async findById(id: string) { async findById(id: string) {
return this.prisma.user.findUnique({ return this.prisma.user.findUnique({
where: { id }, where: { id },
include: { include: {
pets: { pets: {
include: { include: {
medicalConditions: true, medicalConditions: true,
reminders: { reminders: {
include: { include: {
completions: true, completions: true
}, }
}, },
healthLogs: true, healthLogs: true,
}, }
}, },
orders: { orders: {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { include: {
orderItems: { orderItems: {
include: { product: true }, include: { product: true }
}, }
}, }
}, },
addresses: true, addresses: true,
walletTransactions: { walletTransactions: {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 50, take: 50
}, }
}, }
}); });
} }
@ -46,13 +46,13 @@ export class UsersService {
orders: { orders: {
include: { include: {
orderItems: { orderItems: {
include: { product: true }, include: { product: true }
}, }
}, }
}, },
addresses: true, addresses: true,
walletTransactions: true, walletTransactions: true
}, }
}); });
} }

View File

@ -2,18 +2,12 @@ import { IsString, IsNotEmpty, IsOptional, IsBoolean } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateVideoDto { export class CreateVideoDto {
@ApiProperty({ @ApiProperty({ description: 'عنوان ویدئوی آموزشی', example: 'نحوه آماده‌سازی کانی‌هیدروکس GAG' })
description: 'عنوان ویدئوی آموزشی',
example: 'نحوه آماده‌سازی کانی‌هیدروکس GAG',
})
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
title: string; title: string;
@ApiProperty({ @ApiProperty({ description: 'نام دکتر/ارائه‌دهنده', example: 'دکتر کلاوس هنینگ' })
description: 'نام دکتر/ارائه‌دهنده',
example: 'دکتر کلاوس هنینگ',
})
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
doctor: string; doctor: string;
@ -23,18 +17,12 @@ export class CreateVideoDto {
@IsOptional() @IsOptional()
duration?: string; duration?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({ description: 'آدرس تصویر کاور', example: 'https://example.com/thumb.jpg' })
description: 'آدرس تصویر کاور',
example: 'https://example.com/thumb.jpg',
})
@IsString() @IsString()
@IsOptional() @IsOptional()
thumbnail?: string; thumbnail?: string;
@ApiProperty({ @ApiProperty({ description: 'آدرس فایل ویدئو', example: 'https://example.com/video.mp4' })
description: 'آدرس فایل ویدئو',
example: 'https://example.com/video.mp4',
})
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
videoUrl: string; videoUrl: string;
@ -44,10 +32,7 @@ export class CreateVideoDto {
@IsOptional() @IsOptional()
description?: string; description?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({ description: 'آیا ویدئوی ویژه/صفحه اصلی است؟', example: true })
description: 'آیا ویدئوی ویژه/صفحه اصلی است؟',
example: true,
})
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isFeatured?: boolean; isFeatured?: boolean;

View File

@ -1,23 +1,8 @@
import { import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
} from '@nestjs/common';
import { VideosService } from './videos.service'; import { VideosService } from './videos.service';
import { CreateVideoDto } from './dto/create-video.dto'; import { CreateVideoDto } from './dto/create-video.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { import { ApiTags, ApiOperation, ApiQuery, ApiBearerAuth } from '@nestjs/swagger';
ApiTags,
ApiOperation,
ApiQuery,
ApiBearerAuth,
} from '@nestjs/swagger';
@ApiTags('Videos - مشاوره ویدئویی و آکادمی') @ApiTags('Videos - مشاوره ویدئویی و آکادمی')
@Controller('videos') @Controller('videos')
@ -52,10 +37,7 @@ export class VideosController {
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ summary: 'ویرایش ویدئو (ادمین)' }) @ApiOperation({ summary: 'ویرایش ویدئو (ادمین)' })
update( update(@Param('id') id: string, @Body() updateVideoDto: Partial<CreateVideoDto>) {
@Param('id') id: string,
@Body() updateVideoDto: Partial<CreateVideoDto>,
) {
return this.videosService.update(id, updateVideoDto); return this.videosService.update(id, updateVideoDto);
} }

View File

@ -56,12 +56,10 @@ export class VideosService {
throw new NotFoundException('ویدئوی مورد نظر یافت نشد'); throw new NotFoundException('ویدئوی مورد نظر یافت نشد');
} }
if (incrementView) { if (incrementView) {
await this.video await this.video.update({
.update({ where: { id },
where: { id }, data: { viewsCount: { increment: 1 } },
data: { viewsCount: { increment: 1 } }, }).catch(() => {});
})
.catch(() => {});
} }
return video; return video;
} }

View File

@ -1,13 +1,4 @@
import { import { Controller, Post, Get, Put, Body, Param, UseGuards, Request } from '@nestjs/common';
Controller,
Post,
Get,
Put,
Body,
Param,
UseGuards,
Request,
} from '@nestjs/common';
import { WholesaleService } from './wholesale.service'; import { WholesaleService } from './wholesale.service';
import { WholesaleApplyDto } from './dto/wholesale-apply.dto'; import { WholesaleApplyDto } from './dto/wholesale-apply.dto';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
@ -23,9 +14,7 @@ export class WholesaleController {
@Post('apply') @Post('apply')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ @ApiOperation({ summary: 'ثبت درخواست همکاری عمده‌فروشی (ارسال پروانه کلینیک/داروخانه)' })
summary: 'ثبت درخواست همکاری عمده‌فروشی (ارسال پروانه کلینیک/داروخانه)',
})
applyForWholesale(@Request() req: any, @Body() dto: WholesaleApplyDto) { applyForWholesale(@Request() req: any, @Body() dto: WholesaleApplyDto) {
return this.wholesaleService.applyForWholesale(req.user.id, dto); return this.wholesaleService.applyForWholesale(req.user.id, dto);
} }
@ -34,9 +23,7 @@ export class WholesaleController {
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN') @Roles('ADMIN')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ @ApiOperation({ summary: 'لیست تمام درخواست‌های همکاری عمده‌فروشی (مخصوص ادمین)' })
summary: 'لیست تمام درخواست‌های همکاری عمده‌فروشی (مخصوص ادمین)',
})
getWholesaleRequests() { getWholesaleRequests() {
return this.wholesaleService.getWholesaleRequests(); return this.wholesaleService.getWholesaleRequests();
} }
@ -45,9 +32,7 @@ export class WholesaleController {
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN') @Roles('ADMIN')
@ApiBearerAuth() @ApiBearerAuth()
@ApiOperation({ @ApiOperation({ summary: 'تایید درخواست و ارتقا به خریدار عمده (User_Wholesale)' })
summary: 'تایید درخواست و ارتقا به خریدار عمده (User_Wholesale)',
})
approveWholesaleRequest(@Param('userId') userId: string) { approveWholesaleRequest(@Param('userId') userId: string) {
return this.wholesaleService.approveWholesaleRequest(userId); return this.wholesaleService.approveWholesaleRequest(userId);
} }

View File

@ -20,8 +20,7 @@ export class WholesaleService {
return { return {
success: true, success: true,
message: message: 'درخواست همکاری عمده‌فروشی با موفقیت ثبت شد و در حال بررسی توسط ادمین است.',
'درخواست همکاری عمده‌فروشی با موفقیت ثبت شد و در حال بررسی توسط ادمین است.',
user: { user: {
id: updatedUser.id, id: updatedUser.id,
role: updatedUser.role, role: updatedUser.role,
@ -48,8 +47,7 @@ export class WholesaleService {
return { return {
success: true, success: true,
message: message: 'حساب کاربری با موفقیت به خریدار عمده (User_Wholesale) ارتقا یافت.',
'حساب کاربری با موفقیت به خریدار عمده (User_Wholesale) ارتقا یافت.',
user: updated, user: updated,
}; };
} }

View File

@ -1,19 +1,13 @@
import { Controller, Get, Param, HttpStatus, Query } from '@nestjs/common'; import { Controller, Get, Param, HttpStatus, Query } from '@nestjs/common';
import { WikiService } from './wiki.service'; import { WikiService } from './wiki.service';
import { import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse, ApiNotFoundResponse } from '@nestjs/swagger';
ApiTags,
ApiOperation,
ApiResponse,
ApiOkResponse,
ApiNotFoundResponse,
} from '@nestjs/swagger';
import { PaginationDto } from '../common/dto/pagination.dto'; import { PaginationDto } from '../common/dto/pagination.dto';
@ApiTags('Wiki - دانشنامه ترکیبات') @ApiTags('Wiki - دانشنامه ترکیبات')
@Controller('wiki') @Controller('wiki')
@ApiResponse({ @ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR, status: HttpStatus.INTERNAL_SERVER_ERROR,
description: 'خطای داخلی سرور', description: 'خطای داخلی سرور'
}) })
export class WikiController { export class WikiController {
constructor(private readonly wikiService: WikiService) {} constructor(private readonly wikiService: WikiService) {}

View File

@ -7,16 +7,10 @@ export class WikiService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
async findAll(filters: PaginationDto) { async findAll(filters: PaginationDto) {
const { const { search, page = 1, limit = 10, sortBy: rawSortBy = 'term', sortOrder = 'asc' } = filters;
search,
page = 1,
limit = 10,
sortBy: rawSortBy = 'term',
sortOrder = 'asc',
} = filters;
const allowedSortFields = ['key', 'term', 'wikiId']; const allowedSortFields = ['key', 'term', 'wikiId'];
const sortBy = allowedSortFields.includes(rawSortBy) ? rawSortBy : 'term'; const sortBy = allowedSortFields.includes(rawSortBy) ? rawSortBy : 'term';
const whereClause: any = {}; const whereClause: any = {};
if (search) { if (search) {
whereClause.OR = [ whereClause.OR = [
@ -34,7 +28,7 @@ export class WikiService {
take: limit, take: limit,
orderBy: { [sortBy]: sortOrder }, orderBy: { [sortBy]: sortOrder },
}), }),
this.prisma.scientificTerm.count({ where: whereClause }), this.prisma.scientificTerm.count({ where: whereClause })
]); ]);
return { return {
@ -44,13 +38,13 @@ export class WikiService {
page, page,
lastPage: Math.ceil(total / limit), lastPage: Math.ceil(total / limit),
limit, limit,
}, }
}; };
} }
async findOneByKey(key: string) { async findOneByKey(key: string) {
const term = await this.prisma.scientificTerm.findUnique({ const term = await this.prisma.scientificTerm.findUnique({
where: { key }, where: { key }
}); });
if (!term) throw new NotFoundException('Wiki term not found'); if (!term) throw new NotFoundException('Wiki term not found');
return term; return term;

View File

@ -2,7 +2,7 @@ import axios from 'axios';
export const BASE_DOMAIN = import.meta.env.VITE_API_URL export const BASE_DOMAIN = import.meta.env.VITE_API_URL
? import.meta.env.VITE_API_URL.replace(/\/api$/, '') ? import.meta.env.VITE_API_URL.replace(/\/api$/, '')
: (import.meta.env.DEV ? 'http://127.0.0.1:4001' : ''); : (import.meta.env.DEV ? 'http://127.0.0.1:4001' : 'https://api.canina.ir');
const baseURL = `${BASE_DOMAIN}/api`; const baseURL = `${BASE_DOMAIN}/api`;

View File

@ -1,6 +1,6 @@
import axios from 'axios'; import axios from 'axios';
const baseURL = process.env.NEXT_PUBLIC_API_URL || (process.env.NODE_ENV === 'development' ? 'http://localhost:4001/api' : '/api'); const baseURL = process.env.NEXT_PUBLIC_API_URL || (process.env.NODE_ENV === 'development' ? 'http://localhost:4001/api' : 'https://api.canina.ir/api');
const api = axios.create({ const api = axios.create({
baseURL, baseURL,

View File

@ -15,18 +15,13 @@ const nextConfig: NextConfig = {
], ],
}, },
async rewrites() { async rewrites() {
// Only proxy /api in development; in production nginx handles it return [
if (process.env.NODE_ENV === 'development') { {
return [ source: '/api/:path*',
{ destination: 'http://localhost:4001/api/:path*',
source: '/api/:path*', },
destination: 'http://localhost:4001/api/:path*', ];
},
];
}
return [];
}, },
}; };
export default nextConfig; export default nextConfig;

View File

@ -27,7 +27,6 @@ server {
proxy_set_header Connection 'upgrade'; proxy_set_header Connection 'upgrade';
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
client_max_body_size 50M;
} }
} }
@ -58,6 +57,5 @@ server {
proxy_set_header Connection 'upgrade'; proxy_set_header Connection 'upgrade';
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade; proxy_cache_bypass $http_upgrade;
client_max_body_size 50M;
} }
} }

View File

@ -1,74 +0,0 @@
version: '3.8'
services:
db_prod:
image: postgres:16-alpine
container_name: canino_db_prod
restart: always
environment:
POSTGRES_USER: canino_prod
POSTGRES_PASSWORD: caninopassword_prod
POSTGRES_DB: caninodb_prod
volumes:
- canina_prod_db:/var/lib/postgresql/data
networks:
- traefik_public
redis_prod:
image: redis:7-alpine
container_name: canino_redis_prod
restart: always
networks:
- traefik_public
backend_prod:
build:
context: ./backend
dockerfile: Dockerfile
container_name: canino_backend_prod
restart: always
environment:
- DATABASE_URL=postgresql://canino_prod:caninopassword_prod@db_prod:5432/caninodb_prod?schema=public
- REDIS_HOST=redis_prod
- REDIS_PORT=6379
- PORT=3000
- PRISMA_SCHEMA_ENGINE_BINARY=/app/node_modules/@prisma/engines/schema-engine-linux-musl-openssl-3.0.x
- PRISMA_QUERY_ENGINE_LIBRARY=/app/node_modules/@prisma/engines/libquery_engine-linux-musl-openssl-3.0.x.so.node
labels:
- "traefik.enable=true"
- "traefik.http.routers.canina-api-prod.rule=Host(`api.canina.ir`)"
- "traefik.http.routers.canina-api-prod.entrypoints=websecure"
- "traefik.http.routers.canina-api-prod.tls=true"
- "traefik.http.services.canina-api-prod.loadbalancer.server.port=3000"
depends_on:
- db_prod
- redis_prod
networks:
- traefik_public
frontend_prod:
build:
context: .
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: "https://api.canina.ir/api"
VITE_API_URL: "https://api.canina.ir"
container_name: canino_frontend_prod
restart: always
labels:
- "traefik.enable=true"
- "traefik.http.routers.canina-prod.rule=Host(`canina.ir`) || Host(`admin.canina.ir`)"
- "traefik.http.routers.canina-prod.entrypoints=websecure"
- "traefik.http.routers.canina-prod.tls=true"
- "traefik.http.services.canina-prod.loadbalancer.server.port=8080"
depends_on:
- backend_prod
networks:
- traefik_public
networks:
traefik_public:
external: true
volumes:
canina_prod_db:

View File

@ -1,74 +0,0 @@
version: '3.8'
services:
db_stage:
image: postgres:16-alpine
container_name: canino_db_stage
restart: always
environment:
POSTGRES_USER: canino_stage
POSTGRES_PASSWORD: caninopassword_stage
POSTGRES_DB: caninodb_stage
volumes:
- canina_stage_db:/var/lib/postgresql/data
networks:
- traefik_public
redis_stage:
image: redis:7-alpine
container_name: canino_redis_stage
restart: always
networks:
- traefik_public
backend_stage:
build:
context: ./backend
dockerfile: Dockerfile
container_name: canino_backend_stage
restart: always
environment:
- DATABASE_URL=postgresql://canino_stage:caninopassword_stage@db_stage:5432/caninodb_stage?schema=public
- REDIS_HOST=redis_stage
- REDIS_PORT=6379
- PORT=3000
- PRISMA_SCHEMA_ENGINE_BINARY=/app/node_modules/@prisma/engines/schema-engine-linux-musl-openssl-3.0.x
- PRISMA_QUERY_ENGINE_LIBRARY=/app/node_modules/@prisma/engines/libquery_engine-linux-musl-openssl-3.0.x.so.node
labels:
- "traefik.enable=true"
- "traefik.http.routers.canina-api-stage.rule=Host(`stageapi.canina.ir`)"
- "traefik.http.routers.canina-api-stage.entrypoints=websecure"
- "traefik.http.routers.canina-api-stage.tls=true"
- "traefik.http.services.canina-api-stage.loadbalancer.server.port=3000"
depends_on:
- db_stage
- redis_stage
networks:
- traefik_public
frontend_stage:
build:
context: .
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: "https://stageapi.canina.ir/api"
VITE_API_URL: "https://stageapi.canina.ir"
container_name: canino_frontend_stage
restart: always
labels:
- "traefik.enable=true"
- "traefik.http.routers.canina-stage.rule=Host(`stage.canina.ir`) || Host(`stageadmin.canina.ir`)"
- "traefik.http.routers.canina-stage.entrypoints=websecure"
- "traefik.http.routers.canina-stage.tls=true"
- "traefik.http.services.canina-stage.loadbalancer.server.port=8080"
depends_on:
- backend_stage
networks:
- traefik_public
networks:
traefik_public:
external: true
volumes:
canina_stage_db:

View File

@ -1,77 +0,0 @@
#!/bin/bash
set -e
export DOCKER_BUILDKIT=1
export COMPOSE_DOCKER_CLI_BUILD=1
BRANCH="${1:-develop}"
echo "Starting Deployment for branch: ${BRANCH}"
if [ "$BRANCH" == "develop" ]; then
COMPOSE_FILE="compose.stage.yml"
TARGET_DIR="/opt/services/canina/stage"
BACKEND_HOST="backend_stage"
APP_HOST="stage.canina.ir"
ADMIN_HOST="stageadmin.canina.ir"
API_URL="https://stageapi.canina.ir"
CONTAINER_PREFIX="canino_backend_stage"
else
COMPOSE_FILE="compose.prod.yml"
TARGET_DIR="/opt/services/canina/prod"
BACKEND_HOST="backend_prod"
APP_HOST="canina.ir"
ADMIN_HOST="admin.canina.ir"
API_URL="https://api.canina.ir"
CONTAINER_PREFIX="canino_backend_prod"
fi
mkdir -p "$TARGET_DIR"
cd "$TARGET_DIR"
if [ ! -d ".git" ]; then
echo "Cloning repository..."
git clone -b "$BRANCH" https://git.parsaaghayi.ir/parsa/canina.git .
else
echo "Pulling latest changes..."
git fetch origin "$BRANCH"
git reset --hard "origin/$BRANCH"
fi
echo "Fixing .dockerignore..."
sed -i '/node_modules/d' .dockerignore 2>/dev/null || true
sed -i '/node_modules/d' backend/.dockerignore 2>/dev/null || true
sed -i '/^backend$/d' .dockerignore 2>/dev/null || true
echo "Disabling TS checks for fast deploy..."
sed -i 's|tsc -b \&\& vite build|vite build|g' frontend/admin-panel/package.json 2>/dev/null || true
sed -i 's|tsc \&\& vite build|vite build|g' frontend/admin-panel/package.json 2>/dev/null || true
# Copy scientificTerms.ts if needed
cp frontend/application/lib/data/scientificTerms.ts backend/prisma/scientificTerms.ts 2>/dev/null || true
# Prepare docker-compose.yml in TARGET_DIR
cp "/opt/services/canina/$COMPOSE_FILE" "$TARGET_DIR/docker-compose.yml"
sed -i 's/- REDIS_PORT: 6379/- REDIS_PORT=6379/g' "$TARGET_DIR/docker-compose.yml" 2>/dev/null || true
# Prepare nginx.conf for current environment
echo "Patching nginx.conf for environment..."
cp nginx.conf nginx.conf.tmp
sed -i "s/__BACKEND_HOST__/${BACKEND_HOST}/g" nginx.conf.tmp
sed -i "s/__APP_HOST__/${APP_HOST}/g" nginx.conf.tmp
sed -i "s/__ADMIN_HOST__/${ADMIN_HOST}/g" nginx.conf.tmp
mv nginx.conf.tmp nginx.conf
echo "Building Docker images..."
docker compose build --build-arg NEXT_PUBLIC_API_URL="${API_URL}/api" --build-arg VITE_API_URL="${API_URL}"
echo "Starting containers..."
docker compose up -d
echo "Waiting for backend to start..."
sleep 15
echo "Running migrations and seeding database..."
docker exec "$CONTAINER_PREFIX" npx prisma migrate deploy || true
docker exec "$CONTAINER_PREFIX" npx prisma db seed || true
echo "Deployment for $BRANCH completed successfully!"