merge: sync develop fixes into main
Some checks failed
Deploy Canina / deploy (push) Failing after 9m25s
Some checks failed
Deploy Canina / deploy (push) Failing after 9m25s
This commit is contained in:
commit
a767e9a6b8
1
.gitignore
vendored
1
.gitignore
vendored
@ -11,3 +11,4 @@ coverage/
|
|||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
test ci trigger
|
||||||
|
|||||||
@ -5,6 +5,8 @@ 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
|
||||||
@ -13,6 +15,8 @@ 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
|
||||||
|
|||||||
@ -4,17 +4,21 @@ 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 --from=builder /app/package*.json ./
|
COPY --chown=node:node --from=builder /app/package*.json ./
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --chown=node:node --from=builder /app/tsconfig.json ./tsconfig.json
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --chown=node:node --from=builder /app/node_modules ./node_modules
|
||||||
COPY --from=builder /app/prisma ./prisma
|
COPY --chown=node:node --from=builder /app/dist ./dist
|
||||||
COPY --from=builder /app/prisma/scientificTerms.ts ./prisma/scientificTerms.ts
|
COPY --chown=node:node --from=builder /app/prisma ./prisma
|
||||||
|
COPY --chown=node:node --from=builder /app/prisma/tsconfig.seed.json ./prisma/tsconfig.seed.json
|
||||||
|
COPY --chown=node:node --from=builder /app/prisma/scientificTerms.ts ./prisma/scientificTerms.ts
|
||||||
|
ENV PRISMA_CLI_BINARY_TARGETS=linux-musl-openssl-3.0.x
|
||||||
EXPOSE 3000
|
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"]
|
||||||
|
|||||||
@ -88,6 +88,6 @@
|
|||||||
"testEnvironment": "node"
|
"testEnvironment": "node"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "ts-node prisma/seed.ts"
|
"seed": "ts-node --project prisma/tsconfig.seed.json prisma/seed.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function main() {
|
export async function main() {
|
||||||
console.log('Seeding Home Page Components...');
|
console.log('Seeding Home Page Components...');
|
||||||
|
|
||||||
// 1. Seed Hero Banners
|
// 1. Seed Hero Banners
|
||||||
|
|||||||
@ -15,8 +15,13 @@ 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 = text.includes('گربه') || text.includes('katzen') || text.includes('cat');
|
const hasCat =
|
||||||
const hasDog = text.includes('سگ') || text.includes('hund') || text.includes('توله') || text.includes('dog');
|
text.includes('گربه') || text.includes('katzen') || text.includes('cat');
|
||||||
|
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 'سگ';
|
||||||
@ -35,49 +40,81 @@ 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': 'https://www.canina.de/media/4c/32/38/1715077271/Ballaststoff-Mix-140108-V1-Canino-100g_600x600.webp',
|
'canina-ballaststoff-mix':
|
||||||
'canina-canhydrox-gag': 'https://www.canina.de/media/83/86/e1/123000_123005_Canhydrox_GAG_Canina-Pharma_1280x1280.png',
|
'https://www.canina.de/media/4c/32/38/1715077271/Ballaststoff-Mix-140108-V1-Canino-100g_600x600.webp',
|
||||||
'canina-eierschalenpulver': 'https://www.canina.de/media/fb/d3/18/120208_Eierschalenpulver_Canina-Pharma_1280x1280.png',
|
'canina-canhydrox-gag':
|
||||||
'canina-flexan': 'https://www.canina.de/media/01/be/93/710003_Flexan_Canina-Pharma_1280x1280.png',
|
'https://www.canina.de/media/83/86/e1/123000_123005_Canhydrox_GAG_Canina-Pharma_1280x1280.png',
|
||||||
'canina-herz-vital': 'https://www.canina.de/media/b9/8b/4c/112036_Herz_Vital_Canina-Pharma_1280x1280.png',
|
'canina-eierschalenpulver':
|
||||||
'canina-immun-booster-paste': 'https://www.canina.de/media/8c/fb/8e/311000_Canino_Immun_Booster_Paste_Abwehrkraefte_1280x1280.png',
|
'https://www.canina.de/media/fb/d3/18/120208_Eierschalenpulver_Canina-Pharma_1280x1280.png',
|
||||||
'canina-katzenmilch': 'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
|
'canina-flexan':
|
||||||
'canina-lachs-l': '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-ol': 'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
|
'canina-herz-vital':
|
||||||
'canina-marine-lmischung-premium': 'https://www.canina.de/media/ad/28/71/153008_Marine_Oelmischung_Premium_Canina-Pharma_1280x1280.png',
|
'https://www.canina.de/media/b9/8b/4c/112036_Herz_Vital_Canina-Pharma_1280x1280.png',
|
||||||
'canina-moortrnke': 'https://www.canina.de/media/9d/b2/87/112708_Moortraenke_Canina-Pharma_1280x1280.png',
|
'canina-immun-booster-paste':
|
||||||
'canina-petvital-arthro-tabletten': 'https://www.canina.de/media/90/a6/50/723003_PETVITAL_Arthro_Tabletten_Canina-Pharma_1280x1280.png',
|
'https://www.canina.de/media/8c/fb/8e/311000_Canino_Immun_Booster_Paste_Abwehrkraefte_1280x1280.png',
|
||||||
'canina-petvital-bio-aktivator': 'https://www.canina.de/media/c9/2e/0f/712007_PETVITAL_Bio_Aktivator_Canina-Pharma_1280x1280.png',
|
'canina-katzenmilch':
|
||||||
'canina-petvital-biotin-tabs': 'https://www.canina.de/media/e1/9b/6c/702008_PETVITAL_Biotin_Tabs_Canina-Pharma_1280x1280.png',
|
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
|
||||||
'canina-petvital-energy-gel': 'https://www.canina.de/media/7f/0f/06/712106_PETVITAL_Energy_Gel_Canina-Pharma_1280x1280.png',
|
'canina-lachs-l':
|
||||||
'canina-petvital-mineral-tabs': 'https://www.canina.de/media/70/4e/f2/723102_PETVITAL_Mineral_Tabs_Canina-Pharma_1280x1280.png',
|
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
|
||||||
'canina-petvital-gag': 'https://www.canina.de/media/58/05/92/723201_723300_PETVITAL_GAG_Canina-Pharma_1280x1280.png',
|
'canina-lachs-ol':
|
||||||
'canina-petvital-vitamin-tabs': 'https://www.canina.de/media/2c/80/7e/712205_PETVITAL_Vitamin_Tabs_Canina-Pharma_1280x1280.png',
|
'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
|
||||||
'canina-rinderblut-pulver': 'https://www.canina.de/media/14/d0/0d/792016_791514_Rinderblut_Pulver_Canina-Pharma_1280x1280.png',
|
'canina-marine-lmischung-premium':
|
||||||
'canina-rinderfett-pulver': 'https://www.canina.de/media/9a/31/59/131235_Rinderfett_Pulver_Canina-Pharma_1280x1280.png',
|
'https://www.canina.de/media/ad/28/71/153008_Marine_Oelmischung_Premium_Canina-Pharma_1280x1280.png',
|
||||||
'canina-schwarz-kmmel-samen': 'https://www.canina.de/media/a9/c8/aa/131105_Schwarzkuemmelsamen_Canina-Pharma_1280x1280.png',
|
'canina-moortrnke':
|
||||||
'canina-seealgen-bio-seealgenmehl': 'https://www.canina.de/media/e4/c4/b2/130504_130511_130412_Seealgen_Bio-Seealgenmehl_Canina-Pharma_1280x1280.png',
|
'https://www.canina.de/media/9d/b2/87/112708_Moortraenke_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-petvital-arthro-tabletten':
|
||||||
'canina-velox-gelenkenergie': 'https://www.canina.de/media/1a/0c/33/701902_Velox_Gelenkenergie_Canina-Pharma_1280x1280.png',
|
'https://www.canina.de/media/90/a6/50/723003_PETVITAL_Arthro_Tabletten_Canina-Pharma_1280x1280.png',
|
||||||
'canina-welpenbrei': 'https://www.canina.de/media/98/95/43/130603_Welpenbrei_Canina-Pharma_1280x1280.png',
|
'canina-petvital-bio-aktivator':
|
||||||
'canina-welpenmilch': 'https://www.canina.de/media/2c/e0/75/130702_Welpenmilch_Canina-Pharma_1280x1280.png',
|
'https://www.canina.de/media/c9/2e/0f/712007_PETVITAL_Bio_Aktivator_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-petvital-biotin-tabs':
|
||||||
'canina-mikrosilber-zahngel': 'https://www.canina.de/media/9d/5e/54/131454_Mikrosilber_Zahngel_Canina-Pharma_1280x1280.png',
|
'https://www.canina.de/media/e1/9b/6c/702008_PETVITAL_Biotin_Tabs_Canina-Pharma_1280x1280.png',
|
||||||
'canina-novagard-green-augenpflege': 'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png',
|
'canina-petvital-energy-gel':
|
||||||
'canina-novagard-green-pfotenpflege': '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-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(slug: string): Promise<{ id: string; slug: string }> {
|
async function findOrCreateCategory(
|
||||||
|
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({
|
||||||
@ -88,80 +125,141 @@ async function findOrCreateCategory(slug: string): Promise<{ id: string; slug: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
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-katzenmilch': ["تغذیه بچه گربه", "بیمادری بچه گربه"],
|
'canina-canhydrox-gag': [
|
||||||
'canina-lachs-ol': ["خشکی پوست", "ریزش مو", "خارش", "التهاب پوست"],
|
'درد مفاصل',
|
||||||
'canina-marine-lmischung-premium': ["خشکی پوست", "ریزش مو", "التهاب"],
|
'سختی در بلند شدن',
|
||||||
'canina-moortrnke': ["اسهال", "اشتهای کم", "مسمومیت گوارشی"],
|
'لنگیدن',
|
||||||
'canina-petvital-arthro-tabletten': ["درد مفاصل", "سختی در بلند شدن", "لنگیدن"],
|
'رشد سریع تولهسگ',
|
||||||
'canina-petvital-bio-aktivator': ["کاهش ایمنی", "خستگی", "دوران نقاهت"],
|
'تقویت رباط و تاندون',
|
||||||
'canina-petvital-biotin-tabs': ["ریزش مو", "خشکی پوست", "شکنندگی ناخن"],
|
],
|
||||||
'canina-petvital-energy-gel': ["بیاشتهایی", "کاهش وزن", "کمبود انرژی"],
|
'canina-eierschalenpulver': [
|
||||||
'canina-petvital-mineral-tabs': ["کمبود مواد معدنی", "ضعف استخوان"],
|
'کمبود کلسیم',
|
||||||
'canina-petvital-gag': ["سختی در بلند شدن", "لنگیدن"],
|
'رژیم خام گوشتی',
|
||||||
'canina-petvital-vitamin-tabs': ["کمبود ویتامین", "کاهش انرژی"],
|
'سلامت دندانها',
|
||||||
'canina-rinderblut-pulver': ["کمخونی", "بیاشتهایی", "دوران رشد"],
|
],
|
||||||
'canina-rinderfett-pulver': ["کمبود وزن", "اشتهای کم"],
|
'canina-flexan': ['درد مفاصل', 'سختی در بلند شدن', 'لنگیدن', 'تخریب غضروف'],
|
||||||
'canina-schwarz-kmmel-samen': ["انگل روده", "ضعف ایمنی"],
|
'canina-herz-vital': ['نارسایی قلبی', 'بیحالی', 'کاهش انرژی'],
|
||||||
'canina-seealgen-bio-seealgenmehl': ["کاهش پیگمنت مو", "کمبود تیروئید"],
|
'canina-immun-booster-paste': [
|
||||||
'canina-taurin-fr-katzen': ["مشکل بینایی گربه", "نارسایی قلبی گربه"],
|
'ضعف بعد از بیماری',
|
||||||
'canina-velox-gelenkenergie': ["درد مفاصل", "لنگیدن"],
|
'اسهال',
|
||||||
'canina-welpenbrei': ["تغذیه تولهسگ", "از شیر گرفتن تولهسگ"],
|
'ضعف تولهسگ',
|
||||||
'canina-welpenmilch': ["تغذیه تولهسگ", "بیمادری تولهسگ"],
|
'کاهش ایمنی',
|
||||||
'canina-petvital-bio-insect-shocker': ["کک و کنه", "انگلهای پوستی"],
|
],
|
||||||
'canina-mikrosilber-zahngel': ["جرم دندان", "بوی بد دهان", "التهاب لثه"],
|
'canina-katzenmilch': ['تغذیه بچه گربه', 'بیمادری بچه گربه'],
|
||||||
'canina-novagard-green-augenpflege': ["ترشحات چشم", "التهاب چشم", "کثیفی چشم"],
|
'canina-lachs-ol': ['خشکی پوست', 'ریزش مو', 'خارش', 'التهاب پوست'],
|
||||||
'canina-novagard-green-pfotenpflege': ["ترک پنجه", "خشکی پنجه", "زخم پنجه"],
|
'canina-marine-lmischung-premium': ['خشکی پوست', 'ریزش مو', 'التهاب'],
|
||||||
|
'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-eierschalenpulver': "کلسیم ارگانیک صد در صد طبیعی مناسب رژیمهای غذایی خام (BARF)",
|
'canina-canhydrox-gag':
|
||||||
'canina-flexan': "پپتیدهای کلاژن زیستفعال برای بهینهسازی دامنه حرکتی و بازسازی مفصلی",
|
'فرمولاسیون ویژه دامپزشکی برای پایداری بافتهای همبند، غضروف و استخوانها',
|
||||||
'canina-herz-vital': "تقویت عملکرد فیزیولوژیک عضله قلب و افزایش نشاط حیوان",
|
'canina-eierschalenpulver':
|
||||||
'canina-immun-booster-paste': "تامین فوری ایمونوگلوبولینهای آغوز و تثبیت فلور روده در زمان نقاهت",
|
'کلسیم ارگانیک صد در صد طبیعی مناسب رژیمهای غذایی خام (BARF)',
|
||||||
'canina-katzenmilch': "شیر خشک جایگزین بچه گربه حاوی تورین و فاقد لاکتوز مزاحم",
|
'canina-flexan':
|
||||||
'canina-lachs-ol': "اسیدهای چرب ضروری امگا ۳ برای درخشش پوشش مویی و سلامت پوست",
|
'پپتیدهای کلاژن زیستفعال برای بهینهسازی دامنه حرکتی و بازسازی مفصلی',
|
||||||
'canina-marine-lmischung-premium': "ترکیب روغنهای ممتاز دریایی غنی از EPA و DHA جهت کاهش التهابات پوستی",
|
'canina-herz-vital': 'تقویت عملکرد فیزیولوژیک عضله قلب و افزایش نشاط حیوان',
|
||||||
'canina-moortrnke': "عصاره پیت طبیعی برای جذب بیولوژیکی سموم و بهبود ترشحات گوارشی",
|
'canina-immun-booster-paste':
|
||||||
'canina-petvital-arthro-tabletten': "فرمول گیاهی-معدنی برای کاهش دردهای حاد مفصلی و تسهیل در بلند شدن",
|
'تامین فوری ایمونوگلوبولینهای آغوز و تثبیت فلور روده در زمان نقاهت',
|
||||||
'canina-petvital-bio-aktivator': "آمینو اسیدها و آهن فعال جهت بازسازی قوای جسمی و بهبود اشتها",
|
'canina-katzenmilch':
|
||||||
'canina-petvital-biotin-tabs': "دوز بالای بیوتین و ویتامین ب برای توقف سریع ریزش مو و بازسازی ناخن",
|
'شیر خشک جایگزین بچه گربه حاوی تورین و فاقد لاکتوز مزاحم',
|
||||||
'canina-petvital-energy-gel': "کنسانتره انرژی بالا همراه با الکترولیتها برای سگها و گربههای ضعیف",
|
'canina-lachs-ol':
|
||||||
'canina-petvital-mineral-tabs': "مواد معدنی و عناصر کمیاب جهت تراکم استخوانی و دوران بارداری و رشد",
|
'اسیدهای چرب ضروری امگا ۳ برای درخشش پوشش مویی و سلامت پوست',
|
||||||
'canina-petvital-gag': "ترکیب صدف لبسبز نیوزیلند و اسیدهای آمینه جهت تقویت رباطها و تاندونها",
|
'canina-marine-lmischung-premium':
|
||||||
'canina-petvital-vitamin-tabs': "مولتیویتامین کامل روزانه برای تقویت سیستم دفاعی و افزایش شادابی پت",
|
'ترکیب روغنهای ممتاز دریایی غنی از EPA و DHA جهت کاهش التهابات پوستی',
|
||||||
'canina-rinderblut-pulver': "پودر خون گاو غنی از آهن طبیعی و هموگلوبین برای بهبود اشتها و رفع کمخونی",
|
'canina-moortrnke':
|
||||||
'canina-rinderfett-pulver': "مکمل چربی طبیعی با طعمدهندگی بالا جهت جبران کمبود وزن و افزایش انرژی",
|
'عصاره پیت طبیعی برای جذب بیولوژیکی سموم و بهبود ترشحات گوارشی',
|
||||||
'canina-schwarz-kmmel-samen': "دانههای سیاه دانه مصری برای پشتیبانی متابولیک و دفع طبیعی انگلها",
|
'canina-petvital-arthro-tabletten':
|
||||||
'canina-seealgen-bio-seealgenmehl': "جلبک دریایی ارگانیک سرشار از ید طبیعی جهت درخشش و تیره کردن پیگمنتهای مو و بینی",
|
'فرمول گیاهی-معدنی برای کاهش دردهای حاد مفصلی و تسهیل در بلند شدن',
|
||||||
'canina-taurin-fr-katzen': "اسید آمینه تورین خالص برای سلامت بینایی و پیشگیری از کاردیومیوپاتی گربهها",
|
'canina-petvital-bio-aktivator':
|
||||||
'canina-velox-gelenkenergie': "پودر صد در صد صدف لبسبز نیوزیلند برای مفاصل، غضروفها و رباطها",
|
'آمینو اسیدها و آهن فعال جهت بازسازی قوای جسمی و بهبود اشتها',
|
||||||
'canina-welpenbrei': "غذای کمکی بچه سگها برای انتقال آسان از شیر مادر به غذای جامد",
|
'canina-petvital-biotin-tabs':
|
||||||
'canina-welpenmilch': "شیر خشک تخصصی تولهسگ غنی شده با ویتامینها و املاح معدنی فاقد لاکتوز",
|
'دوز بالای بیوتین و ویتامین ب برای توقف سریع ریزش مو و بازسازی ناخن',
|
||||||
'canina-petvital-bio-insect-shocker': "اسپری دافع انگلهای خارجی کک، کنه و شپش صد در صد طبیعی و بدون سموم شیمیایی",
|
'canina-petvital-energy-gel':
|
||||||
'canina-mikrosilber-zahngel': "ژل دندان با نقره میکروسیلور جهت مبارزه با بوی بد دهان، پلاک و عفونت لثه",
|
'کنسانتره انرژی بالا همراه با الکترولیتها برای سگها و گربههای ضعیف',
|
||||||
'canina-novagard-green-augenpflege': "محلول ملایم شستشوی چشم سگ و گربه برای پاک کردن ترشحات و کاهش التهابات",
|
'canina-petvital-mineral-tabs':
|
||||||
'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':
|
||||||
|
'مومیایی محافظ پنجهها برای التیام خشکی، قرمزی و ترک خوردگی پنجهها در برف و سرما',
|
||||||
};
|
};
|
||||||
|
|
||||||
async function main() {
|
export 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(process.cwd() + "/prisma", 'seed-products-data.json');
|
const dataPath = path.join(
|
||||||
|
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(fs.readFileSync(dataPath, 'utf-8').replace(/^\ufeff/, ''));
|
const productsData = JSON.parse(
|
||||||
|
fs.readFileSync(dataPath, 'utf-8').replace(/^\ufeff/, ''),
|
||||||
|
);
|
||||||
let variantCount = 0;
|
let variantCount = 0;
|
||||||
|
|
||||||
for (const item of productsData) {
|
for (const item of productsData) {
|
||||||
@ -201,82 +299,116 @@ async function main() {
|
|||||||
const nameEn = `${baseNameEn} - ${sizeLabel}`;
|
const nameEn = `${baseNameEn} - ${sizeLabel}`;
|
||||||
const priceDisplay = `${sellPrice.toLocaleString()} تومان`;
|
const priceDisplay = `${sellPrice.toLocaleString()} تومان`;
|
||||||
|
|
||||||
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'];
|
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',
|
||||||
|
];
|
||||||
const requiresRx = RX_PRODUCTS.includes(baseSlug);
|
const requiresRx = RX_PRODUCTS.includes(baseSlug);
|
||||||
|
|
||||||
const product = await prisma.product.upsert({
|
try {
|
||||||
where: { artNo },
|
const product = await prisma.product.upsert({
|
||||||
update: {
|
where: { artNo },
|
||||||
barcode,
|
update: {
|
||||||
slug,
|
barcode,
|
||||||
productGroup: baseSlug,
|
slug,
|
||||||
nameFa,
|
productGroup: baseSlug,
|
||||||
nameEn,
|
nameFa,
|
||||||
scientificTagline: PRODUCT_TAGLINES_MAP[baseSlug] || null,
|
nameEn,
|
||||||
description: cleanDesc,
|
scientificTagline: PRODUCT_TAGLINES_MAP[baseSlug] || null,
|
||||||
shortDescription: cleanShortDesc,
|
description: cleanDesc,
|
||||||
ingredients: cleanIngredients,
|
shortDescription: cleanShortDesc,
|
||||||
categoryId: category.id,
|
ingredients: cleanIngredients,
|
||||||
categorySlug: category.slug,
|
categoryId: category.id,
|
||||||
priceValue: sellPrice,
|
categorySlug: category.slug,
|
||||||
wholesalePrice: Math.round(sellPrice * 0.7),
|
priceValue: sellPrice,
|
||||||
requiresRx,
|
wholesalePrice: Math.round(sellPrice * 0.7),
|
||||||
buyPrice,
|
requiresRx,
|
||||||
priceDisplay,
|
buyPrice,
|
||||||
unit,
|
priceDisplay,
|
||||||
packageSize,
|
unit,
|
||||||
dosageLogic: cleanDosage,
|
packageSize,
|
||||||
suitableFor,
|
dosageLogic: cleanDosage,
|
||||||
imageUrl: getProductImageUrl(baseSlug),
|
suitableFor,
|
||||||
},
|
imageUrl: getProductImageUrl(baseSlug),
|
||||||
create: {
|
},
|
||||||
artNo,
|
create: {
|
||||||
barcode,
|
artNo,
|
||||||
slug,
|
barcode,
|
||||||
productGroup: baseSlug,
|
slug,
|
||||||
nameFa,
|
productGroup: baseSlug,
|
||||||
nameEn,
|
nameFa,
|
||||||
scientificTagline: PRODUCT_TAGLINES_MAP[baseSlug] || null,
|
nameEn,
|
||||||
description: cleanDesc,
|
scientificTagline: PRODUCT_TAGLINES_MAP[baseSlug] || null,
|
||||||
shortDescription: cleanShortDesc,
|
description: cleanDesc,
|
||||||
ingredients: cleanIngredients,
|
shortDescription: cleanShortDesc,
|
||||||
categoryId: category.id,
|
ingredients: cleanIngredients,
|
||||||
categorySlug: category.slug,
|
categoryId: category.id,
|
||||||
priceValue: sellPrice,
|
categorySlug: category.slug,
|
||||||
wholesalePrice: Math.round(sellPrice * 0.7),
|
priceValue: sellPrice,
|
||||||
requiresRx,
|
wholesalePrice: Math.round(sellPrice * 0.7),
|
||||||
buyPrice,
|
requiresRx,
|
||||||
priceDisplay,
|
buyPrice,
|
||||||
unit,
|
priceDisplay,
|
||||||
packageSize,
|
unit,
|
||||||
dosageLogic: cleanDosage,
|
packageSize,
|
||||||
suitableFor,
|
dosageLogic: cleanDosage,
|
||||||
imageUrl: getProductImageUrl(baseSlug),
|
suitableFor,
|
||||||
}
|
imageUrl: getProductImageUrl(baseSlug),
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Seed ingredients
|
// Seed ingredients safely
|
||||||
await prisma.productIngredient.deleteMany({ where: { productId: product.id } });
|
await prisma.productIngredient.deleteMany({
|
||||||
if (cleanIngredients) {
|
where: { productId: product.id },
|
||||||
const parts = cleanIngredients.split(/[،,]/).map((s: string) => s.trim()).filter(Boolean);
|
});
|
||||||
const meaningful = parts.slice(0, 20);
|
if (cleanIngredients) {
|
||||||
for (const ing of meaningful) {
|
const parts = cleanIngredients
|
||||||
const truncated = ing.substring(0, 150);
|
.split(/[،,]/)
|
||||||
if (truncated.length > 0) {
|
.map((s: string) => s.trim())
|
||||||
await prisma.productIngredient.create({
|
.filter(Boolean);
|
||||||
data: { productId: product.id, ingredient: truncated }
|
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
|
// Seed symptoms safely
|
||||||
await prisma.productSymptom.deleteMany({ where: { productId: product.id } });
|
await prisma.productSymptom.deleteMany({
|
||||||
const symptomsList = PRODUCT_SYMPTOMS_MAP[baseSlug] || [];
|
where: { productId: product.id },
|
||||||
for (const sym of symptomsList) {
|
|
||||||
await prisma.productSymptom.create({
|
|
||||||
data: { productId: product.id, symptom: sym }
|
|
||||||
});
|
});
|
||||||
|
const symptomsList: string[] = Array.from(
|
||||||
|
new Set<string>(PRODUCT_SYMPTOMS_MAP[baseSlug] || []),
|
||||||
|
);
|
||||||
|
for (const item of symptomsList) {
|
||||||
|
const sym: string = String(item);
|
||||||
|
try {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
variantCount++;
|
variantCount++;
|
||||||
@ -296,8 +428,8 @@ 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
|
||||||
@ -322,7 +454,7 @@ 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 = [
|
||||||
@ -346,7 +478,7 @@ 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 = [
|
||||||
@ -363,21 +495,30 @@ 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) {
|
||||||
await prisma.blog.upsert({
|
try {
|
||||||
where: { slug: b.slug },
|
await prisma.blog.upsert({
|
||||||
update: {},
|
where: { slug: b.slug },
|
||||||
create: b
|
update: {
|
||||||
});
|
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);
|
||||||
})
|
})
|
||||||
|
|||||||
@ -296,8 +296,10 @@ async function main() {
|
|||||||
// 3. Seed Products & Categories
|
// 3. Seed Products & Categories
|
||||||
console.log('Seeding Products & Categories...');
|
console.log('Seeding Products & Categories...');
|
||||||
try {
|
try {
|
||||||
const { execSync } = require('child_process');
|
const seedProducts = require('./seed-products');
|
||||||
execSync('npx ts-node prisma/seed-products.ts', { stdio: 'inherit' });
|
if (seedProducts && typeof seedProducts.main === 'function') {
|
||||||
|
await seedProducts.main();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to seed products:', err);
|
console.error('Failed to seed products:', err);
|
||||||
}
|
}
|
||||||
@ -305,8 +307,10 @@ 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 { execSync } = require('child_process');
|
const seedHome = require('./seed-home');
|
||||||
execSync('npx ts-node prisma/seed-home.ts', { stdio: 'inherit' });
|
if (seedHome && typeof seedHome.main === 'function') {
|
||||||
|
await seedHome.main();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to seed home components:', err);
|
console.error('Failed to seed home components:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
8
backend/prisma/tsconfig.seed.json
Normal file
8
backend/prisma/tsconfig.seed.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,22 @@
|
|||||||
import { Controller, Get, UseGuards, Param, Put, Post, Body, Delete, Query } from '@nestjs/common';
|
import {
|
||||||
|
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 { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
@ApiTags('Admin - پنل مدیریت')
|
@ApiTags('Admin - پنل مدیریت')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ -16,7 +31,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,15 +40,28 @@ 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({ name: 'search', required: false, description: 'جستجو در نام یا ایمیل' })
|
@ApiQuery({
|
||||||
@ApiQuery({ name: 'role', required: false, description: 'فیلتر بر اساس نقش کاربر' })
|
name: 'search',
|
||||||
|
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({ page, limit, search, role });
|
const data = await this.adminService.getUsers({
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
search,
|
||||||
|
role,
|
||||||
|
});
|
||||||
return { success: true, ...data };
|
return { success: true, ...data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,7 +72,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,14 +82,23 @@ 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({ name: 'categoryId', required: false, description: 'شناسه دستهبندی' })
|
@ApiQuery({
|
||||||
|
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({ page, limit, search, categoryId });
|
const data = await this.adminService.getProducts({
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
search,
|
||||||
|
categoryId,
|
||||||
|
});
|
||||||
return { success: true, ...data };
|
return { success: true, ...data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,7 +117,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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,7 +128,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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,9 +143,14 @@ 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({ page, limit, search, status });
|
const data = await this.adminService.getOrders({
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
search,
|
||||||
|
status,
|
||||||
|
});
|
||||||
return { success: true, ...data };
|
return { success: true, ...data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,12 +160,16 @@ 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(id, status, trackingNumber);
|
const order = await this.adminService.updateOrderStatus(
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
trackingNumber,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: order
|
data: order,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,7 +204,10 @@ export class AdminController {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Put('coupons/:id/toggle')
|
@Put('coupons/:id/toggle')
|
||||||
@ApiOperation({ summary: 'فعال/غیرفعال کردن کد تخفیف' })
|
@ApiOperation({ summary: 'فعال/غیرفعال کردن کد تخفیف' })
|
||||||
async toggleCoupon(@Param('id') id: string, @Body('isActive') isActive: boolean) {
|
async toggleCoupon(
|
||||||
|
@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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,23 @@ import { RedisModule } from '../redis/redis.module';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, RedisModule],
|
imports: [PrismaModule, RedisModule],
|
||||||
controllers: [AdminController, ReportsController, MediaController, CategoriesController, BlogsController, WikiController, PetsController],
|
controllers: [
|
||||||
providers: [AdminService, ReportsService, MediaService, CategoriesService, BlogsService, WikiService, PetsService],
|
AdminController,
|
||||||
|
ReportsController,
|
||||||
|
MediaController,
|
||||||
|
CategoriesController,
|
||||||
|
BlogsController,
|
||||||
|
WikiController,
|
||||||
|
PetsController,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
AdminService,
|
||||||
|
ReportsService,
|
||||||
|
MediaService,
|
||||||
|
CategoriesService,
|
||||||
|
BlogsService,
|
||||||
|
WikiService,
|
||||||
|
PetsService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
|
|||||||
@ -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,17 +67,25 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [data, total] = await Promise.all([
|
const [data, total] = await Promise.all([
|
||||||
this.prisma.user.findMany({ where, skip, take: limit, orderBy: { createdAt: 'desc' } }),
|
this.prisma.user.findMany({
|
||||||
this.prisma.user.count({ where })
|
where,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
}),
|
||||||
|
this.prisma.user.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { data, meta: { total, page, limit, lastPage: Math.ceil(total / limit) } };
|
return {
|
||||||
|
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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,7 +99,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) {
|
||||||
@ -99,16 +107,22 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [data, total] = await Promise.all([
|
const [data, total] = await Promise.all([
|
||||||
this.prisma.product.findMany({
|
this.prisma.product.findMany({
|
||||||
where, skip, take: limit, orderBy: { createdAt: 'desc' },
|
where,
|
||||||
include: { category: true, symptoms: true }
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { category: true, symptoms: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.product.count({ where })
|
this.prisma.product.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { data, meta: { total, page, limit, lastPage: Math.ceil(total / limit) } };
|
return {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -136,15 +150,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 && Array.isArray(data.symptoms)) {
|
if (data.symptoms && Array.isArray(data.symptoms)) {
|
||||||
await this.prisma.productSymptom.createMany({
|
await this.prisma.productSymptom.createMany({
|
||||||
data: data.symptoms.map((s: string) => ({
|
data: data.symptoms
|
||||||
productId: product.id,
|
.map((s: string) => ({
|
||||||
symptom: s.trim()
|
productId: product.id,
|
||||||
})).filter((s: any) => s.symptom.length > 0)
|
symptom: s.trim(),
|
||||||
|
}))
|
||||||
|
.filter((s: any) => s.symptom.length > 0),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,17 +191,19 @@ 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.map((s: string) => ({
|
data: data.symptoms
|
||||||
productId: id,
|
.map((s: string) => ({
|
||||||
symptom: s.trim()
|
productId: id,
|
||||||
})).filter((s: any) => s.symptom.length > 0)
|
symptom: s.trim(),
|
||||||
|
}))
|
||||||
|
.filter((s: any) => s.symptom.length > 0),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -209,9 +227,11 @@ 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) {
|
||||||
@ -219,25 +239,28 @@ 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 { data, meta: { total, page, limit, lastPage: Math.ceil(total / limit) } };
|
return {
|
||||||
|
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) {
|
||||||
@ -252,10 +275,10 @@ export class AdminService {
|
|||||||
user: true,
|
user: true,
|
||||||
orderItems: {
|
orderItems: {
|
||||||
include: {
|
include: {
|
||||||
product: true
|
product: true,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -263,23 +286,30 @@ 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 ? { code: { contains: search, mode: 'insensitive' as any } } : {};
|
const where = search
|
||||||
|
? { 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: { total, page: Number(page), limit: Number(limit), lastPage: Math.ceil(total / Number(limit)) }
|
meta: {
|
||||||
|
total,
|
||||||
|
page: Number(page),
|
||||||
|
limit: Number(limit),
|
||||||
|
lastPage: Math.ceil(total / Number(limit)),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -294,16 +324,19 @@ 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: data.targets && data.targets.length > 0 ? {
|
targets:
|
||||||
create: data.targets.map((t: any) => ({
|
data.targets && data.targets.length > 0
|
||||||
targetType: t.targetType,
|
? {
|
||||||
targetId: t.targetId,
|
create: data.targets.map((t: any) => ({
|
||||||
modifierType: t.modifierType || 'override',
|
targetType: t.targetType,
|
||||||
modifierValue: t.modifierValue || null
|
targetId: t.targetId,
|
||||||
}))
|
modifierType: t.modifierType || 'override',
|
||||||
} : undefined
|
modifierValue: t.modifierValue || null,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
include: { targets: true }
|
include: { targets: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -322,41 +355,52 @@ 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: data.targets && data.targets.length > 0 ? {
|
targets:
|
||||||
create: data.targets.map((t: any) => ({
|
data.targets && data.targets.length > 0
|
||||||
targetType: t.targetType,
|
? {
|
||||||
targetId: t.targetId,
|
create: data.targets.map((t: any) => ({
|
||||||
modifierType: t.modifierType || 'override',
|
targetType: t.targetType,
|
||||||
modifierValue: t.modifierValue || null
|
targetId: t.targetId,
|
||||||
}))
|
modifierType: t.modifierType || 'override',
|
||||||
} : undefined
|
modifierValue: t.modifierValue || null,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: 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 = ['SHIPPING_FEE', 'MIN_ORDER_AMOUNT', 'B2B_DISCOUNT_PERCENT', 'MAINTENANCE_MODE'];
|
const keys = [
|
||||||
|
'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((acc, curr) => ({ ...acc, [curr.key]: curr.value }), {});
|
return settings.reduce(
|
||||||
|
(acc, curr) => ({ ...acc, [curr.key]: curr.value }),
|
||||||
|
{},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSettings(data: Record<string, string>) {
|
async updateSettings(data: Record<string, string>) {
|
||||||
@ -365,10 +409,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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,23 @@
|
|||||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request } from '@nestjs/common';
|
import {
|
||||||
|
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 { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
@ApiTags('Admin - مدیریت مقالات (بلاگ)')
|
@ApiTags('Admin - مدیریت مقالات (بلاگ)')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
|||||||
@ -8,29 +8,36 @@ 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 ? { title: { contains: search, mode: 'insensitive' as any } } : {};
|
const where = search
|
||||||
|
? { 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: { total, page: Number(page), limit: Number(limit), lastPage: Math.ceil(total / Number(limit)) }
|
meta: {
|
||||||
|
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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,22 @@
|
|||||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
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 { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
@ApiTags('Admin - مدیریت دستهبندیها')
|
@ApiTags('Admin - مدیریت دستهبندیها')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
|||||||
@ -8,22 +8,29 @@ 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 ? { name: { contains: search, mode: 'insensitive' as any } } : {};
|
const where = search
|
||||||
|
? { 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: { total, page: Number(page), limit: Number(limit), lastPage: Math.ceil(total / Number(limit)) }
|
meta: {
|
||||||
|
total,
|
||||||
|
page: Number(page),
|
||||||
|
limit: Number(limit),
|
||||||
|
lastPage: Math.ceil(total / Number(limit)),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,21 +40,22 @@ 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) cleanData.slug = cleanData.name.replace(/\s+/g, '-').toLowerCase();
|
if (!cleanData.slug)
|
||||||
|
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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,14 @@
|
|||||||
import { Controller, Get, Post, Delete, Param, UseGuards, UseInterceptors, UploadedFile, BadRequestException } from '@nestjs/common';
|
import {
|
||||||
|
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';
|
||||||
|
|||||||
@ -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,7 +49,11 @@ 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(process.cwd(), 'uploads', path.basename(media.url));
|
const filePath = path.join(
|
||||||
|
process.cwd(),
|
||||||
|
'uploads',
|
||||||
|
path.basename(media.url),
|
||||||
|
);
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
fs.unlinkSync(filePath);
|
fs.unlinkSync(filePath);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,19 @@
|
|||||||
import { Controller, Get, Delete, Param, Query, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
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 { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
@ApiTags('Admin - مدیریت حیوانات خانگی')
|
@ApiTags('Admin - مدیریت حیوانات خانگی')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
|||||||
@ -8,23 +8,39 @@ 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 ? { name: { contains: search, mode: 'insensitive' as any } } : {};
|
const where = search
|
||||||
|
? { 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: { user: { select: { firstName: true, lastName: true, email: true, mobile: true } } }
|
include: {
|
||||||
|
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: { total, page: Number(page), limit: Number(limit), lastPage: Math.ceil(total / Number(limit)) }
|
meta: {
|
||||||
|
total,
|
||||||
|
page: Number(page),
|
||||||
|
limit: Number(limit),
|
||||||
|
lastPage: Math.ceil(total / Number(limit)),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,12 @@ 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: { totalAmount: true, charityDonation: true, createdAt: true, user: { select: { role: true } } }
|
select: {
|
||||||
|
totalAmount: true,
|
||||||
|
charityDonation: true,
|
||||||
|
createdAt: true,
|
||||||
|
user: { select: { role: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let totalRevenue = 0;
|
let totalRevenue = 0;
|
||||||
@ -23,7 +28,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 {
|
||||||
@ -45,23 +50,28 @@ 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: { order: { status: { not: 'cancelled' } }, productId: { not: null } },
|
where: {
|
||||||
|
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.map(item => item.productId).filter(Boolean) as string[];
|
const topProductsIds = orderItems
|
||||||
|
.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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -72,18 +82,24 @@ 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({ include: { products: { select: { id: true } } } });
|
const categories = await this.prisma.category.findMany({
|
||||||
const catSalesDist = categories.map(cat => {
|
include: { products: { select: { id: true } } },
|
||||||
const pIds = cat.products.map(p => p.id);
|
});
|
||||||
const catSales = orderItems.filter(oi => oi.productId && pIds.includes(oi.productId)).reduce((sum, oi) => sum + (oi._sum.quantity || 0), 0);
|
const catSalesDist = categories
|
||||||
return { name: cat.name, value: catSales };
|
.map((cat) => {
|
||||||
}).filter(c => c.value > 0);
|
const pIds = cat.products.map((p) => p.id);
|
||||||
|
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 {
|
||||||
@ -97,7 +113,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 })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,22 @@
|
|||||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
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 { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
@ApiTags('Admin - مدیریت دانشنامه (اصطلاحات علمی)')
|
@ApiTags('Admin - مدیریت دانشنامه (اصطلاحات علمی)')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
|||||||
@ -8,22 +8,29 @@ 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 ? { term: { contains: search, mode: 'insensitive' as any } } : {};
|
const where = search
|
||||||
|
? { 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: { total, page: Number(page), limit: Number(limit), lastPage: Math.ceil(total / Number(limit)) }
|
meta: {
|
||||||
|
total,
|
||||||
|
page: Number(page),
|
||||||
|
limit: Number(limit),
|
||||||
|
lastPage: Math.ceil(total / Number(limit)),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,13 +39,17 @@ export class WikiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateTerm(key: string, data: any) {
|
async updateTerm(key: string, data: any) {
|
||||||
const term = await this.prisma.scientificTerm.findUnique({ where: { key } });
|
const term = await this.prisma.scientificTerm.findUnique({
|
||||||
|
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({ where: { key } });
|
const term = await this.prisma.scientificTerm.findUnique({
|
||||||
|
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 } });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,10 +30,12 @@ import { VideosModule } from './videos/videos.module';
|
|||||||
PetsModule,
|
PetsModule,
|
||||||
OrdersModule,
|
OrdersModule,
|
||||||
SettingsModule,
|
SettingsModule,
|
||||||
ThrottlerModule.forRoot([{
|
ThrottlerModule.forRoot([
|
||||||
ttl: 60000,
|
{
|
||||||
limit: 100,
|
ttl: 60000,
|
||||||
}]),
|
limit: 100,
|
||||||
|
},
|
||||||
|
]),
|
||||||
AdminModule,
|
AdminModule,
|
||||||
HomeModule,
|
HomeModule,
|
||||||
BlogsModule,
|
BlogsModule,
|
||||||
|
|||||||
@ -7,16 +7,18 @@ describe('AuthController', () => {
|
|||||||
let service: AuthService;
|
let service: AuthService;
|
||||||
|
|
||||||
const mockAuthService = {
|
const mockAuthService = {
|
||||||
sendOtp: jest.fn().mockResolvedValue({ success: true, message: 'کد تایید ارسال شد' }),
|
sendOtp: jest
|
||||||
verifyOtp: jest.fn().mockResolvedValue({ success: true, data: { accessToken: 'token' } }),
|
.fn()
|
||||||
|
.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: [
|
providers: [{ provide: AuthService, useValue: mockAuthService }],
|
||||||
{ provide: AuthService, useValue: mockAuthService },
|
|
||||||
],
|
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
controller = module.get<AuthController>(AuthController);
|
controller = module.get<AuthController>(AuthController);
|
||||||
|
|||||||
@ -4,7 +4,13 @@ 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 { ApiTags, ApiOperation, ApiResponse, ApiOkResponse, ApiBadRequestResponse } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiBadRequestResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
@ApiTags('Auth - احراز هویت')
|
@ApiTags('Auth - احراز هویت')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@ -16,9 +22,9 @@ import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse, ApiBadRequestRespons
|
|||||||
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) {}
|
||||||
@ -32,9 +38,9 @@ export class AuthController {
|
|||||||
example: {
|
example: {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'کد تایید ارسال شد',
|
message: 'کد تایید ارسال شد',
|
||||||
code: '12345'
|
code: '12345',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
@ApiBadRequestResponse({
|
@ApiBadRequestResponse({
|
||||||
description: 'فرمت شماره تلفن همراه نامعتبر است',
|
description: 'فرمت شماره تلفن همراه نامعتبر است',
|
||||||
@ -44,10 +50,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);
|
||||||
@ -72,12 +78,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: 'کد تایید اشتباه یا منقضی شده است',
|
||||||
@ -86,9 +92,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);
|
||||||
@ -98,7 +104,9 @@ export class AuthController {
|
|||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
@ApiOperation({ summary: 'ثبتنام با ایمیل/موبایل و رمز عبور' })
|
@ApiOperation({ summary: 'ثبتنام با ایمیل/موبایل و رمز عبور' })
|
||||||
@ApiOkResponse({ description: 'کاربر با موفقیت ثبتنام شد' })
|
@ApiOkResponse({ description: 'کاربر با موفقیت ثبتنام شد' })
|
||||||
@ApiBadRequestResponse({ description: 'اطلاعات ثبتنام نامعتبر است یا کاربر از قبل وجود دارد' })
|
@ApiBadRequestResponse({
|
||||||
|
description: 'اطلاعات ثبتنام نامعتبر است یا کاربر از قبل وجود دارد',
|
||||||
|
})
|
||||||
register(@Body() registerDto: RegisterDto) {
|
register(@Body() registerDto: RegisterDto) {
|
||||||
return this.authService.register(registerDto);
|
return this.authService.register(registerDto);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -88,11 +88,19 @@ 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({ phoneNumber: '09123456789', code: '12345' });
|
const result = await service.verifyOtp({
|
||||||
|
phoneNumber: '09123456789',
|
||||||
|
code: '12345',
|
||||||
|
});
|
||||||
|
|
||||||
expect(redis.del).toHaveBeenCalledWith('otp:09123456789');
|
expect(redis.del).toHaveBeenCalledWith('otp:09123456789');
|
||||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({ where: { mobile: '09123456789' } });
|
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||||
expect(jwt.sign).toHaveBeenCalledWith({ sub: 'user-id', phoneNumber: '09123456789' });
|
where: { mobile: '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);
|
||||||
@ -104,7 +112,10 @@ 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({ phoneNumber: '09123456789', code: '12345' });
|
const result = await service.verifyOtp({
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -33,20 +33,28 @@ 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({ message: 'کد تایید منقضی شده است', error: 'OTP_EXPIRED' });
|
throw new BadRequestException({
|
||||||
|
message: 'کد تایید منقضی شده است',
|
||||||
|
error: 'OTP_EXPIRED',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (savedCode !== code) {
|
if (savedCode !== code) {
|
||||||
throw new BadRequestException({ message: 'کد تایید اشتباه است', error: 'OTP_INVALID' });
|
throw new BadRequestException({
|
||||||
|
message: 'کد تایید اشتباه است',
|
||||||
|
error: 'OTP_INVALID',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.redisService.del(`otp:${phoneNumber}`);
|
await this.redisService.del(`otp:${phoneNumber}`);
|
||||||
|
|
||||||
let user = await this.prisma.user.findUnique({ where: { mobile: phoneNumber } });
|
let user = await this.prisma.user.findUnique({
|
||||||
|
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: 'جدید',
|
||||||
@ -70,15 +78,25 @@ 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({ where: { mobile } });
|
const existingUser = await this.prisma.user.findUnique({
|
||||||
|
where: { mobile },
|
||||||
|
});
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
throw new BadRequestException({ message: 'کاربری با این شماره موبایل قبلا ثبت نام کرده است', error: 'MOBILE_EXISTS' });
|
throw new BadRequestException({
|
||||||
|
message: 'کاربری با این شماره موبایل قبلا ثبت نام کرده است',
|
||||||
|
error: 'MOBILE_EXISTS',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (email) {
|
if (email) {
|
||||||
const existingEmail = await this.prisma.user.findUnique({ where: { email } });
|
const existingEmail = await this.prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
});
|
||||||
if (existingEmail) {
|
if (existingEmail) {
|
||||||
throw new BadRequestException({ message: 'کاربری با این ایمیل قبلا ثبت نام کرده است', error: 'EMAIL_EXISTS' });
|
throw new BadRequestException({
|
||||||
|
message: 'کاربری با این ایمیل قبلا ثبت نام کرده است',
|
||||||
|
error: 'EMAIL_EXISTS',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -111,16 +129,25 @@ 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({ message: 'نام کاربری یا رمز عبور اشتباه است', error: 'INVALID_CREDENTIALS' });
|
throw new BadRequestException({
|
||||||
|
message: 'نام کاربری یا رمز عبور اشتباه است',
|
||||||
|
error: 'INVALID_CREDENTIALS',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.password) {
|
if (!user.password) {
|
||||||
throw new BadRequestException({ message: 'شما با رمز عبور ثبت نام نکردهاید. لطفاً با موبایل وارد شوید', error: 'NO_PASSWORD' });
|
throw new BadRequestException({
|
||||||
|
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({ message: 'نام کاربری یا رمز عبور اشتباه است', error: 'INVALID_CREDENTIALS' });
|
throw new BadRequestException({
|
||||||
|
message: 'نام کاربری یا رمز عبور اشتباه است',
|
||||||
|
error: 'INVALID_CREDENTIALS',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = { sub: user.id, phoneNumber: user.mobile };
|
const payload = { sub: user.id, phoneNumber: user.mobile };
|
||||||
@ -136,16 +163,30 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async adminLogin(body: any) {
|
async adminLogin(body: any) {
|
||||||
if (body.email === 'admin@canino-iran.com' && body.password === 'admin123') {
|
if (
|
||||||
const payload = { sub: '12345678-1234-1234-1234-123456789012', email: body.email, role: 'Admin' };
|
body.email === 'admin@canino-iran.com' &&
|
||||||
|
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: { id: '12345678-1234-1234-1234-123456789012', email: body.email, role: 'Admin' },
|
user: {
|
||||||
|
id: '12345678-1234-1234-1234-123456789012',
|
||||||
|
email: body.email,
|
||||||
|
role: 'Admin',
|
||||||
|
},
|
||||||
accessToken: this.jwtService.sign(payload),
|
accessToken: this.jwtService.sign(payload),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw new BadRequestException({ message: 'ایمیل یا رمز عبور اشتباه است', error: 'INVALID_CREDENTIALS' });
|
throw new BadRequestException({
|
||||||
|
message: 'ایمیل یا رمز عبور اشتباه است',
|
||||||
|
error: 'INVALID_CREDENTIALS',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,10 @@
|
|||||||
import { IsEmail, IsNotEmpty, IsOptional, IsString, MinLength } from 'class-validator';
|
import {
|
||||||
|
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 {
|
||||||
@ -12,7 +18,10 @@ export class RegisterDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
lastName: string;
|
lastName: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'ایمیل (اختیاری)', example: 'ali@example.com' })
|
@ApiPropertyOptional({
|
||||||
|
description: 'ایمیل (اختیاری)',
|
||||||
|
example: 'ali@example.com',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEmail({}, { message: 'ایمیل نامعتبر است' })
|
@IsEmail({}, { message: 'ایمیل نامعتبر است' })
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|||||||
@ -5,7 +5,9 @@ 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 err || new UnauthorizedException('لطفا ابتدا وارد حساب کاربری خود شوید');
|
throw (
|
||||||
|
err || new UnauthorizedException('لطفا ابتدا وارد حساب کاربری خود شوید')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
import {
|
||||||
|
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';
|
||||||
|
|
||||||
@ -7,11 +12,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[]>(ROLES_KEY, [
|
const requiredRoles = this.reflector.getAllAndOverride<string[]>(
|
||||||
context.getHandler(),
|
ROLES_KEY,
|
||||||
context.getClass(),
|
[context.getHandler(), context.getClass()],
|
||||||
]);
|
);
|
||||||
|
|
||||||
if (!requiredRoles || requiredRoles.length === 0) {
|
if (!requiredRoles || requiredRoles.length === 0) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,20 @@
|
|||||||
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 { ApiTags, ApiOperation, ApiResponse, ApiOkResponse, ApiNotFoundResponse, ApiQuery } from '@nestjs/swagger';
|
import {
|
||||||
|
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) {}
|
||||||
|
|||||||
@ -7,8 +7,14 @@ export class BlogsService {
|
|||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
async findAll(filters: PaginationDto) {
|
async findAll(filters: PaginationDto) {
|
||||||
const { search, page = 1, limit = 10, sortBy = 'createdAt', sortOrder = 'desc' } = filters;
|
const {
|
||||||
|
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 = [
|
||||||
@ -27,11 +33,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 {
|
||||||
@ -41,7 +47,7 @@ export class BlogsService {
|
|||||||
page,
|
page,
|
||||||
lastPage: Math.ceil(total / limit),
|
lastPage: Math.ceil(total / limit),
|
||||||
limit,
|
limit,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,15 +56,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,19 @@
|
|||||||
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { CmsService } from './cms.service';
|
import { CmsService } from './cms.service';
|
||||||
import { CreateHeroBannerDto, CreateVetTestimonialDto, CreateSmartAdvisorRuleDto } from './dto/cms.dto';
|
import {
|
||||||
|
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';
|
||||||
@ -29,7 +42,10 @@ export class CmsController {
|
|||||||
|
|
||||||
@Put('hero-banners/:id')
|
@Put('hero-banners/:id')
|
||||||
@ApiOperation({ summary: 'ویرایش بنر هیرو' })
|
@ApiOperation({ summary: 'ویرایش بنر هیرو' })
|
||||||
updateHeroBanner(@Param('id') id: string, @Body() dto: Partial<CreateHeroBannerDto>) {
|
updateHeroBanner(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: Partial<CreateHeroBannerDto>,
|
||||||
|
) {
|
||||||
return this.cmsService.updateHeroBanner(id, dto);
|
return this.cmsService.updateHeroBanner(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,7 +70,10 @@ export class CmsController {
|
|||||||
|
|
||||||
@Put('vet-testimonials/:id')
|
@Put('vet-testimonials/:id')
|
||||||
@ApiOperation({ summary: 'ویرایش نظر دامپزشک' })
|
@ApiOperation({ summary: 'ویرایش نظر دامپزشک' })
|
||||||
updateVetTestimonial(@Param('id') id: string, @Body() dto: Partial<CreateVetTestimonialDto>) {
|
updateVetTestimonial(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: Partial<CreateVetTestimonialDto>,
|
||||||
|
) {
|
||||||
return this.cmsService.updateVetTestimonial(id, dto);
|
return this.cmsService.updateVetTestimonial(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,7 +98,10 @@ export class CmsController {
|
|||||||
|
|
||||||
@Put('smart-advisor-rules/:id')
|
@Put('smart-advisor-rules/:id')
|
||||||
@ApiOperation({ summary: 'ویرایش قانون مشاوره هوشمند' })
|
@ApiOperation({ summary: 'ویرایش قانون مشاوره هوشمند' })
|
||||||
updateSmartAdvisorRule(@Param('id') id: string, @Body() dto: Partial<CreateSmartAdvisorRuleDto>) {
|
updateSmartAdvisorRule(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: Partial<CreateSmartAdvisorRuleDto>,
|
||||||
|
) {
|
||||||
return this.cmsService.updateSmartAdvisorRule(id, dto);
|
return this.cmsService.updateSmartAdvisorRule(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
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 { CreateHeroBannerDto, CreateVetTestimonialDto, CreateSmartAdvisorRuleDto } from './dto/cms.dto';
|
import {
|
||||||
|
CreateHeroBannerDto,
|
||||||
|
CreateVetTestimonialDto,
|
||||||
|
CreateSmartAdvisorRuleDto,
|
||||||
|
} from './dto/cms.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CmsService {
|
export class CmsService {
|
||||||
@ -38,8 +42,13 @@ export class CmsService {
|
|||||||
return this.prisma.vetTestimonial.create({ data: dto });
|
return this.prisma.vetTestimonial.create({ data: dto });
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateVetTestimonial(id: string, dto: Partial<CreateVetTestimonialDto>) {
|
async updateVetTestimonial(
|
||||||
const exists = await this.prisma.vetTestimonial.findUnique({ where: { id } });
|
id: string,
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
@ -59,8 +68,13 @@ export class CmsService {
|
|||||||
return this.prisma.smartAdvisorRule.create({ data: dto });
|
return this.prisma.smartAdvisorRule.create({ data: dto });
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateSmartAdvisorRule(id: string, dto: Partial<CreateSmartAdvisorRuleDto>) {
|
async updateSmartAdvisorRule(
|
||||||
const exists = await this.prisma.smartAdvisorRule.findUnique({ where: { id } });
|
id: string,
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,10 @@
|
|||||||
import { IsString, IsOptional, IsBoolean, IsNumber, IsUUID } from 'class-validator';
|
import {
|
||||||
|
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 {
|
||||||
|
|||||||
@ -8,26 +8,41 @@ export enum SortOrder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class PaginationDto {
|
export class PaginationDto {
|
||||||
@ApiPropertyOptional({ description: 'شماره صفحه (شروع از ۱)', minimum: 1, default: 1 })
|
@ApiPropertyOptional({
|
||||||
|
description: 'شماره صفحه (شروع از ۱)',
|
||||||
|
minimum: 1,
|
||||||
|
default: 1,
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
page?: number = 1;
|
page?: number = 1;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'تعداد آیتمها در هر صفحه', minimum: 1, default: 10 })
|
@ApiPropertyOptional({
|
||||||
|
description: 'تعداد آیتمها در هر صفحه',
|
||||||
|
minimum: 1,
|
||||||
|
default: 10,
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
limit?: number = 10;
|
limit?: number = 10;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'فیلد برای مرتبسازی', default: 'createdAt' })
|
@ApiPropertyOptional({
|
||||||
|
description: 'فیلد برای مرتبسازی',
|
||||||
|
default: 'createdAt',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
sortBy?: string = 'createdAt';
|
sortBy?: string = 'createdAt';
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'جهت مرتبسازی (asc/desc)', enum: SortOrder, default: SortOrder.DESC })
|
@ApiPropertyOptional({
|
||||||
|
description: 'جهت مرتبسازی (asc/desc)',
|
||||||
|
enum: SortOrder,
|
||||||
|
default: SortOrder.DESC,
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(SortOrder)
|
@IsEnum(SortOrder)
|
||||||
sortOrder?: SortOrder = SortOrder.DESC;
|
sortOrder?: SortOrder = SortOrder.DESC;
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
|
import {
|
||||||
|
ExceptionFilter,
|
||||||
|
Catch,
|
||||||
|
ArgumentsHost,
|
||||||
|
HttpException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
|
||||||
@Catch(HttpException)
|
@Catch(HttpException)
|
||||||
@ -9,9 +14,17 @@ 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 = typeof exceptionResponse === 'string' ? exceptionResponse : (exceptionResponse.message || 'خطای سرور');
|
let message =
|
||||||
let code = typeof exceptionResponse === 'object' && exceptionResponse.error ? exceptionResponse.error : (status === 400 ? 'BAD_REQUEST' : 'ERROR');
|
typeof exceptionResponse === 'string'
|
||||||
|
? 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 = 'اطلاعات وارد شده نامعتبر است. لطفاً فرم را بررسی کنید.';
|
||||||
@ -30,7 +43,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
success: false,
|
success: false,
|
||||||
message,
|
message,
|
||||||
code,
|
code,
|
||||||
details: typeof exceptionResponse === 'object' ? exceptionResponse : {}
|
details: typeof exceptionResponse === 'object' ? exceptionResponse : {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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`;
|
||||||
|
|||||||
@ -4,12 +4,19 @@ export class ApiErrorResponse {
|
|||||||
@ApiProperty({ description: 'موفقیتآمیز بودن درخواست', example: false })
|
@ApiProperty({ description: 'موفقیتآمیز بودن درخواست', example: false })
|
||||||
success: boolean;
|
success: boolean;
|
||||||
|
|
||||||
@ApiProperty({ description: 'پیام خطا', example: 'اطلاعات وارد شده نامعتبر است. لطفاً فرم را بررسی کنید.' })
|
@ApiProperty({
|
||||||
|
description: 'پیام خطا',
|
||||||
|
example: 'اطلاعات وارد شده نامعتبر است. لطفاً فرم را بررسی کنید.',
|
||||||
|
})
|
||||||
message: string;
|
message: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'کد خطا', example: 'BAD_REQUEST' })
|
@ApiProperty({ description: 'کد خطا', example: 'BAD_REQUEST' })
|
||||||
code: string;
|
code: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'جزئیات خطا (در صورت وجود)', required: false, example: {} })
|
@ApiProperty({
|
||||||
|
description: 'جزئیات خطا (در صورت وجود)',
|
||||||
|
required: false,
|
||||||
|
example: {},
|
||||||
|
})
|
||||||
details?: any;
|
details?: any;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,19 +1,26 @@
|
|||||||
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 { ApiTags, ApiOperation, ApiResponse, ApiOkResponse } from '@nestjs/swagger';
|
import {
|
||||||
|
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({ description: 'اطلاعات ویترین، بنرها، پرفروشترینها، وبلاگ و غیره' })
|
@ApiOkResponse({
|
||||||
|
description: 'اطلاعات ویترین، بنرها، پرفروشترینها، وبلاگ و غیره',
|
||||||
|
})
|
||||||
getHomeData() {
|
getHomeData() {
|
||||||
return this.homeService.getHomeData();
|
return this.homeService.getHomeData();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,41 +9,46 @@ 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(helmet({
|
app.use(
|
||||||
contentSecurityPolicy: false, // Avoid blocking Swagger UI scripts and assets
|
helmet({
|
||||||
}));
|
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(new ValidationPipe({
|
app.useGlobalPipes(
|
||||||
transform: true,
|
new ValidationPipe({
|
||||||
whitelist: true,
|
transform: true,
|
||||||
forbidNonWhitelisted: true,
|
whitelist: 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('API Documentation for Canina Iran Pet Health & Supplement Platform')
|
.setDescription(
|
||||||
|
'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();
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsNotEmpty, IsArray, ValidateNested, IsNumber, IsOptional, IsString } from 'class-validator';
|
import {
|
||||||
|
IsNotEmpty,
|
||||||
|
IsArray,
|
||||||
|
ValidateNested,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
class OrderItemDto {
|
class OrderItemDto {
|
||||||
@ -25,12 +32,16 @@ export class CreateOrderDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
couponCode?: string;
|
couponCode?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'آدرس/شناسه تصویر نسخه پزشکی (برای داروهای نیازمند نسخه)' })
|
@ApiPropertyOptional({
|
||||||
|
description: 'آدرس/شناسه تصویر نسخه پزشکی (برای داروهای نیازمند نسخه)',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
prescriptionUrl?: string;
|
prescriptionUrl?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'مبلغ کمک به پناهگاه حیوانات (ردپای مهربانی)' })
|
@ApiPropertyOptional({
|
||||||
|
description: 'مبلغ کمک به پناهگاه حیوانات (ردپای مهربانی)',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
charityDonation?: number;
|
charityDonation?: number;
|
||||||
|
|||||||
@ -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.fn().mockResolvedValue([{ id: 'order-id', totalAmount: 1000 }]),
|
findAllByUser: jest
|
||||||
|
.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: [
|
providers: [{ provide: OrdersService, useValue: mockOrdersService }],
|
||||||
{ provide: OrdersService, useValue: mockOrdersService },
|
|
||||||
],
|
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
controller = module.get<OrdersController>(OrdersController);
|
controller = module.get<OrdersController>(OrdersController);
|
||||||
|
|||||||
@ -1,8 +1,29 @@
|
|||||||
import { Controller, Get, Post, Patch, Body, Param, UseGuards, Req, HttpStatus, Query } from '@nestjs/common';
|
import {
|
||||||
|
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 { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiOkResponse, ApiCreatedResponse, ApiBadRequestResponse, ApiNotFoundResponse, ApiBody } from '@nestjs/swagger';
|
import {
|
||||||
|
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';
|
||||||
|
|
||||||
@ -27,9 +48,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) {}
|
||||||
@ -47,9 +68,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: 'اعتبارسنجی اقلام سبد خرید با خطا مواجه شد',
|
||||||
@ -58,9 +79,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);
|
||||||
@ -77,13 +98,19 @@ 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(body.code, body.cartTotal, req.user.id);
|
return this.ordersService.validateCoupon(
|
||||||
|
body.code,
|
||||||
|
body.cartTotal,
|
||||||
|
req.user.id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ -99,17 +126,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);
|
||||||
@ -136,12 +163,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: 'سفارش یافت نشد یا متعلق به کاربر فعلی نیست',
|
||||||
@ -150,9 +177,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);
|
||||||
|
|||||||
@ -43,23 +43,32 @@ 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(NotFoundException);
|
await expect(service.create('user-id', dto)).rejects.toThrow(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw BadRequestException if items are empty', async () => {
|
it('should throw BadRequestException if items are empty', async () => {
|
||||||
const dto = { items: [] };
|
const dto = { items: [] };
|
||||||
await expect(service.create('user-id', dto)).rejects.toThrow(BadRequestException);
|
await expect(service.create('user-id', dto)).rejects.toThrow(
|
||||||
|
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({ id: 'order-1', totalAmount: 2000 });
|
mockPrisma.order.create.mockResolvedValue({
|
||||||
|
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({ where: { id: 'prod-1' } });
|
expect(prisma.product.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'prod-1' },
|
||||||
|
});
|
||||||
expect(prisma.order.create).toHaveBeenCalledWith({
|
expect(prisma.order.create).toHaveBeenCalledWith({
|
||||||
data: {
|
data: {
|
||||||
userId: 'user-id',
|
userId: 'user-id',
|
||||||
@ -92,7 +101,9 @@ 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(NotFoundException);
|
await expect(service.findOne('order-id', 'user-id')).rejects.toThrow(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return order if found', async () => {
|
it('should return order if found', async () => {
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import {
|
||||||
|
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';
|
||||||
@ -22,28 +26,43 @@ export class OrdersService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!coupon || !coupon.isActive) {
|
if (!coupon || !coupon.isActive) {
|
||||||
throw new BadRequestException({ message: 'کد تخفیف معتبر نیست یا منقضی شده است', error: 'COUPON_INVALID' });
|
throw new BadRequestException({
|
||||||
|
message: 'کد تخفیف معتبر نیست یا منقضی شده است',
|
||||||
|
error: 'COUPON_INVALID',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coupon.expiresAt && new Date() > coupon.expiresAt) {
|
if (coupon.expiresAt && new Date() > coupon.expiresAt) {
|
||||||
throw new BadRequestException({ message: 'کد تخفیف منقضی شده است', error: 'COUPON_EXPIRED' });
|
throw new BadRequestException({
|
||||||
|
message: 'کد تخفیف منقضی شده است',
|
||||||
|
error: 'COUPON_EXPIRED',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (coupon.maxUses && coupon.usedCount >= coupon.maxUses) {
|
if (coupon.maxUses && coupon.usedCount >= coupon.maxUses) {
|
||||||
throw new BadRequestException({ message: 'ظرفیت استفاده از این کد تخفیف تکمیل شده است', error: 'COUPON_LIMIT_REACHED' });
|
throw new BadRequestException({
|
||||||
|
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 (userTargets.length > 0 && !userTargets.some(t => t.targetId === userId)) {
|
if (
|
||||||
throw new BadRequestException({ message: 'این کد تخفیف برای حساب شما معتبر نیست', error: 'COUPON_NOT_FOR_USER' });
|
userTargets.length > 0 &&
|
||||||
|
!userTargets.some((t) => t.targetId === userId)
|
||||||
|
) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
message: 'این کد تخفیف برای حساب شما معتبر نیست',
|
||||||
|
error: 'COUPON_NOT_FOR_USER',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let discountValue: number;
|
let discountValue: number;
|
||||||
@ -74,9 +93,13 @@ 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({ where: { id: item.productId } });
|
const product = await this.prisma.product.findUnique({
|
||||||
|
where: { id: item.productId },
|
||||||
|
});
|
||||||
if (!product) {
|
if (!product) {
|
||||||
throw new NotFoundException(`محصول با شناسه ${item.productId} یافت نشد`);
|
throw new NotFoundException(
|
||||||
|
`محصول با شناسه ${item.productId} یافت نشد`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
totalAmount += Number(product.priceValue) * item.quantity;
|
totalAmount += Number(product.priceValue) * item.quantity;
|
||||||
orderItems.push({
|
orderItems.push({
|
||||||
@ -93,7 +116,11 @@ export class OrdersService {
|
|||||||
let discountAmount = 0;
|
let discountAmount = 0;
|
||||||
if (createOrderDto.couponCode) {
|
if (createOrderDto.couponCode) {
|
||||||
try {
|
try {
|
||||||
const couponResult = await this.validateCoupon(createOrderDto.couponCode, totalAmount, userId);
|
const couponResult = await this.validateCoupon(
|
||||||
|
createOrderDto.couponCode,
|
||||||
|
totalAmount,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
discountAmount = couponResult.discountValue;
|
discountAmount = couponResult.discountValue;
|
||||||
couponId = couponResult.couponId;
|
couponId = couponResult.couponId;
|
||||||
// Increment usedCount
|
// Increment usedCount
|
||||||
@ -107,7 +134,10 @@ export class OrdersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const charityAmount = Number(createOrderDto.charityDonation || 0);
|
const charityAmount = Number(createOrderDto.charityDonation || 0);
|
||||||
const finalAmount = Math.max(totalAmount - discountAmount + charityAmount, 0);
|
const finalAmount = Math.max(
|
||||||
|
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
|
||||||
@ -118,13 +148,15 @@ 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: {
|
||||||
@ -132,8 +164,8 @@ export class OrdersService {
|
|||||||
amount: finalAmount,
|
amount: finalAmount,
|
||||||
type: 'withdrawal',
|
type: 'withdrawal',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
description: `پرداخت سفارش ${trackingNumber}`
|
description: `پرداخت سفارش ${trackingNumber}`,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,7 +174,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
|
||||||
@ -165,14 +197,19 @@ 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 { page = 1, limit = 10, sortBy = 'createdAt', sortOrder = 'desc' } = filters;
|
const {
|
||||||
|
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([
|
||||||
@ -185,7 +222,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 {
|
||||||
@ -195,7 +232,7 @@ export class OrdersService {
|
|||||||
page,
|
page,
|
||||||
lastPage: Math.ceil(total / limit),
|
lastPage: Math.ceil(total / limit),
|
||||||
limit,
|
limit,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,10 @@ export class CreateHealthLogDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
digestion: string;
|
digestion: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'یادداشت یا توضیح اضافی', example: 'امروز فعالیت خوبی داشت.' })
|
@ApiPropertyOptional({
|
||||||
|
description: 'یادداشت یا توضیح اضافی',
|
||||||
|
example: 'امروز فعالیت خوبی داشت.',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
note?: string;
|
note?: string;
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsNotEmpty, IsString, IsOptional, IsNumber, IsArray } from 'class-validator';
|
import {
|
||||||
|
IsNotEmpty,
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsNumber,
|
||||||
|
IsArray,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreatePetDto {
|
export class CreatePetDto {
|
||||||
@ApiProperty({ description: 'نام حیوان خانگی', example: 'بادی' })
|
@ApiProperty({ description: 'نام حیوان خانگی', example: 'بادی' })
|
||||||
|
|||||||
@ -12,12 +12,18 @@ export class CreateReminderDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
time: string;
|
time: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'دوره زمانی (روزانه / هفتگی)', example: 'روزانه' })
|
@ApiProperty({
|
||||||
|
description: 'دوره زمانی (روزانه / هفتگی)',
|
||||||
|
example: 'روزانه',
|
||||||
|
})
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@IsString()
|
@IsString()
|
||||||
frequency: string;
|
frequency: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'شناسه محصول مربوطه', example: 'a1b2c3d4-1234-5678-abcd-ef1234567890' })
|
@ApiPropertyOptional({
|
||||||
|
description: 'شناسه محصول مربوطه',
|
||||||
|
example: 'a1b2c3d4-1234-5678-abcd-ef1234567890',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
productId?: string;
|
productId?: string;
|
||||||
|
|||||||
@ -8,7 +8,9 @@ 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.fn().mockResolvedValue([{ id: 'pet-id', name: 'Buddy' }]),
|
findAllByUser: jest
|
||||||
|
.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 }),
|
||||||
@ -17,9 +19,7 @@ describe('PetsController', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [PetsController],
|
controllers: [PetsController],
|
||||||
providers: [
|
providers: [{ provide: PetsService, useValue: mockPetsService }],
|
||||||
{ provide: PetsService, useValue: mockPetsService },
|
|
||||||
],
|
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
controller = module.get<PetsController>(PetsController);
|
controller = module.get<PetsController>(PetsController);
|
||||||
|
|||||||
@ -1,11 +1,32 @@
|
|||||||
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Req, HttpStatus, Query } from '@nestjs/common';
|
import {
|
||||||
|
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 { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiOkResponse, ApiCreatedResponse, ApiBadRequestResponse, ApiNotFoundResponse } from '@nestjs/swagger';
|
import {
|
||||||
|
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 - مدیریت حیوانات خانگی')
|
||||||
@ -20,9 +41,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) {}
|
||||||
@ -42,9 +63,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: 'خطا در صحتسنجی فیلدهای ورودی',
|
||||||
@ -53,9 +74,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);
|
||||||
@ -78,17 +99,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);
|
||||||
@ -112,9 +133,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: 'حیوان خانگی پیدا نشد یا متعلق به کاربر جاری نیست',
|
||||||
@ -123,9 +144,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);
|
||||||
@ -143,9 +164,9 @@ export class PetsController {
|
|||||||
breed: 'ژرمن شپرد',
|
breed: 'ژرمن شپرد',
|
||||||
age: 4,
|
age: 4,
|
||||||
weight: '26.00',
|
weight: '26.00',
|
||||||
activityLevel: 'زیاد'
|
activityLevel: 'زیاد',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
@ApiNotFoundResponse({
|
@ApiNotFoundResponse({
|
||||||
description: 'حیوان خانگی یافت نشد',
|
description: 'حیوان خانگی یافت نشد',
|
||||||
@ -154,11 +175,15 @@ export class PetsController {
|
|||||||
success: false,
|
success: false,
|
||||||
message: 'Pet not found',
|
message: 'Pet not found',
|
||||||
code: 'NOT_FOUND',
|
code: 'NOT_FOUND',
|
||||||
details: {}
|
details: {},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
update(@Req() req: any, @Param('id') id: string, @Body() updatePetDto: UpdatePetDto) {
|
update(
|
||||||
|
@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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,9 +194,9 @@ export class PetsController {
|
|||||||
schema: {
|
schema: {
|
||||||
example: {
|
example: {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'حیوان خانگی با موفقیت حذف شد'
|
message: 'حیوان خانگی با موفقیت حذف شد',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
@ApiNotFoundResponse({
|
@ApiNotFoundResponse({
|
||||||
description: 'حیوان خانگی یافت نشد',
|
description: 'حیوان خانگی یافت نشد',
|
||||||
@ -180,9 +205,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);
|
||||||
@ -206,7 +231,12 @@ export class PetsController {
|
|||||||
@Param('reminderId') reminderId: string,
|
@Param('reminderId') reminderId: string,
|
||||||
@Body('date') date: string,
|
@Body('date') date: string,
|
||||||
) {
|
) {
|
||||||
return this.petsService.toggleReminder(req.user.id, petId, reminderId, date);
|
return this.petsService.toggleReminder(
|
||||||
|
req.user.id,
|
||||||
|
petId,
|
||||||
|
reminderId,
|
||||||
|
date,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':petId/health-logs')
|
@Post(':petId/health-logs')
|
||||||
@ -216,6 +246,10 @@ export class PetsController {
|
|||||||
@Param('petId') petId: string,
|
@Param('petId') petId: string,
|
||||||
@Body() createHealthLogDto: CreateHealthLogDto,
|
@Body() createHealthLogDto: CreateHealthLogDto,
|
||||||
) {
|
) {
|
||||||
return this.petsService.addHealthLog(req.user.id, petId, createHealthLogDto);
|
return this.petsService.addHealthLog(
|
||||||
|
req.user.id,
|
||||||
|
petId,
|
||||||
|
createHealthLogDto,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,7 +58,9 @@ 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(NotFoundException);
|
await expect(service.findOne('pet-id', 'user-id')).rejects.toThrow(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return pet if found', async () => {
|
it('should return pet if found', async () => {
|
||||||
|
|||||||
@ -9,7 +9,8 @@ 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 } = createPetDto;
|
const { name, type, breed, weight, age, activityLevel, medicalConditions } =
|
||||||
|
createPetDto;
|
||||||
|
|
||||||
return this.prisma.pet.create({
|
return this.prisma.pet.create({
|
||||||
data: {
|
data: {
|
||||||
@ -20,16 +21,24 @@ 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 }))
|
? {
|
||||||
} : undefined,
|
create: medicalConditions.map((condition) => ({ condition })),
|
||||||
|
}
|
||||||
|
: 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 { search, page = 1, limit = 10, sortBy = 'createdAt', sortOrder = 'desc' } = filters;
|
const {
|
||||||
|
search,
|
||||||
|
page = 1,
|
||||||
|
limit = 10,
|
||||||
|
sortBy = 'createdAt',
|
||||||
|
sortOrder = 'desc',
|
||||||
|
} = filters;
|
||||||
|
|
||||||
const whereClause: any = { userId };
|
const whereClause: any = { userId };
|
||||||
if (search) {
|
if (search) {
|
||||||
@ -48,7 +57,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 {
|
||||||
@ -58,7 +67,7 @@ export class PetsService {
|
|||||||
page,
|
page,
|
||||||
lastPage: Math.ceil(total / limit),
|
lastPage: Math.ceil(total / limit),
|
||||||
limit,
|
limit,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,7 +86,9 @@ 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({ where: { petId: id } });
|
await this.prisma.petMedicalCondition.deleteMany({
|
||||||
|
where: { petId: id },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.pet.update({
|
return this.prisma.pet.update({
|
||||||
@ -89,9 +100,13 @@ 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 }))
|
? {
|
||||||
} : undefined,
|
create: updatePetDto.medicalConditions.map((condition) => ({
|
||||||
|
condition,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
include: { medicalConditions: true, reminders: true, healthLogs: true },
|
include: { medicalConditions: true, reminders: true, healthLogs: true },
|
||||||
});
|
});
|
||||||
@ -117,7 +132,12 @@ export class PetsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleReminder(userId: string, petId: string, reminderId: string, dateStr: string) {
|
async toggleReminder(
|
||||||
|
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({
|
||||||
@ -128,7 +148,13 @@ export class PetsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dateParts = dateStr.split('-');
|
const dateParts = dateStr.split('-');
|
||||||
const completedDate = new Date(Date.UTC(Number(dateParts[0]), Number(dateParts[1]) - 1, Number(dateParts[2])));
|
const completedDate = new Date(
|
||||||
|
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: {
|
||||||
|
|||||||
@ -2,7 +2,10 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
export class PrismaService
|
||||||
|
extends PrismaClient
|
||||||
|
implements OnModuleInit, OnModuleDestroy
|
||||||
|
{
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
await this.$connect();
|
await this.$connect();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,10 @@ export class GetProductsDto extends PaginationDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
category?: string;
|
category?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'فیلتر بر اساس نوع حیوان', enum: ['سگ', 'گربه', 'all'] })
|
@ApiPropertyOptional({
|
||||||
|
description: 'فیلتر بر اساس نوع حیوان',
|
||||||
|
enum: ['سگ', 'گربه', 'all'],
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(['سگ', 'گربه', 'all'])
|
@IsEnum(['سگ', 'گربه', 'all'])
|
||||||
petType?: string;
|
petType?: string;
|
||||||
|
|||||||
@ -8,16 +8,16 @@ describe('ProductsController', () => {
|
|||||||
let service: ProductsService;
|
let service: ProductsService;
|
||||||
|
|
||||||
const mockProductsService = {
|
const mockProductsService = {
|
||||||
findAll: jest.fn().mockResolvedValue([{ id: 'prod-id', name: 'Product 1' }]),
|
findAll: jest
|
||||||
|
.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: [
|
providers: [{ provide: ProductsService, useValue: mockProductsService }],
|
||||||
{ provide: ProductsService, useValue: mockProductsService },
|
|
||||||
],
|
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
controller = module.get<ProductsController>(ProductsController);
|
controller = module.get<ProductsController>(ProductsController);
|
||||||
@ -42,7 +42,9 @@ 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(NotFoundException);
|
await expect(controller.findOne('invalid-id')).rejects.toThrow(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return product details if found', async () => {
|
it('should return product details if found', async () => {
|
||||||
|
|||||||
@ -1,7 +1,20 @@
|
|||||||
import { Controller, Get, Query, Param, NotFoundException, HttpStatus } from '@nestjs/common';
|
import {
|
||||||
|
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 { ApiTags, ApiOperation, ApiResponse, ApiOkResponse, ApiNotFoundResponse } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiNotFoundResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
@ApiTags('Products - مدیریت محصولات دارویی')
|
@ApiTags('Products - مدیریت محصولات دارویی')
|
||||||
@Controller('products')
|
@Controller('products')
|
||||||
@ -13,9 +26,9 @@ import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse, ApiNotFoundResponse
|
|||||||
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) {}
|
||||||
@ -32,7 +45,8 @@ 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',
|
||||||
@ -45,40 +59,44 @@ 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({ summary: 'دریافت فیلترهای فعال محصولات (دسته، علائم، نوع پت)' })
|
@ApiOperation({
|
||||||
|
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({ summary: 'دریافت فیلترها و راهکارهای ناوبری (دسته با علائم مربوطه)' })
|
@ApiOperation({
|
||||||
|
summary: 'دریافت فیلترها و راهکارهای ناوبری (دسته با علائم مربوطه)',
|
||||||
|
})
|
||||||
@ApiOkResponse({
|
@ApiOkResponse({
|
||||||
description: 'ساختار درختی فیلترها و تگهای درمانی واقعی متصل به محصولات',
|
description: 'ساختار درختی فیلترها و تگهای درمانی واقعی متصل به محصولات',
|
||||||
schema: {
|
schema: {
|
||||||
@ -87,10 +105,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();
|
||||||
@ -119,24 +137,31 @@ 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: 'Product with ID a1b2c3d4-1234-5678-abcd-ef1234567890 not found',
|
message:
|
||||||
|
'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);
|
||||||
|
|||||||
@ -7,7 +7,17 @@ export class ProductsService {
|
|||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
async findAll(filters: GetProductsDto, userRole?: string) {
|
async findAll(filters: GetProductsDto, userRole?: string) {
|
||||||
const { category, petType, search, symptom, requiresRx, page = 1, limit = 10, sortBy = 'createdAt', sortOrder = 'desc' } = filters;
|
const {
|
||||||
|
category,
|
||||||
|
petType,
|
||||||
|
search,
|
||||||
|
symptom,
|
||||||
|
requiresRx,
|
||||||
|
page = 1,
|
||||||
|
limit = 10,
|
||||||
|
sortBy = 'createdAt',
|
||||||
|
sortOrder = 'desc',
|
||||||
|
} = filters;
|
||||||
|
|
||||||
const whereClause: any = {};
|
const whereClause: any = {};
|
||||||
|
|
||||||
@ -27,8 +37,8 @@ export class ProductsService {
|
|||||||
if (symptom) {
|
if (symptom) {
|
||||||
whereClause.symptoms = {
|
whereClause.symptoms = {
|
||||||
some: {
|
some: {
|
||||||
symptom: { contains: symptom, mode: 'insensitive' }
|
symptom: { contains: symptom, mode: 'insensitive' },
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,9 +55,9 @@ export class ProductsService {
|
|||||||
{
|
{
|
||||||
symptoms: {
|
symptoms: {
|
||||||
some: {
|
some: {
|
||||||
symptom: { contains: search, mode: 'insensitive' }
|
symptom: { contains: search, mode: 'insensitive' },
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -55,7 +65,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 {
|
||||||
@ -79,8 +89,9 @@ export class ProductsService {
|
|||||||
this.prisma.product.count({ where: whereClause }),
|
this.prisma.product.count({ where: whereClause }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isWholesaleOrAdmin = userRole === 'User_Wholesale' || userRole === 'ADMIN';
|
const isWholesaleOrAdmin =
|
||||||
const data = rawProducts.map(p => {
|
userRole === 'User_Wholesale' || userRole === 'ADMIN';
|
||||||
|
const data = rawProducts.map((p) => {
|
||||||
if (!isWholesaleOrAdmin) {
|
if (!isWholesaleOrAdmin) {
|
||||||
const { wholesalePrice, ...rest } = p;
|
const { wholesalePrice, ...rest } = p;
|
||||||
return rest;
|
return rest;
|
||||||
@ -100,7 +111,10 @@ export class ProductsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findOne(idOrSlug: string, userRole?: string) {
|
async findOne(idOrSlug: string, userRole?: string) {
|
||||||
const isUuid = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(idOrSlug);
|
const isUuid =
|
||||||
|
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(
|
||||||
|
idOrSlug,
|
||||||
|
);
|
||||||
const 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: {
|
||||||
@ -110,7 +124,8 @@ export class ProductsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!product) return null;
|
if (!product) return null;
|
||||||
const isWholesaleOrAdmin = userRole === 'User_Wholesale' || userRole === 'ADMIN';
|
const isWholesaleOrAdmin =
|
||||||
|
userRole === 'User_Wholesale' || userRole === 'ADMIN';
|
||||||
if (!isWholesaleOrAdmin) {
|
if (!isWholesaleOrAdmin) {
|
||||||
const { wholesalePrice, ...rest } = product;
|
const { wholesalePrice, ...rest } = product;
|
||||||
return rest;
|
return rest;
|
||||||
@ -123,33 +138,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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,22 +172,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);
|
||||||
}
|
}
|
||||||
@ -183,7 +198,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),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,9 @@ export class SeoController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('product-schema/:idOrSlug')
|
@Get('product-schema/:idOrSlug')
|
||||||
@ApiOperation({ summary: 'دریافت متادیتای ساختاریافته Schema.org JSON-LD محصول' })
|
@ApiOperation({
|
||||||
|
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) {
|
||||||
|
|||||||
@ -8,19 +8,36 @@ 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({ select: { slug: true, createdAt: true } }),
|
this.prisma.category.findMany({
|
||||||
this.prisma.blog.findMany({ where: { isPublished: true }, select: { slug: true, updatedAt: true } }),
|
select: { slug: true, createdAt: true },
|
||||||
|
}),
|
||||||
|
this.prisma.blog.findMany({
|
||||||
|
where: { isPublished: true },
|
||||||
|
select: { slug: true, updatedAt: true },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
products: products.map(p => ({ url: `/shop/${p.slug}`, lastmod: p.createdAt })),
|
products: products.map((p) => ({
|
||||||
categories: categories.map(c => ({ url: `/shop?category=${c.slug}`, lastmod: c.createdAt })),
|
url: `/shop/${p.slug}`,
|
||||||
blogs: blogs.map(b => ({ url: `/blog/${b.slug}`, lastmod: b.updatedAt })),
|
lastmod: p.createdAt,
|
||||||
|
})),
|
||||||
|
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 = /^[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 isUuid =
|
||||||
|
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(
|
||||||
|
idOrSlug,
|
||||||
|
);
|
||||||
const 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 },
|
||||||
});
|
});
|
||||||
@ -28,26 +45,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"
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,9 +17,7 @@ describe('SettingsController', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [SettingsController],
|
controllers: [SettingsController],
|
||||||
providers: [
|
providers: [{ provide: SettingsService, useValue: mockSettingsService }],
|
||||||
{ provide: SettingsService, useValue: mockSettingsService },
|
|
||||||
],
|
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
controller = module.get<SettingsController>(SettingsController);
|
controller = module.get<SettingsController>(SettingsController);
|
||||||
|
|||||||
@ -1,7 +1,24 @@
|
|||||||
import { Controller, Get, Patch, Put, Delete, Body, Param, UseGuards, HttpStatus } from '@nestjs/common';
|
import {
|
||||||
|
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 { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiOkResponse, ApiUnauthorizedResponse } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiUnauthorizedResponse,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
@ApiTags('Settings - تنظیمات متون پویا و واژهنامه علمی')
|
@ApiTags('Settings - تنظیمات متون پویا و واژهنامه علمی')
|
||||||
@Controller('settings')
|
@Controller('settings')
|
||||||
@ -11,14 +28,15 @@ 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();
|
||||||
@ -33,13 +51,19 @@ export class SettingsController {
|
|||||||
schema: {
|
schema: {
|
||||||
example: {
|
example: {
|
||||||
key: 'hero_badge',
|
key: 'hero_badge',
|
||||||
value: 'تخصص دارویی ممتاز از آلمان'
|
value: 'تخصص دارویی ممتاز از آلمان',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
@ApiUnauthorizedResponse({
|
@ApiUnauthorizedResponse({
|
||||||
description: 'عدم دسترسی به دلیل عدم احراز هویت',
|
description: 'عدم دسترسی به دلیل عدم احراز هویت',
|
||||||
schema: { example: { success: false, message: 'Unauthorized', code: 'UNAUTHORIZED' } }
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: false,
|
||||||
|
message: 'Unauthorized',
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
updateUiText(@Param('key') key: string, @Body('value') value: string) {
|
updateUiText(@Param('key') key: string, @Body('value') value: string) {
|
||||||
return this.settingsService.updateUiText(key, value);
|
return this.settingsService.updateUiText(key, value);
|
||||||
@ -55,10 +79,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();
|
||||||
@ -75,13 +99,19 @@ export class SettingsController {
|
|||||||
key: 'green-mussel',
|
key: 'green-mussel',
|
||||||
term: 'صدف لبسبز اصل نیوزیلند',
|
term: 'صدف لبسبز اصل نیوزیلند',
|
||||||
definition: 'این صدف بومی سواحل بکر نیوزیلند است...',
|
definition: 'این صدف بومی سواحل بکر نیوزیلند است...',
|
||||||
wikiId: 'general'
|
wikiId: 'general',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
@ApiUnauthorizedResponse({
|
@ApiUnauthorizedResponse({
|
||||||
description: 'عدم دسترسی',
|
description: 'عدم دسترسی',
|
||||||
schema: { example: { success: false, message: 'Unauthorized', code: 'UNAUTHORIZED' } }
|
schema: {
|
||||||
|
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);
|
||||||
@ -96,13 +126,19 @@ 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: { example: { success: false, message: 'Unauthorized', code: 'UNAUTHORIZED' } }
|
schema: {
|
||||||
|
example: {
|
||||||
|
success: false,
|
||||||
|
message: 'Unauthorized',
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
deleteScientificTerm(@Param('key') key: string) {
|
deleteScientificTerm(@Param('key') key: string) {
|
||||||
return this.settingsService.deleteScientificTerm(key);
|
return this.settingsService.deleteScientificTerm(key);
|
||||||
|
|||||||
@ -8,9 +8,13 @@ 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.fn().mockResolvedValue({ id: 'user-id', firstName: 'Updated' }),
|
update: jest
|
||||||
|
.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.fn().mockResolvedValue({ id: 'addr-id', title: 'Work' }),
|
updateAddress: jest
|
||||||
|
.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 }),
|
||||||
};
|
};
|
||||||
@ -18,9 +22,7 @@ describe('UsersController', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [UsersController],
|
controllers: [UsersController],
|
||||||
providers: [
|
providers: [{ provide: UsersService, useValue: mockUsersService }],
|
||||||
{ provide: UsersService, useValue: mockUsersService },
|
|
||||||
],
|
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
controller = module.get<UsersController>(UsersController);
|
controller = module.get<UsersController>(UsersController);
|
||||||
@ -78,7 +80,11 @@ 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('user-id', 'addr-id', dto);
|
expect(service.updateAddress).toHaveBeenCalledWith(
|
||||||
|
'user-id',
|
||||||
|
'addr-id',
|
||||||
|
dto,
|
||||||
|
);
|
||||||
expect(result.title).toBe('Work');
|
expect(result.title).toBe('Work');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -92,7 +98,10 @@ 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('user-id', 'addr-id');
|
expect(service.setDefaultAddress).toHaveBeenCalledWith(
|
||||||
|
'user-id',
|
||||||
|
'addr-id',
|
||||||
|
);
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,27 @@
|
|||||||
import { Controller, Get, Patch, Post, Delete, Body, Param, UseGuards, Req, HttpStatus, BadRequestException } from '@nestjs/common';
|
import {
|
||||||
|
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 { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiOkResponse, ApiCreatedResponse, ApiBadRequestResponse } from '@nestjs/swagger';
|
import {
|
||||||
|
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';
|
||||||
|
|
||||||
@ -16,9 +36,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) {}
|
||||||
@ -41,9 +61,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);
|
||||||
@ -63,9 +83,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: 'خطا در صحتسنجی فیلدهای ورودی',
|
||||||
@ -75,10 +95,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);
|
||||||
@ -101,9 +121,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);
|
||||||
@ -126,9 +146,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,
|
||||||
@ -146,9 +166,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);
|
||||||
@ -162,9 +182,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);
|
||||||
@ -183,9 +203,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 }) {
|
||||||
|
|||||||
@ -57,8 +57,11 @@ 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({ id: 'addr-id', ...addressData });
|
mockPrisma.userAddress.create.mockResolvedValue({
|
||||||
|
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' },
|
||||||
@ -70,9 +73,16 @@ 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({ id: 'addr-id', ...addressData });
|
mockPrisma.userAddress.update.mockResolvedValue({
|
||||||
|
id: 'addr-id',
|
||||||
|
...addressData,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await service.updateAddress('user-id', 'addr-id', addressData);
|
const result = await service.updateAddress(
|
||||||
|
'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');
|
||||||
@ -86,7 +96,10 @@ describe('UsersService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should setDefaultAddress', async () => {
|
it('should setDefaultAddress', async () => {
|
||||||
mockPrisma.userAddress.update.mockResolvedValue({ id: 'addr-id', isDefault: true });
|
mockPrisma.userAddress.update.mockResolvedValue({
|
||||||
|
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' },
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,12 +2,18 @@ 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({ description: 'عنوان ویدئوی آموزشی', example: 'نحوه آمادهسازی کانیهیدروکس GAG' })
|
@ApiProperty({
|
||||||
|
description: 'عنوان ویدئوی آموزشی',
|
||||||
|
example: 'نحوه آمادهسازی کانیهیدروکس GAG',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
title: string;
|
title: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'نام دکتر/ارائهدهنده', example: 'دکتر کلاوس هنینگ' })
|
@ApiProperty({
|
||||||
|
description: 'نام دکتر/ارائهدهنده',
|
||||||
|
example: 'دکتر کلاوس هنینگ',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
doctor: string;
|
doctor: string;
|
||||||
@ -17,12 +23,18 @@ export class CreateVideoDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
duration?: string;
|
duration?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'آدرس تصویر کاور', example: 'https://example.com/thumb.jpg' })
|
@ApiPropertyOptional({
|
||||||
|
description: 'آدرس تصویر کاور',
|
||||||
|
example: 'https://example.com/thumb.jpg',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
|
|
||||||
@ApiProperty({ description: 'آدرس فایل ویدئو', example: 'https://example.com/video.mp4' })
|
@ApiProperty({
|
||||||
|
description: 'آدرس فایل ویدئو',
|
||||||
|
example: 'https://example.com/video.mp4',
|
||||||
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
videoUrl: string;
|
videoUrl: string;
|
||||||
@ -32,7 +44,10 @@ export class CreateVideoDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'آیا ویدئوی ویژه/صفحه اصلی است؟', example: true })
|
@ApiPropertyOptional({
|
||||||
|
description: 'آیا ویدئوی ویژه/صفحه اصلی است؟',
|
||||||
|
example: true,
|
||||||
|
})
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isFeatured?: boolean;
|
isFeatured?: boolean;
|
||||||
|
|||||||
@ -1,8 +1,23 @@
|
|||||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
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 { ApiTags, ApiOperation, ApiQuery, ApiBearerAuth } from '@nestjs/swagger';
|
import {
|
||||||
|
ApiTags,
|
||||||
|
ApiOperation,
|
||||||
|
ApiQuery,
|
||||||
|
ApiBearerAuth,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
@ApiTags('Videos - مشاوره ویدئویی و آکادمی')
|
@ApiTags('Videos - مشاوره ویدئویی و آکادمی')
|
||||||
@Controller('videos')
|
@Controller('videos')
|
||||||
@ -37,7 +52,10 @@ export class VideosController {
|
|||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'ویرایش ویدئو (ادمین)' })
|
@ApiOperation({ summary: 'ویرایش ویدئو (ادمین)' })
|
||||||
update(@Param('id') id: string, @Body() updateVideoDto: Partial<CreateVideoDto>) {
|
update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() updateVideoDto: Partial<CreateVideoDto>,
|
||||||
|
) {
|
||||||
return this.videosService.update(id, updateVideoDto);
|
return this.videosService.update(id, updateVideoDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -56,10 +56,12 @@ export class VideosService {
|
|||||||
throw new NotFoundException('ویدئوی مورد نظر یافت نشد');
|
throw new NotFoundException('ویدئوی مورد نظر یافت نشد');
|
||||||
}
|
}
|
||||||
if (incrementView) {
|
if (incrementView) {
|
||||||
await this.video.update({
|
await this.video
|
||||||
where: { id },
|
.update({
|
||||||
data: { viewsCount: { increment: 1 } },
|
where: { id },
|
||||||
}).catch(() => {});
|
data: { viewsCount: { increment: 1 } },
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
return video;
|
return video;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,13 @@
|
|||||||
import { Controller, Post, Get, Put, Body, Param, UseGuards, Request } from '@nestjs/common';
|
import {
|
||||||
|
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';
|
||||||
@ -14,7 +23,9 @@ export class WholesaleController {
|
|||||||
@Post('apply')
|
@Post('apply')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'ثبت درخواست همکاری عمدهفروشی (ارسال پروانه کلینیک/داروخانه)' })
|
@ApiOperation({
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
@ -23,7 +34,9 @@ export class WholesaleController {
|
|||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles('ADMIN')
|
@Roles('ADMIN')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'لیست تمام درخواستهای همکاری عمدهفروشی (مخصوص ادمین)' })
|
@ApiOperation({
|
||||||
|
summary: 'لیست تمام درخواستهای همکاری عمدهفروشی (مخصوص ادمین)',
|
||||||
|
})
|
||||||
getWholesaleRequests() {
|
getWholesaleRequests() {
|
||||||
return this.wholesaleService.getWholesaleRequests();
|
return this.wholesaleService.getWholesaleRequests();
|
||||||
}
|
}
|
||||||
@ -32,7 +45,9 @@ export class WholesaleController {
|
|||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles('ADMIN')
|
@Roles('ADMIN')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiOperation({ summary: 'تایید درخواست و ارتقا به خریدار عمده (User_Wholesale)' })
|
@ApiOperation({
|
||||||
|
summary: 'تایید درخواست و ارتقا به خریدار عمده (User_Wholesale)',
|
||||||
|
})
|
||||||
approveWholesaleRequest(@Param('userId') userId: string) {
|
approveWholesaleRequest(@Param('userId') userId: string) {
|
||||||
return this.wholesaleService.approveWholesaleRequest(userId);
|
return this.wholesaleService.approveWholesaleRequest(userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,8 @@ 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,
|
||||||
@ -47,7 +48,8 @@ export class WholesaleService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'حساب کاربری با موفقیت به خریدار عمده (User_Wholesale) ارتقا یافت.',
|
message:
|
||||||
|
'حساب کاربری با موفقیت به خریدار عمده (User_Wholesale) ارتقا یافت.',
|
||||||
user: updated,
|
user: updated,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,19 @@
|
|||||||
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 { ApiTags, ApiOperation, ApiResponse, ApiOkResponse, ApiNotFoundResponse } from '@nestjs/swagger';
|
import {
|
||||||
|
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) {}
|
||||||
|
|||||||
@ -7,10 +7,16 @@ export class WikiService {
|
|||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
async findAll(filters: PaginationDto) {
|
async findAll(filters: PaginationDto) {
|
||||||
const { search, page = 1, limit = 10, sortBy: rawSortBy = 'term', sortOrder = 'asc' } = filters;
|
const {
|
||||||
|
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 = [
|
||||||
@ -28,7 +34,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 {
|
||||||
@ -38,13 +44,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;
|
||||||
|
|||||||
@ -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' : 'https://api.canina.ir');
|
: (import.meta.env.DEV ? 'http://127.0.0.1:4001' : '');
|
||||||
|
|
||||||
const baseURL = `${BASE_DOMAIN}/api`;
|
const baseURL = `${BASE_DOMAIN}/api`;
|
||||||
|
|
||||||
|
|||||||
@ -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' : 'https://api.canina.ir/api');
|
const baseURL = process.env.NEXT_PUBLIC_API_URL || (process.env.NODE_ENV === 'development' ? 'http://localhost:4001/api' : '/api');
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL,
|
baseURL,
|
||||||
|
|||||||
@ -15,13 +15,18 @@ const nextConfig: NextConfig = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
// Only proxy /api in development; in production nginx handles it
|
||||||
{
|
if (process.env.NODE_ENV === 'development') {
|
||||||
source: '/api/:path*',
|
return [
|
||||||
destination: 'http://localhost:4001/api/:path*',
|
{
|
||||||
},
|
source: '/api/:path*',
|
||||||
];
|
destination: 'http://localhost:4001/api/:path*',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
||||||
|
|||||||
@ -27,6 +27,7 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,5 +58,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
74
scripts/compose.prod.yml
Normal file
74
scripts/compose.prod.yml
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
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:
|
||||||
74
scripts/compose.stage.yml
Normal file
74
scripts/compose.stage.yml
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
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:
|
||||||
77
scripts/deploy.sh
Normal file
77
scripts/deploy.sh
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
#!/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!"
|
||||||
Loading…
Reference in New Issue
Block a user