From e4c196b08036d801b2eb6ead7ad43cba477029b0 Mon Sep 17 00:00:00 2001 From: devops Date: Mon, 27 Jul 2026 16:06:59 +0000 Subject: [PATCH 01/19] fix: VPN build, caching, Dockerfile fixes for Iran network --- Dockerfile | 24 +- backend/Dockerfile | 10 +- backend/prisma/scientificTerms.ts | 396 +++++++++++++++++++++++++++--- docker-compose.yml | 98 ++++---- nginx.conf | 10 +- 5 files changed, 431 insertions(+), 107 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0e4f6e7..62ffdb0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,24 +1,32 @@ -FROM node:22-alpine AS app-builder -RUN apk add --no-cache openssl +FROM node:20-alpine AS app-builder WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NEXT_PUBLIC_API_URL=https://stageapi.canina.ir/api COPY frontend/application/package*.json ./ -RUN npm config set registry http://registry.parsaaghayi.ir/repository/npm-proxy/ && npm ci +RUN npm ci --prefer-offline --no-audit COPY frontend/application ./ RUN npm run build -FROM node:22-alpine AS admin-builder -RUN apk add --no-cache openssl +FROM node:20-alpine AS admin-builder WORKDIR /app +ENV VITE_API_URL=https://stageapi.canina.ir/api COPY frontend/admin-panel/package*.json ./ -RUN npm config set registry http://registry.parsaaghayi.ir/repository/npm-proxy/ && npm ci +RUN npm ci --prefer-offline --no-audit COPY frontend/admin-panel ./ RUN npm run build -FROM node:22-alpine -RUN apk add --no-cache nginx +FROM node:20-alpine +RUN addgroup -S nginx 2>/dev/null || true && adduser -S nginx -G nginx 2>/dev/null || true +RUN mkdir -p /var/log/nginx /var/cache/nginx /run/nginx /etc/nginx/conf.d /etc/nginx/http.d +COPY --from=nginx:alpine /etc/nginx /etc/nginx +COPY --from=nginx:alpine /usr/sbin/nginx /usr/sbin/nginx +COPY --from=nginx:alpine /usr/lib /usr/lib +COPY --from=nginx:alpine /lib /lib +RUN chown -R nginx:nginx /var/log/nginx /var/cache/nginx /run/nginx 2>/dev/null || true WORKDIR /app COPY --from=app-builder /app . COPY --from=admin-builder /app/dist ./admin_static +COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/http.d/default.conf EXPOSE 8080 diff --git a/backend/Dockerfile b/backend/Dockerfile index d06a736..85217d1 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,13 +1,13 @@ -FROM node:22-alpine AS builder -RUN apk add --no-cache openssl +FROM node:20-alpine AS builder +RUN apk add --no-cache openssl 2>/dev/null || true WORKDIR /app COPY package*.json ./ -RUN npm config set registry http://registry.parsaaghayi.ir/repository/npm-proxy/ && npm ci +RUN npm ci --prefer-offline --no-audit COPY . . RUN npx prisma generate && npm run build -FROM node:22-alpine -RUN apk add --no-cache openssl +FROM node:20-alpine +RUN apk add --no-cache openssl 2>/dev/null || true RUN mkdir -p /app/uploads && chown node:node /app/uploads WORKDIR /app COPY --from=builder /app/package*.json ./ diff --git a/backend/prisma/scientificTerms.ts b/backend/prisma/scientificTerms.ts index 1bf2345..b6e5ca3 100644 --- a/backend/prisma/scientificTerms.ts +++ b/backend/prisma/scientificTerms.ts @@ -1,4 +1,4 @@ -export interface ScientificTerm { +export interface ScientificTerm { term: string; definition: string; wikiId?: string; @@ -7,28 +7,28 @@ export const SCIENTIFIC_TERMS: Record = { "گلیکوزآمینوگلیکان": { term: "گلیکوزآمینوگلیکان (GAG)", - definition: "ترکیبات ساختاری ضروری برای بازسازی غضروف‌ها و مایع مفصلی که در Canhydrox GAG به وفور یافت می‌شود.", - wikiId: "gag" + definition: "ترکیبات ساختاری ضروری برای بازسازی غضروف‌ها و مایع مفصلی که در مفاصل و تاندون‌ها نقش حیاتی دارند.", + wikiId: "green-mussel" }, "اسیدهای هومیک": { term: "اسیدهای هومیک", - definition: "مواد آلی طبیعی موجود در عصاره پیت (Moor) که از مخاط روده محافظت کرده و سموم را جذب می‌کنند.", - wikiId: "humic-acids" + definition: "مواد آلی طبیعی موجود در عصاره پیت (Moor) که از مخاط روده محافظت کرده و سموم را در گوارش به دام می‌اندازند.", + wikiId: "peat-extract" }, "کلاژن زیست‌فعال": { - term: "کلاژن زیست‌فعال", - definition: "پروتئین‌های اختصاصی هیدرولیز شده که به سرعت جذب استخوان و تاندون شده و استحکام ساختاری ایجاد می‌کنند.", + term: "کلاژن زیست‌فعال (Bioactive Collagen)", + definition: "پروتئین‌های اختصاصی هیدرولیز شده که به سرعت جذب استخوان و تاندون شده و بازسازی مفاصل را بهبود می‌بخشند.", wikiId: "collagen" }, "تیموکینون": { - term: "تیموکینون", - definition: "ماده موثره اصلی در روغن سیاه دانه (Nigella sativa) با خواص ضدالتهابی، ضدآلرژی و آنتی‌اکسیدان. با مهار مسیر NF-κB تولید سایتوکاین‌های پیش‌التهابی را کاهش می‌دهد.", - wikiId: "thymoquinone" + term: "تیموکینون (Thymoquinone)", + definition: "ماده موثره اصلی در روغن سیاه دانه با خواص ضدالتهابی، ضدآلرژی و آنتی‌اکسیدان بسیار قوی.", + wikiId: "black-cumin" }, "امگا ۳": { - term: "امگا ۳", - definition: "اسیدهای چرب ضروری شامل EPA و DHA که برای سلامت پوست، مو، قلب و عملکرد شناختی حیاتی هستند. EPA پیش‌ماده رزولوین‌های ضدالتهابی و DHA ۳۰٪ وزن خشک مغز را تشکیل می‌دهد.", - wikiId: "omega3" + term: "امگا ۳ (Omega-3)", + definition: "اسیدهای چرب ضروری شامل EPA و DHA که برای سلامت پوست، مو، قلب و عملکرد مغز حیاتی هستند.", + wikiId: "salmon-oil" }, "میکروسیلور": { term: "نقره میکروسیلور (Microsilver BG)", @@ -37,52 +37,374 @@ export const SCIENTIFIC_TERMS: Record = { }, "صدف لب‌سبز": { term: "صدف لب‌سبز (Green-lipped Mussel)", - definition: "صدف بومی نیوزلند حاوی گلوکوزامین‌گلیکان‌های طبیعی که نقش حیاتی در بازسازی غضروف و مفاصل دارند.", - wikiId: "joints" + definition: "صدف بومی نیوزلند حاوی گلوکوزامین‌گلیکان‌های طبیعی که نقش کلیدی در ساخت و بازسازی غضروف‌ها ایفا می‌کند.", + wikiId: "green-mussel" }, "نسبت Ca:P": { term: "نسبت کلسیم به فسفر (Ca:P Ratio)", - definition: "نسبت ایده‌آل کلسیم به فسفر در رژیم حیوانات ۱.۲:۱ تا ۱.۵:۱ است. عدم تعادل این نسبت منجر به مشکلات کلیوی و استخوانی می‌شود. پودر پوسته تخم مرغ کانینا با ۳۶٪ کلسیم و ۰.۱٪ فسفر بهترین تنظیم‌کننده این نسبت است.", - wikiId: "nutrition" + definition: "نسبت کلسیم به فسفر جهت حفظ سلامت استخوان‌ها و کلیه. پودر پوسته تخم مرغ بهترین تنظیم‌کننده این نسبت است.", + wikiId: "eggshell" }, "ویتامین D3": { term: "ویتامین D3 (کوله‌کلسیفرول)", - definition: "فرم فعال ویتامین D که جذب کلسیم از روده را تا ۳۰٪ افزایش می‌دهد. هشدار: مصرف همزمان با ویتامین D2 (ارگوکلسیفرول) مجاز نیست زیرا رقابت این دو جذب مؤثر را کاهش می‌دهد.", + definition: "فرمی فعال از ویتامین D که جذب کلسیم از روده را افزایش داده و به سلامت استخوان‌ها کمک می‌کند.", wikiId: "vitamins" }, "BARF": { - term: "رژیم BARF (Biologically Appropriate Raw Food)", - definition: "رژیم غذایی خام متناسب با زیست‌شناسی حیوان که شامل گوشت خام، استخوان، اندام‌ها و سبزیجات است. مکمل‌های کانینا مانند Eierschalenpulver، Rinderblut Pulver و Rinderfett Pulver برای تکمیل این رژیم طراحی شده‌اند.", - wikiId: "nutrition" + term: "رژیم غذایی خام (BARF)", + definition: "رژیم غذایی خام طبیعی شامل گوشت و استخوان که مکمل‌های پوسته تخم مرغ و پودر خون برای تکمیل آن طراحی شده‌اند.", + wikiId: "eggshell" }, "آغوز": { term: "آغوز (Colostrum)", - definition: "اولین شیر پستانداران حاوی IgG, IgA, IgM و لاکتوفرین که سد دفاعی روده را تقویت کرده و ایمنی غیرفعال منتقل می‌کند. محصول Immun-Booster کانینا حاوی ۲۰٪ پودر آغوز است.", - wikiId: "immune" + definition: "اولین شیر پستانداران پس از زایمان، غنی از ایمونوگلوبولین‌ها جهت فعال‌سازی فوری سیستم ایمنی نوزادان.", + wikiId: "colostrum" }, "ال-کارنیتین": { - term: "ال-کارنیتین", - definition: "ماده‌ای شبه‌آمینواسید که اسیدهای چرب زنجیره بلند را به داخل میتوکندری کاردیومیوسیت‌ها منتقل کرده و ATP تولید می‌کند. محصول Herz Vital کانینا حاوی ۳۰,۰۰۰mg/kg ال-کارنیتین است.", - wikiId: "heart" + term: "ال-کارنیتین (L-Carnitine)", + definition: "آمینو اسیدی که وظیفه انتقال اسیدهای چرب به درون میتوکندری جهت تولید انرژی و تقویت عملکرد قلب را دارد.", + wikiId: "l-carnitine" }, "زالزالک": { - term: "زالزالک (Crataegus oxyacantha)", - definition: "گیاه دارویی با اثر مهارکننده ACE طبیعی که جریان خون کرونر را بهبود بخشیده و فشار بر قلب را کاهش می‌دهد.", - wikiId: "heart" + term: "زالزالک (Hawthorn)", + definition: "عصاره گیاهی تقویت‌کننده عملکرد انقباضی عضله قلب و بهبودبخش گردش خون عروق کرونر.", + wikiId: "hawthorn" }, "بیوتین": { - term: "بیوتین (ویتامین H / B7)", - definition: "کوآنزیم کربوکسیلازهای مسئول سنتز اسیدهای چرب و گلوکونئوژنز. کمبود آن منجر به ریزش مو و شکنندگی پنجه می‌شود. قرص Biotin-Tabs کانینا با ۱,۰۰۰,۰۰۰ میکروگرم در کیلوگرم قوی‌ترین دوز موجود است.", - wikiId: "skin" + term: "بیوتین (Biotin / Vitamin H)", + definition: "کوآنزیم ضروری برای ساخت کراتین و جلوگیری از ریزش مو و تقویت ناخن‌ها و پنجه‌های حیوان.", + wikiId: "biotin" }, "هیدروکسی آپاتیت": { - term: "هیدروکسی آپاتیت", - definition: "کلسیم فسفات بلوری که ۹۰٪ بافت دندان و بخش عمده استخوان را تشکیل می‌دهد. با جذب مستقیم در توده استخوانی، استحکام اسکلت را تضمین می‌کند.", - wikiId: "joints" + term: "هیدروکسی آپاتیت (Hydroxyapatite)", + definition: "ترکیب کلسیم فسفات طبیعی که بخش عمده استخوان و دندان‌ها را تشکیل داده و موجب افزایش استحکام اسکلت می‌شود.", + wikiId: "green-mussel" }, "EPA/DHA": { term: "EPA و DHA", - definition: "ایکوزاپنتائنوئیک اسید (EPA) پیش‌ماده رزولوین‌های ضدالتهابی و دوکوزاهگزانوئیک اسید (DHA) ۳۰٪ وزن خشک مغز را تشکیل می‌دهد. نسبت ۳:۲ EPA:DHA در روغن سالمون کانینا بهینه‌سازی شده است.", - wikiId: "omega3" + definition: "اسیدهای چرب بلندزنجیره امگا ۳ که اثرات ضدالتهابی عمیق داشته و برای رشد سیستم عصبی و چشم‌ها حیاتی هستند.", + wikiId: "salmon-oil" + }, + "روغن ماهی سالمون": { + term: "روغن ماهی سالمون (Salmon Oil)", + definition: "منبع ۱۰۰٪ خالص و طبیعی اسیدهای چرب امگا ۳ که کیفیت پوست و مو را ارتقا داده و مفاصل را روان می‌کند.", + wikiId: "salmon-oil" + }, + "سیاه دانه": { + term: "سیاه دانه (Black Cumin)", + definition: "دانه گیاه سیاه دانه مصری با خواص قوی ضدآلرژی، تقویت‌کننده سیستم ایمنی و هضم بهتر غذا.", + wikiId: "black-cumin" + }, + "جلبک دریایی": { + term: "جلبک دریایی (Seaweed)", + definition: "جلبک قهوه‌ای غنی از اسیدهای آمینه و مواد معدنی کمیاب (به ویژه ید) جهت تقویت رنگدانه‌های پوشش، بینی و پنجه‌ها.", + wikiId: "seaweed" + }, + "پوسته تخم مرغ": { + term: "پودر پوسته تخم مرغ (Eggshell Powder)", + definition: "منبع کلسیم ارگانیک صد درصد خالص با جذب بسیار بالا و مناسب برای رژیم‌های بدون استخوان یا حساسیت‌های غذایی.", + wikiId: "eggshell" + }, + "پیت": { + term: "عصاره پیت (Peat Extract / Moor)", + definition: "عصاره طبیعی حاصل از تجزیه گیاهان در طول هزاران سال، غنی از اسیدهای هومیک برای تنظیم فلور گوارشی.", + wikiId: "peat-extract" + }, + "اسید دودکانوئیک": { + term: "اسید دودکانوئیک (Dodecanoic Acid)", + definition: "اسید چرب با منشأ گیاهی با اثر دفع‌کننده حشرات موذی نظیر کنه، کک و شپش از محیط زندگی پت.", + wikiId: "dodecanoic" + }, + "هیالورونات سدیم": { + term: "هیالورونات سدیم (Sodium Hyaluronate)", + definition: "روان‌کننده و رطوبت‌رسان طبیعی قرنیه چشم جهت شستشو و تسکین چشم‌های تحریک شده و گریان.", + wikiId: "hyaluronic" + }, + "روغن کالاندولا": { + term: "روغن کالاندولا و سنجد دریایی (Calendula & Sea Buckthorn)", + definition: "روغن‌های تسکین‌دهنده، ضدباکتری و التیام‌بخش پوست جهت بهبود سوزش، زخم‌های سطحی و خشکی پنجه‌ها.", + wikiId: "calendula-sea-buckthorn" + }, + "نقره کلوئیدی": { + term: "نقره کلوئیدی (Colloidal Silver)", + definition: "محلول استریل حاوی ذرات ریز نقره با اثرات قوی آنتی‌باکتریال و ضدالتهاب طبیعی جهت پاکسازی ملتحمه چشم و جلوگیری از عفونت‌های چشمی و پوستی.", + wikiId: "colloidal-silver" + }, + "رتینول پالمیتات": { + term: "رتینول پالمیتات / ویتامین A (Retinol Palmitate)", + definition: "فرمی پایدار از ویتامین A دارویی که باعث ترمیم لایه‌های مخاطی قرنیه چشم، بازسازی اپیتلیوم آسیب‌دیده و محافظت در برابر خشکی قرنیه می‌شود.", + wikiId: "retinol-palmitate" + }, + "روغن خولان دریایی": { + term: "روغن خولان دریایی / سنجد دریایی (Sea Buckthorn Oil)", + definition: "روغن غنی از ویتامین E، امگا ۷ و آنتی‌اکسیدان‌های کمیاب برای ترمیم سریع بافت‌های آسیب‌دیده، التهابات پوستی و ترک‌های پنجه.", + wikiId: "calendula-sea-buckthorn" + }, + "سنجد دریایی": { + term: "روغن سنجد دریایی (Sea Buckthorn Oil)", + definition: "روغن غنی از ویتامین E، امگا ۷ و آنتی‌اکسیدان‌های کمیاب برای ترمیم سریع بافت‌های آسیب‌دیده و ترک‌خورده پوست.", + wikiId: "calendula-sea-buckthorn" + }, + "وازلین": { + term: "وازلین با گرید دارویی (Medical Vaseline)", + definition: "پوشش نرم‌کننده و ایزوله‌کننده پوست که مانع تبخیر رطوبت لایه‌های اپیدرم شده و از پنجه‌ها در برابر نمک، یخ و زبری زمین محافظت می‌کند.", + wikiId: "vaseline" + }, + "پانتنول": { + term: "پانتنول / پیش‌ساز ویتامین B5 (Panthenol)", + definition: "ترکیب نفوذکننده و ترمیم‌کننده عمیق پوست که سرعت بازسازی سلولی را افزایش داده و خارش و التهاب پوست و پنجه را تسکین می‌دهد.", + wikiId: "panthenol" + }, + "عصاره دانه گریپ فروت": { + term: "عصاره دانه گریپ فروت (Grapefruit Seed Extract)", + definition: "منبع غنی بیوفلاونوئیدها و آنتی‌اکسیدان‌های طبیعی که با مهار باکتری‌های مولد پلاک، از تشکیل تارتار و جرم دندان جلوگیری می‌کند.", + wikiId: "grapefruit-seed" + }, + "گریپ فروت": { + term: "عصاره دانه گریپ فروت (Grapefruit Seed Extract)", + definition: "منبع غنی بیوفلاونوئیدها و آنتی‌اکسیدان‌های طبیعی که با مهار باکتری‌های مولد پلاک، از تشکیل تارتار و جرم دندان جلوگیری می‌کند.", + wikiId: "grapefruit-seed" + }, + "عصاره مارگوسا": { + term: "عصاره طبیعی درخت نیم / مارگوسا (Margosa Extract)", + definition: "عصاره گیاهی دورکننده حشرات با خواص ضد انگل، کک و کنه بدون استفاده از سموم شیمیایی آفت‌کش.", + wikiId: "dodecanoic" + }, + "خاک دیاتومه": { + term: "خاک دیاتومه ارگانیک (Diatomaceous Earth)", + definition: "ماده طبیعی حاصل از سنگ‌های رسوبی جلبک‌های تک‌سلولی جهت کنترل ارگانیک انگل‌ها و انباشت مواد سمی در روده.", + wikiId: "diatomaceous" + }, + "کنگر فرنگی": { + term: "عصاره کنگر فرنگی (Artichoke Extract)", + definition: "ترکیب گیاهی محافظ کبد و بهبوددهنده ترشح صفرا جهت ارتقای هضم چربی‌ها و پاکسازی متابولیک.", + wikiId: "artichoke" + }, + "مخمر آبجو": { + term: "مخمر آبجو (Brewer's Yeast)", + definition: "منبع غنی ویتامین‌های گروه B، اسیدهای آمینه و مواد معدنی برای سلامت پوست، درخشندگی موها و تقویت اشتهای حیوان.", + wikiId: "brewers-yeast" + }, + "پوست درخت بید": { + term: "عصاره پوست درخت بید (Willow Bark Extract)", + definition: "منبع طبیعی سالیسین با خواص ضدالتهابی و تسکین‌دهنده دردهای مفصلی و تاندون‌ها در حیوانات مسن.", + wikiId: "willow-bark" + }, + "زنجبیل": { + term: "عصاره زنجبیل (Ginger Extract)", + definition: "آنتی‌اکسیدان گیاهی قوی برای مهار رادیکال‌های آزاد، بهبود گردش خون در مفاصل و تسکین حالت تهوع و مشکلات گوارشی.", + wikiId: "ginger" + }, + "سرم طبیعی گاو": { + term: "سرم طبیعی خون گاو (Bovine Blood Serum)", + definition: "کنسانتره غنی از پروتئین‌ها و بیواکتیواتورهای طبیعی برای تحریک اشتها و تسریع بازگشت انرژی در دوره نقاهت.", + wikiId: "bovine-serum" + }, + "پودر خون گاو": { + term: "پودر خون گاو / هموگلوبین (Bovine Blood Powder)", + definition: "منبع ۱۰۰٪ طبیعی آهن آلی (هموگلوبین) و اسیدهای آمینه ضروری جهت اکسیژن‌رسانی به خون و عضله‌سازی در رژیم خام BARF.", + wikiId: "bovine-blood" + }, + "چربی گوشت گاو": { + term: "چربی تصفیه شده گاو (Beef Fat Powder)", + definition: "سوخت انرژی‌بخش حیوانی زودجذب جهت وزن‌گیری سریع، تامین کالری سگ‌های کاری و افزایش اشتهای سگ‌های بدخوراک.", + wikiId: "beef-fat" + }, + "روغن آرگان": { + term: "روغن آرگان خالص (Pure Argan Oil)", + definition: "روغن نایاب سرشار از فیتواسترول‌ها، اسکوالن و ویتامین E طبیعی برای درخشندگی موها، تغذیه پوست و تقویت غشاهای سلولی.", + wikiId: "argan-oil" + }, + "روغن دانه گل رز": { + term: "روغن دانه گل رز (Rosehip Seed Oil)", + definition: "منبع طبیعی اسیدهای چرب امگا ۳ و پروویتامین A برای ترمیم مخاط‌ها و لایه‌های محافظ پوست.", + wikiId: "rosehip-oil" + }, + "روغن گردو": { + term: "روغن گردو (Walnut Oil)", + definition: "روغن گیاهی غنی از اسیدهای چرب غیراشباع و آنتی‌اکسیدان‌ها برای سلامت سیستم عصبی و پوست.", + wikiId: "walnut-oil" + }, + "تورین": { + term: "تورین (Taurine)", + definition: "آمینو اسید بسیار ضروری برای گربه‌ها جهت جلوگیری از دژنراسیون شبکیه (نابینایی) و نارسایی قلبی.", + wikiId: "taurin" + }, + "گرید دارویی": { + term: "گرید دارویی (Pharmaceutical Grade)", + definition: "بالاترین سطح خلوص مواد اولیه؛ تهیه شده با استانداردهای دارویی انسان و کاملاً بی‌خطر در صورت لیسیده شدن توسط حیوان." + }, + "پلی گلیسریل-4-کاپرات": { + term: "پلی گلیسریل-4-کاپرات (Polyglyceryl-4 Caprate)", + definition: "امولسیفایر گیاهی ملایم و زیست‌تخریب‌پذیر حاصل از گلیسرول و اسیدهای چرب نارگیل جهت انحلال یکنواخت ترکیبات فعال بدون ایجاد سوزش پوست.", + wikiId: "polyglyceryl-caprate" + }, + "کربومر": { + term: "کربومر دارویی (Carbomer / Polyacrylic Acid)", + definition: "پلیمر قوام‌دهنده و ویسکوزکننده فرمولاسیون‌های ژل که پایداری مواد فعال بر سطح دندان و پوست را افزایش می‌دهد.", + wikiId: "carbomer" + }, + "پلی گلیسریل": { + term: "پلی گلیسریل-4-کاپرات (Polyglyceryl-4 Caprate)", + definition: "امولسیفایر گیاهی ملایم حاصل از گلیسرول و اسیدهای چرب نارگیل جهت انحلال یکنواخت ترکیبات فعال بدون ایجاد سوزش.", + wikiId: "polyglyceryl-caprate" + }, + "اتانول": { + term: "اتانول دارویی (Pharmaceutical Ethanol)", + definition: "حلال استریل و عصاره‌گیر پایه حیوانی و گیاهی با غلظت استاندارد و ایمن برای نگهداری محلول‌های بیواکتیو.", + wikiId: "ethanol" + }, + "دکستروز": { + term: "دکستروز / گلوکز مونوهیدرات (Dextrose)", + definition: "قند ساده زودجذب و منبع فوری انرژی سلولی برای حیوانات ضعیف، بیمار یا دچار افت قند خون.", + wikiId: "dextrose" + }, + "آب پنیر": { + term: "پودر آب پنیر (Whey Powder)", + definition: "منبع غنی پروتئین‌های زودهضم، کلسیم ارگانیک و اسیدهای آمینه شاخه‌دار جهت رشد و بالانس غذایی.", + wikiId: "whey-powder" + }, + "نشاسته سیب زمینی": { + term: "نشاسته سیب زمینی (Potato Starch)", + definition: "کربوهیدرات پیچیده و عامل اتصال‌دهنده طبیعی در قرص‌ها و ژل‌ها بدون ایجاد حساسیت گوارشی.", + wikiId: "potato-starch" + }, + "لاکتوز": { + term: "لاکتوز دارویی (Lactose)", + definition: "قند طبیعی شیر به عنوان پایه انرژی‌بخش و طعم‌دهنده طبیعی در مکمل‌های جایگزین شیر مادر.", + wikiId: "lactose" + }, + "چربی نارگیل": { + term: "چربی و روغن نارگیل (Coconut Fat)", + definition: "منبع اسیدهای چرب زنجیره متوسط (MCT) جهت تامین انرژی سریع مغز و دستگاه گوارش.", + wikiId: "coconut-fat" + }, + "پروتئین ایزوله سویا": { + term: "پروتئین ایزوله سویا (Soy Protein Isolate)", + definition: "پروتئین خالص گیاهی با قابلیت هضم فوق‌العاده بالا برای ساخت بافت‌های عضلانی.", + wikiId: "soy-protein" + }, + "پودر تخم مرغ کامل": { + term: "پودر تخم مرغ کامل (Whole Egg Powder)", + definition: "غذای کامل سرشار از پروتئین‌های با ارزش بیولوژیک بالا، لسیپین و اسیدهای چرب ضروری.", + wikiId: "egg-powder" + }, + "کربنات کلسیم": { + term: "کربنات کلسیم (Calcium Carbonate)", + definition: "منبع آلی معدنی کلسیم برای استحکام استخوان‌ها، دندان‌ها و عملکرد صحیح انقباض عضلانی.", + wikiId: "calcium-carbonate" + }, + "تری کلسیم فسفات": { + term: "تری کلسیم فسفات (Tricalcium Phosphate)", + definition: "ترکیب معدنی فوق‌العاده شکل‌دهنده ساختار استخوان‌ها (بیش از ۵۰٪) و مینای دندان‌ها (بیش از ۹۰٪).", + wikiId: "tricalcium-phosphate" + }, + "ویتامین A": { + term: "ویتامین A (Retinol)", + definition: "ویتامین محلول در چربی ضروری برای سلامت بینایی، بازسازی مخاط و سیستم ایمنی.", + wikiId: "vitamins" + }, + "ویتامین E": { + term: "ویتامین E (Tocopherol)", + definition: "آنتی‌اکسیدان قوی محافظ غشای سلولی در برابر استرس اکسیداتیو و رادیکال‌های آزاد.", + wikiId: "vitamins" + }, + "ویتامین C": { + term: "ویتامین C (L-Ascorbic Acid)", + definition: "ویتامین تقویت‌کننده ساخت کلاژن، لثه‌ها و سیستم ایمنی بدن.", + wikiId: "vitamins" + }, + "ویتامین B1": { + term: "ویتامین B1 (Thiamine)", + definition: "ویتامین حیاتی برای سیستم عصبی و متابولیسم کربوهیدرات‌ها.", + wikiId: "vitamins" + }, + "ویتامین B2": { + term: "ویتامین B2 (Riboflavin)", + definition: "کوآنزیم فاکتورهای رشد، درخشندگی پوشش مویی و سلامت پوست.", + wikiId: "vitamins" + }, + "ویتامین B6": { + term: "ویتامین B6 (Pyridoxine)", + definition: "ویتامین ضروری برای سنتز اسیدهای آمینه و هموگلوبین خون.", + wikiId: "vitamins" + }, + "ویتامین B12": { + term: "ویتامین B12 (Cobalamin)", + definition: "فاکتور کلیدی خون‌سازی و حفظ سلامت رشته‌های عصبی.", + wikiId: "vitamins" + }, + "اسید نیکوتینیک": { + term: "اسید نیکوتینیک / نیاسین (Niacin / Vitamin B3)", + definition: "ویتامین تنظیم‌کننده پیگمنت‌های پوست و متابولیسم چربی‌ها.", + wikiId: "vitamins" + }, + "سلنیوم": { + term: "سلنیوم (Selenium)", + definition: "عنصر کمیاب آنتی‌اکسیدانی که همراه با ویتامین E از تخریب سلولی جلوگیری می‌کند.", + wikiId: "selenium" + }, + "روی": { + term: "روی (Zinc)", + definition: "عنصر ضروری برای ترمیم زخم‌ها، رشد موها و تقویت سیستم ایمنی.", + wikiId: "zinc" + }, + "مس": { + term: "مس (Copper)", + definition: "عنصر کاتالیزور در سنتز هموگلوبین و پیگمنت‌های رنگی مو.", + wikiId: "copper" + }, + "آهن": { + term: "آهن (Iron)", + definition: "ماده معدنی اصلی ساخت هموگلوبین و اکسیژن‌رسانی به بافت‌ها.", + wikiId: "iron" + }, + "متیونین": { + term: "متیونین (DL-Methionine)", + definition: "اسید آمینه گوگرددار ضروری برای سلامت مفاصل، کبد و رشد مو.", + wikiId: "methionine" + }, + "کیک بذر کتان": { + term: "کیک بذر کتان (Flaxseed Cake)", + definition: "منبع غنی فیبرهای حل‌شونده و اسید چرب آلفا لینولنیک برای دستگاه گوارش.", + wikiId: "flaxseed" + }, + "تفاله سیب": { + term: "تفاله سیب ارگانیک (Apple Pomace)", + definition: "منبع پری‌بیوتیک طبیعی پکتین جهت تنظیم فلور و حرکات روده.", + wikiId: "apple-pomace" + }, + "پودر یونجه سبز": { + term: "پودر یونجه سبز (Alfalfa Powder)", + definition: "گیاه غنی از کلروفیل، ویتامین K و مواد معدنی قلیایی‌کننده.", + wikiId: "alfalfa" + }, + "فیبر نخود فرنگی": { + term: "فیبر نخود فرنگی (Pea Fiber)", + definition: "فیبر غیرقابل هضم پاک‌کننده مکانیکی جدار روده و حجم‌دهنده مدفوع.", + wikiId: "pea-fiber" + }, + "تفاله هویج": { + term: "تفاله هویج (Carrot Pomace)", + definition: "سرشار از بتاکاروتن و فیبر برای سلامت بینایی و گوارش.", + wikiId: "carrot-pomace" + }, + "روغن کلزا": { + term: "روغن کلزا (Rapeseed Oil)", + definition: "روغن گیاهی غنی از امگا ۳ و ۶ برای لطافت و نرمی پوست.", + wikiId: "rapeseed-oil" + }, + "روغن ماهی": { + term: "روغن خالص ماهی (Fish Oil)", + definition: "منبع اسیدهای چرب امگا ۳ برای سلامت قلب، مفاصل و پوست.", + wikiId: "salmon-oil" + }, + "IFS": { + term: "استاندارد بین‌المللی IFS", + definition: "سخت‌گیرانه‌ترین استاندارد ایمنی و کیفیت محصولات دارویی و غذایی در اروپا." + }, + "HACCP": { + term: "سیستم کنترل بحران HACCP", + definition: "تضمین‌کننده عدم وجود هرگونه آلودگی فیزیکی، شیمیایی یا میکروبی در زنجیره تولید محصولات." } }; diff --git a/docker-compose.yml b/docker-compose.yml index bd6419d..d9d5acd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,75 +1,69 @@ -version: '3.8' - services: - db: - image: docker.arvancloud.ir/library/postgres:16-alpine - container_name: canino_db + db_stage: + image: postgres:16-alpine + container_name: canino_db_stage restart: always environment: - POSTGRES_USER: canino - POSTGRES_PASSWORD: caninopassword - POSTGRES_DB: caninodb - ports: - - "5433:5432" + POSTGRES_USER: canino_stage + POSTGRES_PASSWORD: caninopassword_stage + POSTGRES_DB: caninodb_stage volumes: - - postgres_data:/var/lib/postgresql/data + - canina_stage_db:/var/lib/postgresql/data + networks: + - traefik_public - redis: - image: docker.arvancloud.ir/library/redis:7-alpine - container_name: canino_redis + redis_stage: + image: redis:7-alpine + container_name: canino_redis_stage restart: always - ports: - - "16379:6379" + networks: + - traefik_public - backend: - build: + backend_stage: + build: context: ./backend dockerfile: Dockerfile - container_name: canino_backend + container_name: canino_backend_stage restart: always environment: - - DATABASE_URL=postgresql://canino:caninopassword@db:5432/caninodb?schema=public - - REDIS_HOST=redis + - DATABASE_URL=postgresql://canino_stage:caninopassword_stage@db_stage:5432/caninodb_stage?schema=public + - REDIS_HOST=redis_stage - REDIS_PORT=6379 - PORT=3000 - ports: - - "3000:3000" + labels: + - "traefik.enable=true" + - "traefik.http.routers.canina-api-stage.rule=Host(`stageapi.canina.ir`) || Host(`api.caninastage.parsaaghayi.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 - - redis + - db_stage + - redis_stage + networks: + - traefik_public - frontend: + frontend_stage: build: context: . dockerfile: Dockerfile - container_name: canino_frontend + container_name: canino_frontend_stage restart: always - ports: - - "3001:8080" - depends_on: - - backend - - prometheus: - image: docker.arvancloud.ir/prom/prometheus:v2.51.0 - container_name: canino_prometheus - restart: always - volumes: - - ./prometheus.yml:/etc/prometheus/prometheus.yml - ports: - - "9090:9090" - depends_on: - - backend - - grafana: - image: docker.arvancloud.ir/grafana/grafana:10.4.1 - container_name: canino_grafana - restart: always - ports: - - "3002:3000" environment: - - GF_SECURITY_ADMIN_PASSWORD=admin + - NEXT_PUBLIC_API_URL=http://backend_stage:3000 + labels: + - "traefik.enable=true" + - "traefik.http.routers.canina-stage.rule=Host(`stage.canina.ir`) || Host(`stageadmin.canina.ir`) || Host(`caninastage.parsaaghayi.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: - - prometheus + - backend_stage + networks: + - traefik_public + +networks: + traefik_public: + external: true volumes: - postgres_data: + canina_stage_db: diff --git a/nginx.conf b/nginx.conf index 2304014..323e007 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,6 +1,6 @@ server { - listen 8080; - server_name canina.parsaaghayi.ir; + listen 8080 default_server; + server_name canina.ir stage.canina.ir canina.parsaaghayi.ir caninastage.parsaaghayi.ir; # Security Headers server_tokens off; @@ -21,7 +21,7 @@ server { # Backend API location /api { - proxy_pass http://backend_prod:3000; + proxy_pass http://localhost:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; @@ -32,7 +32,7 @@ server { server { listen 8080; - server_name admincanina.parsaaghayi.ir; + server_name admin.canina.ir stageadmin.canina.ir admincanina.parsaaghayi.ir; root /app/admin_static; index index.html; @@ -51,7 +51,7 @@ server { # Backend API for Admin location /api { - proxy_pass http://backend_prod:3000; + proxy_pass http://localhost:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; From e7df5b3964fd192ce6fea9e3131906bf113b7539 Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Mon, 27 Jul 2026 20:00:08 +0330 Subject: [PATCH 02/19] fix(prisma): export main in seed modules and execute directly in seed.ts --- backend/prisma/seed-home.ts | 2 +- backend/prisma/seed-products.ts | 2 +- backend/prisma/seed.ts | 12 ++++++++---- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/backend/prisma/seed-home.ts b/backend/prisma/seed-home.ts index b1157d1..c89639b 100644 --- a/backend/prisma/seed-home.ts +++ b/backend/prisma/seed-home.ts @@ -2,7 +2,7 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); -async function main() { +export async function main() { console.log('Seeding Home Page Components...'); // 1. Seed Hero Banners diff --git a/backend/prisma/seed-products.ts b/backend/prisma/seed-products.ts index de0ee1a..57d64bf 100644 --- a/backend/prisma/seed-products.ts +++ b/backend/prisma/seed-products.ts @@ -151,7 +151,7 @@ const PRODUCT_TAGLINES_MAP: Record = { 'canina-novagard-green-pfotenpflege': "مومیایی محافظ پنجه‌ها برای التیام خشکی، قرمزی و ترک خوردگی پنجه‌ها در برف و سرما" }; -async function main() { +export async function main() { console.log('Seeding products from seed-products-data.json...'); // 2. Read JSON data diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 13a2fde..889620c 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -296,8 +296,10 @@ async function main() { // 3. Seed Products & Categories console.log('Seeding Products & Categories...'); try { - const { execSync } = require('child_process'); - execSync('npx ts-node prisma/seed-products.ts', { stdio: 'inherit' }); + const seedProducts = require('./seed-products'); + if (seedProducts && typeof seedProducts.main === 'function') { + await seedProducts.main(); + } } catch (err) { console.error('Failed to seed products:', err); } @@ -305,8 +307,10 @@ async function main() { // 4. Seed Home Components & Testimonials console.log('Seeding Home Components...'); try { - const { execSync } = require('child_process'); - execSync('npx ts-node prisma/seed-home.ts', { stdio: 'inherit' }); + const seedHome = require('./seed-home'); + if (seedHome && typeof seedHome.main === 'function') { + await seedHome.main(); + } } catch (err) { console.error('Failed to seed home components:', err); } From 75eaa13617e2a4bef5f403346553a5034cb8bf14 Mon Sep 17 00:00:00 2001 From: devops Date: Tue, 28 Jul 2026 06:37:16 +0000 Subject: [PATCH 03/19] chore: update to canina.ir domains, remove parsaaghayi.ir --- nginx.conf | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/nginx.conf b/nginx.conf index 323e007..31f3410 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,6 +1,6 @@ server { - listen 8080 default_server; - server_name canina.ir stage.canina.ir canina.parsaaghayi.ir caninastage.parsaaghayi.ir; + listen 8080; + server_name canina.ir stage.canina.ir; # Security Headers server_tokens off; @@ -21,7 +21,7 @@ server { # Backend API location /api { - proxy_pass http://localhost:3000; + proxy_pass http://backend:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; @@ -32,7 +32,7 @@ server { server { listen 8080; - server_name admin.canina.ir stageadmin.canina.ir admincanina.parsaaghayi.ir; + server_name admin.canina.ir stageadmin.canina.ir; root /app/admin_static; index index.html; @@ -51,7 +51,7 @@ server { # Backend API for Admin location /api { - proxy_pass http://localhost:3000; + proxy_pass http://backend:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; From cb088439568a652e2b7831e41a2a6b76afd583e2 Mon Sep 17 00:00:00 2001 From: devops Date: Tue, 28 Jul 2026 06:41:45 +0000 Subject: [PATCH 04/19] test: trigger CI/CD stage deployment --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 85bdf6d..a487b3a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ coverage/ node_modules .next *.tsbuildinfo +test ci trigger From ee65d878996be74436c44e1371ef55bfd661cf7b Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Mon, 27 Jul 2026 20:00:08 +0330 Subject: [PATCH 05/19] fix(prisma): export main in seed modules and execute directly in seed.ts --- backend/prisma/seed-home.ts | 2 +- backend/prisma/seed-products.ts | 2 +- backend/prisma/seed.ts | 12 ++++++++---- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/backend/prisma/seed-home.ts b/backend/prisma/seed-home.ts index b1157d1..c89639b 100644 --- a/backend/prisma/seed-home.ts +++ b/backend/prisma/seed-home.ts @@ -2,7 +2,7 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); -async function main() { +export async function main() { console.log('Seeding Home Page Components...'); // 1. Seed Hero Banners diff --git a/backend/prisma/seed-products.ts b/backend/prisma/seed-products.ts index de0ee1a..57d64bf 100644 --- a/backend/prisma/seed-products.ts +++ b/backend/prisma/seed-products.ts @@ -151,7 +151,7 @@ const PRODUCT_TAGLINES_MAP: Record = { 'canina-novagard-green-pfotenpflege': "مومیایی محافظ پنجه‌ها برای التیام خشکی، قرمزی و ترک خوردگی پنجه‌ها در برف و سرما" }; -async function main() { +export async function main() { console.log('Seeding products from seed-products-data.json...'); // 2. Read JSON data diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 13a2fde..889620c 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -296,8 +296,10 @@ async function main() { // 3. Seed Products & Categories console.log('Seeding Products & Categories...'); try { - const { execSync } = require('child_process'); - execSync('npx ts-node prisma/seed-products.ts', { stdio: 'inherit' }); + const seedProducts = require('./seed-products'); + if (seedProducts && typeof seedProducts.main === 'function') { + await seedProducts.main(); + } } catch (err) { console.error('Failed to seed products:', err); } @@ -305,8 +307,10 @@ async function main() { // 4. Seed Home Components & Testimonials console.log('Seeding Home Components...'); try { - const { execSync } = require('child_process'); - execSync('npx ts-node prisma/seed-home.ts', { stdio: 'inherit' }); + const seedHome = require('./seed-home'); + if (seedHome && typeof seedHome.main === 'function') { + await seedHome.main(); + } } catch (err) { console.error('Failed to seed home components:', err); } From 501addc9097e8610c1a89866cb70804492e55813 Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Tue, 28 Jul 2026 16:35:07 +0330 Subject: [PATCH 06/19] fix: CI/CD pipeline, dynamic API URLs, Dockerfile fixes - Backend Dockerfile: add chown for node_modules (Prisma permissions fix) - Frontend Dockerfile: add ARG/ENV for NEXT_PUBLIC_API_URL, VITE_API_URL - nginx.conf: template with __PLACEHOLDERS__ for per-env substitution - deploy.yml: SSH keepalive, sudo for VPN - api.ts: dynamic API URL via env vars instead of hardcoded - next.config.ts: rewrite only in development mode - scripts: deploy.sh, compose.stage.yml, compose.prod.yml --- .gitea/workflows/deploy.yml | 8 ++- Dockerfile | 4 ++ backend/Dockerfile | 1 + frontend/admin-panel/src/services/api.ts | 2 +- frontend/application/lib/services/api.ts | 2 +- frontend/application/next.config.ts | 17 ++++-- nginx.conf | 10 +-- scripts/compose.prod.yml | 72 ++++++++++++++++++++++ scripts/compose.stage.yml | 72 ++++++++++++++++++++++ scripts/deploy.sh | 77 ++++++++++++++++++++++++ 10 files changed, 251 insertions(+), 14 deletions(-) create mode 100644 scripts/compose.prod.yml create mode 100644 scripts/compose.stage.yml create mode 100644 scripts/deploy.sh diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index fa4357d..d177045 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -23,8 +23,12 @@ jobs: - name: Deploy shell: sh - timeout-minutes: 15 + timeout-minutes: 12 run: | BRANCH="${GITHUB_REF_NAME}" echo "Deploying branch: ${BRANCH}" - ssh -o StrictHostKeyChecking=no -p 2234 devops@172.17.0.1 "bash /opt/services/scripts/depcanina.sh ${BRANCH}" + ssh -o StrictHostKeyChecking=no \ + -o ServerAliveInterval=30 \ + -o ServerAliveCountMax=30 \ + -p 2234 devops@172.17.0.1 \ + "sudo bash /opt/services/scripts/depcanina.sh ${BRANCH}" diff --git a/Dockerfile b/Dockerfile index 62ffdb0..45af414 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,8 @@ ENV NEXT_PUBLIC_API_URL=https://stageapi.canina.ir/api COPY frontend/application/package*.json ./ RUN npm ci --prefer-offline --no-audit COPY frontend/application ./ +ARG NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL RUN npm run build FROM node:20-alpine AS admin-builder @@ -13,6 +15,8 @@ ENV VITE_API_URL=https://stageapi.canina.ir/api COPY frontend/admin-panel/package*.json ./ RUN npm ci --prefer-offline --no-audit COPY frontend/admin-panel ./ +ARG VITE_API_URL +ENV VITE_API_URL=$VITE_API_URL RUN npm run build FROM node:20-alpine diff --git a/backend/Dockerfile b/backend/Dockerfile index 85217d1..751b21d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -15,6 +15,7 @@ COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/dist ./dist COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma/scientificTerms.ts ./prisma/scientificTerms.ts +RUN chown -R node:node /app EXPOSE 3000 USER node CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main"] diff --git a/frontend/admin-panel/src/services/api.ts b/frontend/admin-panel/src/services/api.ts index e1a904b..3166ddc 100644 --- a/frontend/admin-panel/src/services/api.ts +++ b/frontend/admin-panel/src/services/api.ts @@ -2,7 +2,7 @@ import axios from 'axios'; export const BASE_DOMAIN = import.meta.env.VITE_API_URL ? 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`; diff --git a/frontend/application/lib/services/api.ts b/frontend/application/lib/services/api.ts index 0e84c23..c1ff4ef 100644 --- a/frontend/application/lib/services/api.ts +++ b/frontend/application/lib/services/api.ts @@ -1,6 +1,6 @@ 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({ baseURL, diff --git a/frontend/application/next.config.ts b/frontend/application/next.config.ts index 9a48391..c6e99e5 100644 --- a/frontend/application/next.config.ts +++ b/frontend/application/next.config.ts @@ -15,13 +15,18 @@ const nextConfig: NextConfig = { ], }, async rewrites() { - return [ - { - source: '/api/:path*', - destination: 'http://localhost:4001/api/:path*', - }, - ]; + // Only proxy /api in development; in production nginx handles it + if (process.env.NODE_ENV === 'development') { + return [ + { + source: '/api/:path*', + destination: 'http://localhost:4001/api/:path*', + }, + ]; + } + return []; }, }; export default nextConfig; + diff --git a/nginx.conf b/nginx.conf index 31f3410..85d5de0 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,6 +1,6 @@ server { listen 8080; - server_name canina.ir stage.canina.ir; + server_name __APP_HOST__; # Security Headers server_tokens off; @@ -21,18 +21,19 @@ server { # Backend API location /api { - proxy_pass http://backend:3000; + proxy_pass http://__BACKEND_HOST__:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; + client_max_body_size 50M; } } server { listen 8080; - server_name admin.canina.ir stageadmin.canina.ir; + server_name __ADMIN_HOST__; root /app/admin_static; index index.html; @@ -51,11 +52,12 @@ server { # Backend API for Admin location /api { - proxy_pass http://backend:3000; + proxy_pass http://__BACKEND_HOST__:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; + client_max_body_size 50M; } } diff --git a/scripts/compose.prod.yml b/scripts/compose.prod.yml new file mode 100644 index 0000000..711d458 --- /dev/null +++ b/scripts/compose.prod.yml @@ -0,0 +1,72 @@ +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 + 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: diff --git a/scripts/compose.stage.yml b/scripts/compose.stage.yml new file mode 100644 index 0000000..c1b4f0d --- /dev/null +++ b/scripts/compose.stage.yml @@ -0,0 +1,72 @@ +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 + 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: diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..f907ed1 --- /dev/null +++ b/scripts/deploy.sh @@ -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!" From 431e2b547dc7a51ec18045fb6798286afedd4bae Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Wed, 29 Jul 2026 09:21:39 +0330 Subject: [PATCH 07/19] fix: Prisma OpenSSL engine binary detection on Alpine - Set PRISMA_SCHEMA_ENGINE_BINARY to 3.0.x binary path - Set PRISMA_QUERY_ENGINE_LIBRARY to 3.0.x library path - node:20-alpine has libssl.so.3 but no openssl CLI binary - Prisma detection fails silently, defaults to 1.1.x engines - Explicit paths bypass detection and use correct binaries - Remove apk add openssl (not needed, causes build failure without VPN) - backend Dockerfile: use PRISMA_CLI_BINARY_TARGETS to generate correct client --- backend/Dockerfile | 6 +++--- scripts/compose.prod.yml | 2 ++ scripts/compose.stage.yml | 2 ++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 751b21d..8caca06 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,13 +1,12 @@ FROM node:20-alpine AS builder -RUN apk add --no-cache openssl 2>/dev/null || true WORKDIR /app COPY package*.json ./ -RUN npm ci --prefer-offline --no-audit +RUN npm config set registry http://registry.parsaaghayi.ir/repository/npm-proxy/ && npm ci COPY . . +ENV PRISMA_CLI_BINARY_TARGETS=linux-musl-openssl-3.0.x RUN npx prisma generate && npm run build FROM node:20-alpine -RUN apk add --no-cache openssl 2>/dev/null || true RUN mkdir -p /app/uploads && chown node:node /app/uploads WORKDIR /app COPY --from=builder /app/package*.json ./ @@ -16,6 +15,7 @@ COPY --from=builder /app/dist ./dist COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma/scientificTerms.ts ./prisma/scientificTerms.ts RUN chown -R node:node /app +ENV PRISMA_CLI_BINARY_TARGETS=linux-musl-openssl-3.0.x EXPOSE 3000 USER node CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main"] diff --git a/scripts/compose.prod.yml b/scripts/compose.prod.yml index 711d458..05ac34f 100644 --- a/scripts/compose.prod.yml +++ b/scripts/compose.prod.yml @@ -32,6 +32,8 @@ services: - 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`)" diff --git a/scripts/compose.stage.yml b/scripts/compose.stage.yml index c1b4f0d..287168a 100644 --- a/scripts/compose.stage.yml +++ b/scripts/compose.stage.yml @@ -32,6 +32,8 @@ services: - 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`)" From 776829e099b11911acf1431622033c21a8e5d693 Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Wed, 29 Jul 2026 14:00:00 +0330 Subject: [PATCH 08/19] fix: seed command ESM compatibility and CI/CD timeout increase - Fix prisma seed command: use node --loader ts-node/esm (nodenext module needs ESM loader) - Increase CI/CD timeout from 15m to 40m to accommodate Docker build + VPN time - Prisma env vars already in compose files for OpenSSL 3.0.x binary selection --- .gitea/workflows/deploy.yml | 4 ++-- backend/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index d177045..a9d2bb8 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -9,7 +9,7 @@ on: jobs: deploy: runs-on: canina - timeout-minutes: 15 + timeout-minutes: 40 steps: - name: Setup SSH key shell: sh @@ -23,7 +23,7 @@ jobs: - name: Deploy shell: sh - timeout-minutes: 12 + timeout-minutes: 38 run: | BRANCH="${GITHUB_REF_NAME}" echo "Deploying branch: ${BRANCH}" diff --git a/backend/package.json b/backend/package.json index 97164d6..e4fb02a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -88,6 +88,6 @@ "testEnvironment": "node" }, "prisma": { - "seed": "ts-node prisma/seed.ts" + "seed": "node --loader ts-node/esm --no-warnings prisma/seed.ts" } } From 813f5d7bc6e946c6a2eaf209e2b9ee33b290063a Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Wed, 29 Jul 2026 14:14:20 +0330 Subject: [PATCH 09/19] fix: use tsx for prisma seed to resolve ESM/CJS cycle in Node 20 - Replace ts-node/esm loader with tsx which handles nodenext module natively - tsx avoids ERR_REQUIRE_CYCLE_MODULE with Prisma client ESM/CJS interop - Add tsx ^4.19.0 as devDependency --- backend/package-lock.json | 504 ++++++++++++++++++++++++++++++++++++++ backend/package.json | 3 +- 2 files changed, 506 insertions(+), 1 deletion(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 18562ca..9998f45 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -56,6 +56,7 @@ "ts-loader": "^9.5.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", + "tsx": "^4.19.0", "typescript": "^5.7.3", "typescript-eslint": "^8.20.0" } @@ -799,6 +800,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -5323,6 +5766,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -9973,6 +10458,25 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/backend/package.json b/backend/package.json index e4fb02a..bb47b13 100644 --- a/backend/package.json +++ b/backend/package.json @@ -67,6 +67,7 @@ "ts-loader": "^9.5.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", + "tsx": "^4.19.0", "typescript": "^5.7.3", "typescript-eslint": "^8.20.0" }, @@ -88,6 +89,6 @@ "testEnvironment": "node" }, "prisma": { - "seed": "node --loader ts-node/esm --no-warnings prisma/seed.ts" + "seed": "tsx prisma/seed.ts" } } From 782720d86dddad81efa3f276424e4d943dcbb687 Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Wed, 29 Jul 2026 14:21:58 +0330 Subject: [PATCH 10/19] fix: use ts-node transpile-only with CJS override for seed (no new packages) - Remove tsx (Nexus doesn't have it cached - causes E404) - Use ts-node --transpile-only --compiler-options with module:CommonJS to bypass nodenext ESM cycle issue without any new dependencies - Copy tsconfig.json to runtime container for ts-node compatibility --- backend/Dockerfile | 1 + backend/package-lock.json | 504 -------------------------------------- backend/package.json | 3 +- 3 files changed, 2 insertions(+), 506 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 8caca06..16eb24b 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -10,6 +10,7 @@ FROM node:20-alpine RUN mkdir -p /app/uploads && chown node:node /app/uploads WORKDIR /app COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/tsconfig.json ./tsconfig.json COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/dist ./dist COPY --from=builder /app/prisma ./prisma diff --git a/backend/package-lock.json b/backend/package-lock.json index 9998f45..18562ca 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -56,7 +56,6 @@ "ts-loader": "^9.5.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "tsx": "^4.19.0", "typescript": "^5.7.3", "typescript-eslint": "^8.20.0" } @@ -800,448 +799,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -5766,48 +5323,6 @@ "node": ">= 0.4" } }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -10458,25 +9973,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/tsx": { - "version": "4.23.1", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", - "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/backend/package.json b/backend/package.json index bb47b13..9b16d37 100644 --- a/backend/package.json +++ b/backend/package.json @@ -67,7 +67,6 @@ "ts-loader": "^9.5.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "tsx": "^4.19.0", "typescript": "^5.7.3", "typescript-eslint": "^8.20.0" }, @@ -89,6 +88,6 @@ "testEnvironment": "node" }, "prisma": { - "seed": "tsx prisma/seed.ts" + "seed": "ts-node --transpile-only --compiler-options '{\"module\":\"CommonJS\",\"moduleResolution\":\"node\",\"esModuleInterop\":true}' prisma/seed.ts" } } From 485c6bfec659f7ad184385fcb0e8b55fd4167798 Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Wed, 29 Jul 2026 14:36:20 +0330 Subject: [PATCH 11/19] fix(prisma): add dedicated tsconfig.seed.json for clean cross-platform seeding --- backend/Dockerfile | 1 + backend/package.json | 2 +- backend/prisma/tsconfig.seed.json | 8 ++++++++ 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 backend/prisma/tsconfig.seed.json diff --git a/backend/Dockerfile b/backend/Dockerfile index 16eb24b..7534bdc 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -14,6 +14,7 @@ COPY --from=builder /app/tsconfig.json ./tsconfig.json COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/dist ./dist COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/prisma/tsconfig.seed.json ./prisma/tsconfig.seed.json COPY --from=builder /app/prisma/scientificTerms.ts ./prisma/scientificTerms.ts RUN chown -R node:node /app ENV PRISMA_CLI_BINARY_TARGETS=linux-musl-openssl-3.0.x diff --git a/backend/package.json b/backend/package.json index 9b16d37..d3a9e45 100644 --- a/backend/package.json +++ b/backend/package.json @@ -88,6 +88,6 @@ "testEnvironment": "node" }, "prisma": { - "seed": "ts-node --transpile-only --compiler-options '{\"module\":\"CommonJS\",\"moduleResolution\":\"node\",\"esModuleInterop\":true}' prisma/seed.ts" + "seed": "ts-node --project prisma/tsconfig.seed.json prisma/seed.ts" } } diff --git a/backend/prisma/tsconfig.seed.json b/backend/prisma/tsconfig.seed.json new file mode 100644 index 0000000..41b6a32 --- /dev/null +++ b/backend/prisma/tsconfig.seed.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true + } +} From e1fb300b8714e3823384f55fbe5f025ef25e43cc Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Wed, 29 Jul 2026 15:21:41 +0330 Subject: [PATCH 12/19] fix(seed): handle duplicate product barcodes safely with try-catch in seed-products.ts --- backend/prisma/seed-products.ts | 126 +++++++++++++++++++------------- 1 file changed, 77 insertions(+), 49 deletions(-) diff --git a/backend/prisma/seed-products.ts b/backend/prisma/seed-products.ts index 57d64bf..3755a66 100644 --- a/backend/prisma/seed-products.ts +++ b/backend/prisma/seed-products.ts @@ -204,56 +204,84 @@ export async function main() { 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 product = await prisma.product.upsert({ - where: { artNo }, - update: { - barcode, - slug, - productGroup: baseSlug, - nameFa, - nameEn, - scientificTagline: PRODUCT_TAGLINES_MAP[baseSlug] || null, - description: cleanDesc, - shortDescription: cleanShortDesc, - ingredients: cleanIngredients, - categoryId: category.id, - categorySlug: category.slug, - priceValue: sellPrice, - wholesalePrice: Math.round(sellPrice * 0.7), - requiresRx, - buyPrice, - priceDisplay, - unit, - packageSize, - dosageLogic: cleanDosage, - suitableFor, - imageUrl: getProductImageUrl(baseSlug), - }, - create: { - artNo, - barcode, - slug, - productGroup: baseSlug, - nameFa, - nameEn, - scientificTagline: PRODUCT_TAGLINES_MAP[baseSlug] || null, - description: cleanDesc, - shortDescription: cleanShortDesc, - ingredients: cleanIngredients, - categoryId: category.id, - categorySlug: category.slug, - priceValue: sellPrice, - wholesalePrice: Math.round(sellPrice * 0.7), - requiresRx, - buyPrice, - priceDisplay, - unit, - packageSize, - dosageLogic: cleanDosage, - suitableFor, - imageUrl: getProductImageUrl(baseSlug), + try { + const product = await prisma.product.upsert({ + where: { artNo }, + update: { + barcode, + slug, + productGroup: baseSlug, + nameFa, + nameEn, + scientificTagline: PRODUCT_TAGLINES_MAP[baseSlug] || null, + description: cleanDesc, + shortDescription: cleanShortDesc, + ingredients: cleanIngredients, + categoryId: category.id, + categorySlug: category.slug, + priceValue: sellPrice, + wholesalePrice: Math.round(sellPrice * 0.7), + requiresRx, + buyPrice, + priceDisplay, + unit, + packageSize, + dosageLogic: cleanDosage, + suitableFor, + imageUrl: getProductImageUrl(baseSlug), + }, + create: { + artNo, + barcode, + slug, + productGroup: baseSlug, + nameFa, + nameEn, + scientificTagline: PRODUCT_TAGLINES_MAP[baseSlug] || null, + description: cleanDesc, + shortDescription: cleanShortDesc, + ingredients: cleanIngredients, + categoryId: category.id, + categorySlug: category.slug, + priceValue: sellPrice, + wholesalePrice: Math.round(sellPrice * 0.7), + requiresRx, + buyPrice, + priceDisplay, + unit, + packageSize, + dosageLogic: cleanDosage, + suitableFor, + imageUrl: getProductImageUrl(baseSlug), + } + }); + + // Seed ingredients + await prisma.productIngredient.deleteMany({ where: { productId: product.id } }); + if (cleanIngredients) { + const parts = cleanIngredients.split(/[،,]/).map((s: string) => s.trim()).filter(Boolean); + const meaningful = parts.slice(0, 20); + for (const ing of meaningful) { + const truncated = ing.substring(0, 150); + if (truncated.length > 0) { + await prisma.productIngredient.create({ + data: { productId: product.id, ingredient: truncated } + }); + } + } } - }); + + // Seed symptoms + await prisma.productSymptom.deleteMany({ where: { productId: product.id } }); + const symptomsList = PRODUCT_SYMPTOMS_MAP[baseSlug] || []; + for (const sym of symptomsList) { + await prisma.productSymptom.create({ + data: { productId: product.id, symptom: sym } + }); + } + } catch (err: any) { + console.warn(`[Seed] Warning upserting product ${artNo}:`, err.message || err); + } // Seed ingredients await prisma.productIngredient.deleteMany({ where: { productId: product.id } }); From 8a6afa10d81634d647c7e7441525ba654fbfbc9c Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Wed, 29 Jul 2026 15:23:14 +0330 Subject: [PATCH 13/19] fix(seed): remove duplicate out-of-scope code in seed-products.ts --- backend/prisma/seed-products.ts | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/backend/prisma/seed-products.ts b/backend/prisma/seed-products.ts index 3755a66..ac96217 100644 --- a/backend/prisma/seed-products.ts +++ b/backend/prisma/seed-products.ts @@ -283,30 +283,6 @@ export async function main() { console.warn(`[Seed] Warning upserting product ${artNo}:`, err.message || err); } - // Seed ingredients - await prisma.productIngredient.deleteMany({ where: { productId: product.id } }); - if (cleanIngredients) { - const parts = cleanIngredients.split(/[،,]/).map((s: string) => s.trim()).filter(Boolean); - const meaningful = parts.slice(0, 20); - for (const ing of meaningful) { - const truncated = ing.substring(0, 150); - if (truncated.length > 0) { - await prisma.productIngredient.create({ - data: { productId: product.id, ingredient: truncated } - }); - } - } - } - - // Seed symptoms - await prisma.productSymptom.deleteMany({ where: { productId: product.id } }); - const symptomsList = PRODUCT_SYMPTOMS_MAP[baseSlug] || []; - for (const sym of symptomsList) { - await prisma.productSymptom.create({ - data: { productId: product.id, symptom: sym } - }); - } - variantCount++; } } From ddf2bafb3c1dfc6c4ea7dce72447e6e898f5170c Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Wed, 29 Jul 2026 15:38:15 +0330 Subject: [PATCH 14/19] fix(seed): deduplicate ingredients and symptoms to ensure clean database seeding --- backend/prisma/seed-products.ts | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/backend/prisma/seed-products.ts b/backend/prisma/seed-products.ts index ac96217..91d291b 100644 --- a/backend/prisma/seed-products.ts +++ b/backend/prisma/seed-products.ts @@ -256,28 +256,31 @@ export async function main() { } }); - // Seed ingredients + // Seed ingredients safely await prisma.productIngredient.deleteMany({ where: { productId: product.id } }); if (cleanIngredients) { const parts = cleanIngredients.split(/[،,]/).map((s: string) => s.trim()).filter(Boolean); - const meaningful = parts.slice(0, 20); - for (const ing of meaningful) { - const truncated = ing.substring(0, 150); + const uniqueIngredients = Array.from(new Set(parts.map(ing => ing.substring(0, 150)))).slice(0, 20); + for (const truncated of uniqueIngredients) { if (truncated.length > 0) { - await prisma.productIngredient.create({ - data: { productId: product.id, ingredient: truncated } - }); + try { + await prisma.productIngredient.create({ + data: { productId: product.id, ingredient: truncated } + }); + } catch (_) {} } } } - // Seed symptoms + // Seed symptoms safely await prisma.productSymptom.deleteMany({ where: { productId: product.id } }); - const symptomsList = PRODUCT_SYMPTOMS_MAP[baseSlug] || []; + const symptomsList = Array.from(new Set(PRODUCT_SYMPTOMS_MAP[baseSlug] || [])); for (const sym of symptomsList) { - await prisma.productSymptom.create({ - data: { productId: product.id, symptom: sym } - }); + try { + await prisma.productSymptom.create({ + data: { productId: product.id, symptom: sym } + }); + } catch (_) {} } } catch (err: any) { console.warn(`[Seed] Warning upserting product ${artNo}:`, err.message || err); From 3ede3c31f9bcea28312cc6e443924ef048e1e916 Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Wed, 29 Jul 2026 15:40:01 +0330 Subject: [PATCH 15/19] fix(seed): format seed-products.ts with Prettier and add type annotations for ESLint --- backend/prisma/seed-products.ts | 361 +++++++++++++++++++++----------- 1 file changed, 242 insertions(+), 119 deletions(-) diff --git a/backend/prisma/seed-products.ts b/backend/prisma/seed-products.ts index 91d291b..7075b76 100644 --- a/backend/prisma/seed-products.ts +++ b/backend/prisma/seed-products.ts @@ -15,8 +15,13 @@ function slugify(text: string): string { function determineSuitableFor(name: string, description: string): string { const text = (name + ' ' + description).toLowerCase(); - const hasCat = text.includes('گربه') || text.includes('katzen') || text.includes('cat'); - const hasDog = text.includes('سگ') || text.includes('hund') || text.includes('توله') || text.includes('dog'); + const hasCat = + 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) return 'گربه'; if (hasDog) return 'سگ'; @@ -35,49 +40,81 @@ function parseSize(size: string): { unit: string; packageSize: number } { function getProductImageUrl(baseSlug: string): string { const images: Record = { - 'canina-ballaststoff-mix': 'https://www.canina.de/media/4c/32/38/1715077271/Ballaststoff-Mix-140108-V1-Canino-100g_600x600.webp', - 'canina-canhydrox-gag': 'https://www.canina.de/media/83/86/e1/123000_123005_Canhydrox_GAG_Canina-Pharma_1280x1280.png', - 'canina-eierschalenpulver': 'https://www.canina.de/media/fb/d3/18/120208_Eierschalenpulver_Canina-Pharma_1280x1280.png', - 'canina-flexan': 'https://www.canina.de/media/01/be/93/710003_Flexan_Canina-Pharma_1280x1280.png', - 'canina-herz-vital': 'https://www.canina.de/media/b9/8b/4c/112036_Herz_Vital_Canina-Pharma_1280x1280.png', - 'canina-immun-booster-paste': 'https://www.canina.de/media/8c/fb/8e/311000_Canino_Immun_Booster_Paste_Abwehrkraefte_1280x1280.png', - 'canina-katzenmilch': 'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png', - 'canina-lachs-l': 'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png', - 'canina-lachs-ol': 'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png', - 'canina-marine-lmischung-premium': 'https://www.canina.de/media/ad/28/71/153008_Marine_Oelmischung_Premium_Canina-Pharma_1280x1280.png', - 'canina-moortrnke': 'https://www.canina.de/media/9d/b2/87/112708_Moortraenke_Canina-Pharma_1280x1280.png', - 'canina-petvital-arthro-tabletten': 'https://www.canina.de/media/90/a6/50/723003_PETVITAL_Arthro_Tabletten_Canina-Pharma_1280x1280.png', - 'canina-petvital-bio-aktivator': 'https://www.canina.de/media/c9/2e/0f/712007_PETVITAL_Bio_Aktivator_Canina-Pharma_1280x1280.png', - 'canina-petvital-biotin-tabs': 'https://www.canina.de/media/e1/9b/6c/702008_PETVITAL_Biotin_Tabs_Canina-Pharma_1280x1280.png', - 'canina-petvital-energy-gel': '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', + 'canina-ballaststoff-mix': + 'https://www.canina.de/media/4c/32/38/1715077271/Ballaststoff-Mix-140108-V1-Canino-100g_600x600.webp', + 'canina-canhydrox-gag': + 'https://www.canina.de/media/83/86/e1/123000_123005_Canhydrox_GAG_Canina-Pharma_1280x1280.png', + 'canina-eierschalenpulver': + 'https://www.canina.de/media/fb/d3/18/120208_Eierschalenpulver_Canina-Pharma_1280x1280.png', + 'canina-flexan': + 'https://www.canina.de/media/01/be/93/710003_Flexan_Canina-Pharma_1280x1280.png', + 'canina-herz-vital': + 'https://www.canina.de/media/b9/8b/4c/112036_Herz_Vital_Canina-Pharma_1280x1280.png', + 'canina-immun-booster-paste': + 'https://www.canina.de/media/8c/fb/8e/311000_Canino_Immun_Booster_Paste_Abwehrkraefte_1280x1280.png', + 'canina-katzenmilch': + 'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png', + 'canina-lachs-l': + 'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png', + 'canina-lachs-ol': + 'https://www.canina.de/media/6b/48/81/731000_Canino_Lachsoel_Lachs_Oel_1280x1280.png', + 'canina-marine-lmischung-premium': + 'https://www.canina.de/media/ad/28/71/153008_Marine_Oelmischung_Premium_Canina-Pharma_1280x1280.png', + 'canina-moortrnke': + 'https://www.canina.de/media/9d/b2/87/112708_Moortraenke_Canina-Pharma_1280x1280.png', + 'canina-petvital-arthro-tabletten': + 'https://www.canina.de/media/90/a6/50/723003_PETVITAL_Arthro_Tabletten_Canina-Pharma_1280x1280.png', + 'canina-petvital-bio-aktivator': + 'https://www.canina.de/media/c9/2e/0f/712007_PETVITAL_Bio_Aktivator_Canina-Pharma_1280x1280.png', + 'canina-petvital-biotin-tabs': + 'https://www.canina.de/media/e1/9b/6c/702008_PETVITAL_Biotin_Tabs_Canina-Pharma_1280x1280.png', + 'canina-petvital-energy-gel': + '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`; } -async function findOrCreateCategory(slug: string): Promise<{ id: string; slug: string }> { +async function findOrCreateCategory( + slug: string, +): Promise<{ id: string; slug: string }> { const CATEGORY_NAMES: Record = { - 'joints': 'مفاصل و استخوان', - 'immune': 'تقویت سیستم ایمنی و گوارش', - 'energy': 'ویتامین‌ها و انرژی‌بخش‌ها', + joints: 'مفاصل و استخوان', + immune: 'تقویت سیستم ایمنی و گوارش', + energy: 'ویتامین‌ها و انرژی‌بخش‌ها', 'special-care': 'مراقبت‌های ویژه (پوست، دندان و چشم)', - 'general': 'تقویت عمومی', - 'nutrition': 'تغذیه تخصصی', - 'supplements': 'مکمل‌های غذایی و درمانی', + general: 'تقویت عمومی', + nutrition: 'تغذیه تخصصی', + supplements: 'مکمل‌های غذایی و درمانی', }; const name = CATEGORY_NAMES[slug] || slug; return await prisma.category.upsert({ @@ -88,80 +125,141 @@ async function findOrCreateCategory(slug: string): Promise<{ id: string; slug: s } const PRODUCT_SYMPTOMS_MAP: Record = { - 'canina-ballaststoff-mix': ["اسهال", "یبوست", "اسهال مزمن", "تنظیم فلور روده"], - 'canina-canhydrox-gag': ["درد مفاصل", "سختی در بلند شدن", "لنگیدن", "رشد سریع توله‌سگ", "تقویت رباط و تاندون"], - 'canina-eierschalenpulver': ["کمبود کلسیم", "رژیم خام گوشتی", "سلامت دندان‌ها"], - 'canina-flexan': ["درد مفاصل", "سختی در بلند شدن", "لنگیدن", "تخریب غضروف"], - 'canina-herz-vital': ["نارسایی قلبی", "بی‌حالی", "کاهش انرژی"], - 'canina-immun-booster-paste': ["ضعف بعد از بیماری", "اسهال", "ضعف توله‌سگ", "کاهش ایمنی"], - 'canina-katzenmilch': ["تغذیه بچه گربه", "بی‌مادری بچه گربه"], - '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-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': ["ترک پنجه", "خشکی پنجه", "زخم پنجه"], + 'canina-ballaststoff-mix': [ + 'اسهال', + 'یبوست', + 'اسهال مزمن', + 'تنظیم فلور روده', + ], + 'canina-canhydrox-gag': [ + 'درد مفاصل', + 'سختی در بلند شدن', + 'لنگیدن', + 'رشد سریع توله‌سگ', + 'تقویت رباط و تاندون', + ], + 'canina-eierschalenpulver': [ + 'کمبود کلسیم', + 'رژیم خام گوشتی', + 'سلامت دندان‌ها', + ], + 'canina-flexan': ['درد مفاصل', 'سختی در بلند شدن', 'لنگیدن', 'تخریب غضروف'], + 'canina-herz-vital': ['نارسایی قلبی', 'بی‌حالی', 'کاهش انرژی'], + 'canina-immun-booster-paste': [ + 'ضعف بعد از بیماری', + 'اسهال', + 'ضعف توله‌سگ', + 'کاهش ایمنی', + ], + 'canina-katzenmilch': ['تغذیه بچه گربه', 'بی‌مادری بچه گربه'], + '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-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 = { - 'canina-ballaststoff-mix': "مکمل فیبر پری‌بیوتیک جهت تنظیم گوارش و پایداری فلور روده", - 'canina-canhydrox-gag': "فرمولاسیون ویژه دامپزشکی برای پایداری بافت‌های همبند، غضروف و استخوان‌ها", - 'canina-eierschalenpulver': "کلسیم ارگانیک صد در صد طبیعی مناسب رژیم‌های غذایی خام (BARF)", - 'canina-flexan': "پپتیدهای کلاژن زیست‌فعال برای بهینه‌سازی دامنه حرکتی و بازسازی مفصلی", - 'canina-herz-vital': "تقویت عملکرد فیزیولوژیک عضله قلب و افزایش نشاط حیوان", - 'canina-immun-booster-paste': "تامین فوری ایمونوگلوبولین‌های آغوز و تثبیت فلور روده در زمان نقاهت", - 'canina-katzenmilch': "شیر خشک جایگزین بچه گربه حاوی تورین و فاقد لاکتوز مزاحم", - 'canina-lachs-ol': "اسیدهای چرب ضروری امگا ۳ برای درخشش پوشش مویی و سلامت پوست", - 'canina-marine-lmischung-premium': "ترکیب روغن‌های ممتاز دریایی غنی از EPA و DHA جهت کاهش التهابات پوستی", - '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': "مومیایی محافظ پنجه‌ها برای التیام خشکی، قرمزی و ترک خوردگی پنجه‌ها در برف و سرما" + 'canina-ballaststoff-mix': + 'مکمل فیبر پری‌بیوتیک جهت تنظیم گوارش و پایداری فلور روده', + 'canina-canhydrox-gag': + 'فرمولاسیون ویژه دامپزشکی برای پایداری بافت‌های همبند، غضروف و استخوان‌ها', + 'canina-eierschalenpulver': + 'کلسیم ارگانیک صد در صد طبیعی مناسب رژیم‌های غذایی خام (BARF)', + 'canina-flexan': + 'پپتیدهای کلاژن زیست‌فعال برای بهینه‌سازی دامنه حرکتی و بازسازی مفصلی', + 'canina-herz-vital': 'تقویت عملکرد فیزیولوژیک عضله قلب و افزایش نشاط حیوان', + 'canina-immun-booster-paste': + 'تامین فوری ایمونوگلوبولین‌های آغوز و تثبیت فلور روده در زمان نقاهت', + 'canina-katzenmilch': + 'شیر خشک جایگزین بچه گربه حاوی تورین و فاقد لاکتوز مزاحم', + 'canina-lachs-ol': + 'اسیدهای چرب ضروری امگا ۳ برای درخشش پوشش مویی و سلامت پوست', + 'canina-marine-lmischung-premium': + 'ترکیب روغن‌های ممتاز دریایی غنی از EPA و DHA جهت کاهش التهابات پوستی', + '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': + 'مومیایی محافظ پنجه‌ها برای التیام خشکی، قرمزی و ترک خوردگی پنجه‌ها در برف و سرما', }; export async function main() { console.log('Seeding products from seed-products-data.json...'); // 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)) { console.error('seed-products-data.json not found at', dataPath); 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; for (const item of productsData) { @@ -201,7 +299,16 @@ export async function main() { const nameEn = `${baseNameEn} - ${sizeLabel}`; 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); try { @@ -253,37 +360,53 @@ export async function main() { dosageLogic: cleanDosage, suitableFor, imageUrl: getProductImageUrl(baseSlug), - } + }, }); // Seed ingredients safely - await prisma.productIngredient.deleteMany({ where: { productId: product.id } }); + await prisma.productIngredient.deleteMany({ + where: { productId: product.id }, + }); if (cleanIngredients) { - const parts = cleanIngredients.split(/[،,]/).map((s: string) => s.trim()).filter(Boolean); - const uniqueIngredients = Array.from(new Set(parts.map(ing => ing.substring(0, 150)))).slice(0, 20); + const parts = cleanIngredients + .split(/[،,]/) + .map((s: string) => s.trim()) + .filter(Boolean); + const uniqueIngredients = Array.from( + new Set(parts.map((ing: string) => ing.substring(0, 150))), + ).slice(0, 20); for (const truncated of uniqueIngredients) { if (truncated.length > 0) { try { await prisma.productIngredient.create({ - data: { productId: product.id, ingredient: truncated } + data: { productId: product.id, ingredient: truncated }, }); - } catch (_) {} + } catch { + // Ignore duplicate ingredient error + } } } } // Seed symptoms safely - await prisma.productSymptom.deleteMany({ where: { productId: product.id } }); - const symptomsList = Array.from(new Set(PRODUCT_SYMPTOMS_MAP[baseSlug] || [])); + await prisma.productSymptom.deleteMany({ + where: { productId: product.id }, + }); + const symptomsList = Array.from( + new Set(PRODUCT_SYMPTOMS_MAP[baseSlug] || []), + ); for (const sym of symptomsList) { try { await prisma.productSymptom.create({ - data: { productId: product.id, symptom: sym } + data: { productId: product.id, symptom: sym }, }); - } catch (_) {} + } catch { + // Ignore duplicate symptom error + } } - } catch (err: any) { - console.warn(`[Seed] Warning upserting product ${artNo}:`, err.message || err); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[Seed] Warning upserting product ${artNo}:`, msg); } variantCount++; @@ -303,8 +426,8 @@ export async function main() { email: 'admin@canino-iran.com', firstName: 'Admin', lastName: 'User', - role: 'ADMIN' - } + role: 'ADMIN', + }, }); // 3. Seed some Blogs @@ -329,7 +452,7 @@ export async function main() { '
  • روغن سیاه دانه در بارداری و شیردهی ممنوع است
  • ', '
  • روغن‌های با غلظت بالا باید تدریجاً به رژیم اضافه شوند تا از پانکراتیت جلوگیری شود
  • ', '
  • تورین برای گربه‌ها اسید آمینه ضروری است و بدن آن‌ها قادر به سنتز آن نیست
  • ', - '' + '', ].join('\n'); const blog2Content = [ @@ -353,7 +476,7 @@ export async function main() { '

    قلب و انرژی: Herz Vital (ال-کارنیتین و زالزالک)، Energy-Gel (ویتامین‌های گروه B)

    ', '', '

    تعهد به سلامت حیوانات

    ', - '

    کانینا به عنوان نماینده رسمی کانینا در ایران، متعهد به ارائه محصولات اصل با ضمانت اصالت و پروانه دامپزشکی است. تمام محصولات دارای بسته‌بندی اصلی آلمان و بارکد اختصاصی هستند.

    ' + '

    کانینا به عنوان نماینده رسمی کانینا در ایران، متعهد به ارائه محصولات اصل با ضمانت اصالت و پروانه دامپزشکی است. تمام محصولات دارای بسته‌بندی اصلی آلمان و بارکد اختصاصی هستند.

    ', ].join('\n'); const blogs = [ @@ -370,21 +493,21 @@ export async function main() { content: blog2Content, authorId: adminId, isPublished: true, - } + }, ]; for (const b of blogs) { await prisma.blog.upsert({ where: { slug: b.slug }, update: {}, - create: b + create: b, }); } console.log('Seeded blogs.'); } main() - .catch(e => { + .catch((e) => { console.error(e); process.exit(1); }) From ae636c8b03b01807baa2f3df14e1c36f3d921037 Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Wed, 29 Jul 2026 15:40:44 +0330 Subject: [PATCH 16/19] fix(seed): add explicit String casting for ingredient and symptom loop items to satisfy TypeScript compiler --- backend/prisma/seed-products.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/backend/prisma/seed-products.ts b/backend/prisma/seed-products.ts index 7075b76..c0f52a1 100644 --- a/backend/prisma/seed-products.ts +++ b/backend/prisma/seed-products.ts @@ -372,10 +372,11 @@ export async function main() { .split(/[،,]/) .map((s: string) => s.trim()) .filter(Boolean); - const uniqueIngredients = Array.from( - new Set(parts.map((ing: string) => ing.substring(0, 150))), + const uniqueIngredients: string[] = Array.from( + new Set(parts.map((ing: string) => ing.substring(0, 150))), ).slice(0, 20); - for (const truncated of uniqueIngredients) { + for (const item of uniqueIngredients) { + const truncated: string = String(item); if (truncated.length > 0) { try { await prisma.productIngredient.create({ @@ -392,10 +393,11 @@ export async function main() { await prisma.productSymptom.deleteMany({ where: { productId: product.id }, }); - const symptomsList = Array.from( - new Set(PRODUCT_SYMPTOMS_MAP[baseSlug] || []), + const symptomsList: string[] = Array.from( + new Set(PRODUCT_SYMPTOMS_MAP[baseSlug] || []), ); - for (const sym of symptomsList) { + for (const item of symptomsList) { + const sym: string = String(item); try { await prisma.productSymptom.create({ data: { productId: product.id, symptom: sym }, From 62bd8811fafd33e186ac36e53aee2c02a0862fc1 Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Wed, 29 Jul 2026 15:41:53 +0330 Subject: [PATCH 17/19] style: apply standard ESLint & Prettier formatting across backend --- backend/src/admin/admin.controller.ts | 87 ++++++-- backend/src/admin/admin.module.ts | 20 +- backend/src/admin/admin.service.ts | 198 +++++++++++------- backend/src/admin/blogs.controller.ts | 20 +- backend/src/admin/blogs.service.ts | 23 +- backend/src/admin/categories.controller.ts | 19 +- backend/src/admin/categories.service.ts | 30 ++- backend/src/admin/media.controller.ts | 12 +- backend/src/admin/media.service.ts | 10 +- backend/src/admin/pets.controller.ts | 16 +- backend/src/admin/pets.service.ts | 28 ++- backend/src/admin/reports.service.ts | 50 +++-- backend/src/admin/wiki.controller.ts | 19 +- backend/src/admin/wiki.service.ts | 27 ++- backend/src/app.module.ts | 10 +- backend/src/auth/auth.controller.spec.ts | 12 +- backend/src/auth/auth.controller.ts | 48 +++-- backend/src/auth/auth.service.spec.ts | 21 +- backend/src/auth/auth.service.ts | 71 +++++-- backend/src/auth/dto/register.dto.ts | 13 +- backend/src/auth/jwt-auth.guard.ts | 4 +- backend/src/auth/roles.guard.ts | 17 +- backend/src/blogs/blogs.controller.ts | 11 +- backend/src/blogs/blogs.service.ts | 30 +-- backend/src/cms/cms.controller.ts | 32 ++- backend/src/cms/cms.service.ts | 24 ++- backend/src/cms/dto/cms.dto.ts | 8 +- backend/src/common/dto/pagination.dto.ts | 23 +- .../common/filters/http-exception.filter.ts | 23 +- backend/src/common/metrics.controller.ts | 2 +- .../common/schemas/error-response.schema.ts | 11 +- backend/src/home/home.controller.ts | 13 +- backend/src/home/home.service.ts | 10 +- backend/src/main.ts | 35 ++-- backend/src/orders/dto/create-order.dto.ts | 17 +- backend/src/orders/orders.controller.spec.ts | 8 +- backend/src/orders/orders.controller.ts | 89 +++++--- backend/src/orders/orders.service.spec.ts | 21 +- backend/src/orders/orders.service.ts | 85 +++++--- backend/src/pets/dto/create-health-log.dto.ts | 5 +- backend/src/pets/dto/create-pet.dto.ts | 8 +- backend/src/pets/dto/create-reminder.dto.ts | 10 +- backend/src/pets/pets.controller.spec.ts | 8 +- backend/src/pets/pets.controller.ts | 110 ++++++---- backend/src/pets/pets.service.spec.ts | 4 +- backend/src/pets/pets.service.ts | 52 +++-- backend/src/prisma/prisma.service.ts | 5 +- backend/src/products/dto/get-products.dto.ts | 5 +- .../src/products/products.controller.spec.ts | 12 +- backend/src/products/products.controller.ts | 85 +++++--- backend/src/products/products.service.ts | 73 ++++--- backend/src/seo/seo.controller.ts | 4 +- backend/src/seo/seo.service.ts | 67 +++--- .../src/settings/settings.controller.spec.ts | 4 +- backend/src/settings/settings.controller.ts | 84 +++++--- backend/src/users/users.controller.spec.ts | 23 +- backend/src/users/users.controller.ts | 80 ++++--- backend/src/users/users.service.spec.ts | 23 +- backend/src/users/users.service.ts | 32 +-- backend/src/videos/dto/create-video.dto.ts | 25 ++- backend/src/videos/videos.controller.ts | 24 ++- backend/src/videos/videos.service.ts | 10 +- backend/src/wholesale/wholesale.controller.ts | 23 +- backend/src/wholesale/wholesale.service.ts | 6 +- backend/src/wiki/wiki.controller.ts | 10 +- backend/src/wiki/wiki.service.ts | 16 +- 66 files changed, 1398 insertions(+), 607 deletions(-) diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts index 39e6736..4bfe20f 100644 --- a/backend/src/admin/admin.controller.ts +++ b/backend/src/admin/admin.controller.ts @@ -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 { JwtAuthGuard } from '../auth/jwt-auth.guard'; -import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger'; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiQuery, +} from '@nestjs/swagger'; @ApiTags('Admin - پنل مدیریت') @ApiBearerAuth() @@ -16,7 +31,7 @@ export class AdminController { const stats = await this.adminService.getDashboardStats(); return { success: true, - data: stats + data: stats, }; } @@ -25,15 +40,28 @@ export class AdminController { @ApiOperation({ summary: 'لیست کاربران' }) @ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' }) @ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' }) - @ApiQuery({ name: 'search', required: false, description: 'جستجو در نام یا ایمیل' }) - @ApiQuery({ name: 'role', required: false, description: 'فیلتر بر اساس نقش کاربر' }) + @ApiQuery({ + name: 'search', + required: false, + description: 'جستجو در نام یا ایمیل', + }) + @ApiQuery({ + name: 'role', + required: false, + description: 'فیلتر بر اساس نقش کاربر', + }) async getUsers( @Query('page') page?: string, @Query('limit') limit?: string, @Query('search') search?: string, - @Query('role') role?: string + @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 }; } @@ -44,7 +72,7 @@ export class AdminController { const user = await this.adminService.updateUserRole(id, role); return { success: true, - data: user + data: user, }; } @@ -54,14 +82,23 @@ export class AdminController { @ApiQuery({ name: 'page', required: false, description: 'شماره صفحه' }) @ApiQuery({ name: 'limit', required: false, description: 'تعداد آیتم‌ها' }) @ApiQuery({ name: 'search', required: false, description: 'جستجو' }) - @ApiQuery({ name: 'categoryId', required: false, description: 'شناسه دسته‌بندی' }) + @ApiQuery({ + name: 'categoryId', + required: false, + description: 'شناسه دسته‌بندی', + }) async getProducts( @Query('page') page?: string, @Query('limit') limit?: 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 }; } @@ -80,7 +117,7 @@ export class AdminController { const product = await this.adminService.updateProduct(id, data); return { success: true, - data: product + data: product, }; } @@ -91,7 +128,7 @@ export class AdminController { await this.adminService.deleteProduct(id); return { success: true, - message: 'Product deleted' + message: 'Product deleted', }; } @@ -106,9 +143,14 @@ export class AdminController { @Query('page') page?: string, @Query('limit') limit?: 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 }; } @@ -118,12 +160,16 @@ export class AdminController { async updateOrderStatus( @Param('id') id: 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 { success: true, - data: order + data: order, }; } @@ -158,7 +204,10 @@ export class AdminController { @UseGuards(JwtAuthGuard) @Put('coupons/:id/toggle') @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); return { success: true, data: coupon }; } diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts index b3aeb55..5004ada 100644 --- a/backend/src/admin/admin.module.ts +++ b/backend/src/admin/admin.module.ts @@ -18,7 +18,23 @@ import { RedisModule } from '../redis/redis.module'; @Module({ imports: [PrismaModule, RedisModule], - controllers: [AdminController, ReportsController, MediaController, CategoriesController, BlogsController, WikiController, PetsController], - providers: [AdminService, ReportsService, MediaService, CategoriesService, BlogsService, WikiService, PetsService], + controllers: [ + AdminController, + ReportsController, + MediaController, + CategoriesController, + BlogsController, + WikiController, + PetsController, + ], + providers: [ + AdminService, + ReportsService, + MediaService, + CategoriesService, + BlogsService, + WikiService, + PetsService, + ], }) export class AdminModule {} diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index c2f4c45..4fd8d34 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -13,14 +13,14 @@ export class AdminService { // total revenue const orders = await this.prisma.order.findMany({ where: { status: { not: 'failed' } }, - select: { totalAmount: true } + select: { totalAmount: true }, }); - + const revenue = orders.reduce((sum, o) => sum + Number(o.totalAmount), 0); - + // new orders count (processing status) const newOrders = await this.prisma.order.count({ - where: { status: 'processing' } + where: { status: 'processing' }, }); // active users count @@ -40,7 +40,7 @@ export class AdminService { revenue, newOrders, users, - todayVisits + todayVisits, }; } @@ -55,7 +55,7 @@ export class AdminService { { firstName: { contains: query.search, mode: 'insensitive' } }, { lastName: { contains: query.search, mode: 'insensitive' } }, { email: { contains: query.search, mode: 'insensitive' } }, - { mobile: { contains: query.search } } + { mobile: { contains: query.search } }, ]; } if (query.role) { @@ -67,17 +67,25 @@ export class AdminService { } const [data, total] = await Promise.all([ - this.prisma.user.findMany({ where, skip, take: limit, orderBy: { createdAt: 'desc' } }), - this.prisma.user.count({ where }) + this.prisma.user.findMany({ + 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) { return this.prisma.user.update({ where: { id }, - data: { role } + data: { role }, }); } @@ -91,7 +99,7 @@ export class AdminService { if (query.search) { where.OR = [ { name: { contains: query.search, mode: 'insensitive' } }, - { artNo: { contains: query.search } } + { artNo: { contains: query.search } }, ]; } if (query.categoryId) { @@ -99,16 +107,22 @@ export class AdminService { } const [data, total] = await Promise.all([ - this.prisma.product.findMany({ - where, skip, take: limit, orderBy: { createdAt: 'desc' }, - include: { category: true, symptoms: true } + this.prisma.product.findMany({ + where, + 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) { - console.error("[AdminService] getProducts error:", error); + console.error('[AdminService] getProducts error:', error); throw new HttpException(error.message || 'Error fetching products', 500); } } @@ -136,15 +150,17 @@ export class AdminService { keywords: data.keywords, canonicalUrl: data.canonicalUrl, slug: data.slug || data.artNo, - } + }, }); if (data.symptoms && Array.isArray(data.symptoms)) { await this.prisma.productSymptom.createMany({ - data: data.symptoms.map((s: string) => ({ - productId: product.id, - symptom: s.trim() - })).filter((s: any) => s.symptom.length > 0) + data: data.symptoms + .map((s: string) => ({ + productId: product.id, + symptom: s.trim(), + })) + .filter((s: any) => s.symptom.length > 0), }); } @@ -175,17 +191,19 @@ export class AdminService { keywords: data.keywords, canonicalUrl: data.canonicalUrl, slug: data.slug || data.artNo, - } + }, }); if (data.symptoms !== undefined && Array.isArray(data.symptoms)) { await this.prisma.productSymptom.deleteMany({ where: { productId: id } }); if (data.symptoms.length > 0) { await this.prisma.productSymptom.createMany({ - data: data.symptoms.map((s: string) => ({ - productId: id, - symptom: s.trim() - })).filter((s: any) => s.symptom.length > 0) + data: data.symptoms + .map((s: string) => ({ + productId: id, + symptom: s.trim(), + })) + .filter((s: any) => s.symptom.length > 0), }); } } @@ -209,9 +227,11 @@ export class AdminService { where.OR = [ { id: { contains: query.search } }, { 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: { phone: { contains: query.search } } } + { user: { phone: { contains: query.search } } }, ]; } if (query.status) { @@ -219,25 +239,28 @@ export class AdminService { } const [data, total] = await Promise.all([ - this.prisma.order.findMany({ - where, - skip, - take: limit, + this.prisma.order.findMany({ + where, + skip, + take: limit, orderBy: { createdAt: 'desc' }, - include: { - user: true, + include: { + user: true, orderItems: { 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) { @@ -252,10 +275,10 @@ export class AdminService { user: true, orderItems: { include: { - product: true - } - } - } + product: true, + }, + }, + }, }); } @@ -263,23 +286,30 @@ export class AdminService { async getCoupons(query: any) { const { page = 1, limit = 10, search = '' } = query; 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([ this.prisma.coupon.findMany({ where, skip, take: Number(limit), 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 { 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, expiresAt: data.expiresAt ? new Date(data.expiresAt) : null, isActive: data.isActive !== undefined ? data.isActive : true, - targets: data.targets && data.targets.length > 0 ? { - create: data.targets.map((t: any) => ({ - targetType: t.targetType, - targetId: t.targetId, - modifierType: t.modifierType || 'override', - modifierValue: t.modifierValue || null - })) - } : undefined + targets: + data.targets && data.targets.length > 0 + ? { + create: data.targets.map((t: any) => ({ + targetType: t.targetType, + targetId: t.targetId, + modifierType: t.modifierType || 'override', + modifierValue: t.modifierValue || null, + })), + } + : undefined, }, - include: { targets: true } + include: { targets: true }, }); } @@ -322,41 +355,52 @@ export class AdminService { maxUses: data.maxUses, expiresAt: data.expiresAt ? new Date(data.expiresAt) : null, isActive: data.isActive, - targets: data.targets && data.targets.length > 0 ? { - create: data.targets.map((t: any) => ({ - targetType: t.targetType, - targetId: t.targetId, - modifierType: t.modifierType || 'override', - modifierValue: t.modifierValue || null - })) - } : undefined + targets: + data.targets && data.targets.length > 0 + ? { + create: data.targets.map((t: any) => ({ + targetType: t.targetType, + targetId: t.targetId, + modifierType: t.modifierType || 'override', + modifierValue: t.modifierValue || null, + })), + } + : undefined, }, - include: { targets: true } + include: { targets: true }, }); } async toggleCoupon(id: string, isActive: boolean) { return this.prisma.coupon.update({ where: { id }, - data: { isActive } + data: { isActive }, }); } async deleteCoupon(id: string) { return this.prisma.coupon.delete({ - where: { id } + where: { id }, }); } // --- Settings --- 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({ - where: { key: { in: keys } } + where: { key: { in: keys } }, }); - + // 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) { @@ -365,10 +409,10 @@ export class AdminService { return this.prisma.uiText.upsert({ where: { key }, update: { value: String(value) }, - create: { key, value: String(value) } + create: { key, value: String(value) }, }); }); - + await this.prisma.$transaction(operations); return this.getSettings(); } diff --git a/backend/src/admin/blogs.controller.ts b/backend/src/admin/blogs.controller.ts index 687415c..3ad988b 100644 --- a/backend/src/admin/blogs.controller.ts +++ b/backend/src/admin/blogs.controller.ts @@ -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 { JwtAuthGuard } from '../auth/jwt-auth.guard'; -import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger'; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiQuery, +} from '@nestjs/swagger'; @ApiTags('Admin - مدیریت مقالات (بلاگ)') @ApiBearerAuth() diff --git a/backend/src/admin/blogs.service.ts b/backend/src/admin/blogs.service.ts index e9cfd59..a26d524 100644 --- a/backend/src/admin/blogs.service.ts +++ b/backend/src/admin/blogs.service.ts @@ -8,29 +8,36 @@ export class BlogsService { async getBlogs(query: any) { const { page = 1, limit = 10, search = '' } = query; 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([ this.prisma.blog.findMany({ where, skip, take: Number(limit), 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 { 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) { - return this.prisma.blog.create({ - data: { ...data, authorId } + return this.prisma.blog.create({ + data: { ...data, authorId }, }); } diff --git a/backend/src/admin/categories.controller.ts b/backend/src/admin/categories.controller.ts index 49baeaa..d734c76 100644 --- a/backend/src/admin/categories.controller.ts +++ b/backend/src/admin/categories.controller.ts @@ -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 { JwtAuthGuard } from '../auth/jwt-auth.guard'; -import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger'; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiQuery, +} from '@nestjs/swagger'; @ApiTags('Admin - مدیریت دسته‌بندی‌ها') @ApiBearerAuth() diff --git a/backend/src/admin/categories.service.ts b/backend/src/admin/categories.service.ts index 8beb9e9..fb89bbe 100644 --- a/backend/src/admin/categories.service.ts +++ b/backend/src/admin/categories.service.ts @@ -8,22 +8,29 @@ export class CategoriesService { async getCategories(query: any) { const { page = 1, limit = 10, search = '' } = query; 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([ this.prisma.category.findMany({ where, skip, take: Number(limit), - orderBy: { createdAt: 'desc' } + orderBy: { createdAt: 'desc' }, }), - this.prisma.category.count({ where }) + this.prisma.category.count({ where }), ]); return { 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) { const cleanData = { ...data }; - Object.keys(cleanData).forEach(k => { + Object.keys(cleanData).forEach((k) => { if (cleanData[k] === '') cleanData[k] = null; }); // Ensure required fields - if (!cleanData.slug) cleanData.slug = cleanData.name.replace(/\s+/g, '-').toLowerCase(); - + if (!cleanData.slug) + cleanData.slug = cleanData.name.replace(/\s+/g, '-').toLowerCase(); + return this.prisma.category.create({ data: cleanData }); } async updateCategory(id: string, data: any) { const category = await this.prisma.category.findUnique({ where: { id } }); if (!category) throw new NotFoundException('Category not found'); - + const cleanData = { ...data }; - Object.keys(cleanData).forEach(k => { + Object.keys(cleanData).forEach((k) => { if (cleanData[k] === '') cleanData[k] = null; }); diff --git a/backend/src/admin/media.controller.ts b/backend/src/admin/media.controller.ts index d5a4d43..3652fc8 100644 --- a/backend/src/admin/media.controller.ts +++ b/backend/src/admin/media.controller.ts @@ -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 { MediaService } from './media.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; diff --git a/backend/src/admin/media.service.ts b/backend/src/admin/media.service.ts index d3c77ca..0567c27 100644 --- a/backend/src/admin/media.service.ts +++ b/backend/src/admin/media.service.ts @@ -9,7 +9,7 @@ export class MediaService { async getAllMedia() { return this.prisma.media.findMany({ - orderBy: { createdAt: 'desc' } + orderBy: { createdAt: 'desc' }, }); } @@ -39,7 +39,7 @@ export class MediaService { url, mimetype: file.mimetype, size: file.size, - } + }, }); return media; @@ -49,7 +49,11 @@ export class MediaService { const media = await this.prisma.media.findUnique({ where: { id } }); 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)) { fs.unlinkSync(filePath); } diff --git a/backend/src/admin/pets.controller.ts b/backend/src/admin/pets.controller.ts index 7792771..ab1e76c 100644 --- a/backend/src/admin/pets.controller.ts +++ b/backend/src/admin/pets.controller.ts @@ -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 { JwtAuthGuard } from '../auth/jwt-auth.guard'; -import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger'; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiQuery, +} from '@nestjs/swagger'; @ApiTags('Admin - مدیریت حیوانات خانگی') @ApiBearerAuth() diff --git a/backend/src/admin/pets.service.ts b/backend/src/admin/pets.service.ts index c82f280..47ac7ac 100644 --- a/backend/src/admin/pets.service.ts +++ b/backend/src/admin/pets.service.ts @@ -8,23 +8,39 @@ export class PetsService { async getPets(query: any) { const { page = 1, limit = 10, search = '' } = query; 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([ this.prisma.pet.findMany({ where, skip, take: Number(limit), 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 { 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)), + }, }; } diff --git a/backend/src/admin/reports.service.ts b/backend/src/admin/reports.service.ts index ec85766..9fde2a8 100644 --- a/backend/src/admin/reports.service.ts +++ b/backend/src/admin/reports.service.ts @@ -9,7 +9,12 @@ export class ReportsService { // 1. Total revenue and charity const orders = await this.prisma.order.findMany({ 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; @@ -23,7 +28,7 @@ export class ReportsService { const amount = Number(order.totalAmount); totalRevenue += amount; totalCharity += Number(order.charityDonation); - + if (order.user?.role === 'B2B') { b2bRevenue += amount; } else { @@ -45,23 +50,28 @@ export class ReportsService { const orderItems = await this.prisma.orderItem.groupBy({ by: ['productId'], _sum: { quantity: true }, - where: { order: { status: { not: 'cancelled' } }, productId: { not: null } }, + where: { + order: { status: { not: 'cancelled' } }, + productId: { not: null }, + }, orderBy: { _sum: { quantity: 'desc' } }, - take: 5 + take: 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({ where: { id: { in: topProductsIds } }, - select: { id: true, nameFa: true, nameEn: true } + select: { id: true, nameFa: true, nameEn: true }, }); - const bestSellers = orderItems.map(item => { - const p = productsInfo.find(prod => prod.id === item.productId); + const bestSellers = orderItems.map((item) => { + const p = productsInfo.find((prod) => prod.id === item.productId); return { 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 } } - const categories = await this.prisma.category.findMany({ include: { products: { select: { id: true } } } }); - const catSalesDist = categories.map(cat => { - 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); + const categories = await this.prisma.category.findMany({ + include: { products: { select: { id: true } } }, + }); + const catSalesDist = categories + .map((cat) => { + 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 const topCoupons = await this.prisma.coupon.findMany({ orderBy: { usedCount: 'desc' }, take: 5, - select: { code: true, usedCount: true } + select: { code: true, usedCount: true }, }); return { @@ -97,7 +113,7 @@ export class ReportsService { salesTimeline, bestSellers, categoryDistribution: catSalesDist, - topCoupons: topCoupons.map(c => ({ name: c.code, value: c.usedCount })) + topCoupons: topCoupons.map((c) => ({ name: c.code, value: c.usedCount })), }; } } diff --git a/backend/src/admin/wiki.controller.ts b/backend/src/admin/wiki.controller.ts index 71f7912..a0c822a 100644 --- a/backend/src/admin/wiki.controller.ts +++ b/backend/src/admin/wiki.controller.ts @@ -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 { JwtAuthGuard } from '../auth/jwt-auth.guard'; -import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger'; +import { + ApiTags, + ApiBearerAuth, + ApiOperation, + ApiQuery, +} from '@nestjs/swagger'; @ApiTags('Admin - مدیریت دانشنامه (اصطلاحات علمی)') @ApiBearerAuth() diff --git a/backend/src/admin/wiki.service.ts b/backend/src/admin/wiki.service.ts index 9a0752c..0517d60 100644 --- a/backend/src/admin/wiki.service.ts +++ b/backend/src/admin/wiki.service.ts @@ -8,22 +8,29 @@ export class WikiService { async getTerms(query: any) { const { page = 1, limit = 10, search = '' } = query; 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([ this.prisma.scientificTerm.findMany({ where, skip, take: Number(limit), - orderBy: { term: 'asc' } + orderBy: { term: 'asc' }, }), - this.prisma.scientificTerm.count({ where }) + this.prisma.scientificTerm.count({ where }), ]); return { 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) { - 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'); return this.prisma.scientificTerm.update({ where: { key }, data }); } 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'); return this.prisma.scientificTerm.delete({ where: { key } }); } diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e3a212f..1990863 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -30,10 +30,12 @@ import { VideosModule } from './videos/videos.module'; PetsModule, OrdersModule, SettingsModule, - ThrottlerModule.forRoot([{ - ttl: 60000, - limit: 100, - }]), + ThrottlerModule.forRoot([ + { + ttl: 60000, + limit: 100, + }, + ]), AdminModule, HomeModule, BlogsModule, diff --git a/backend/src/auth/auth.controller.spec.ts b/backend/src/auth/auth.controller.spec.ts index 4a09782..e17f17c 100644 --- a/backend/src/auth/auth.controller.spec.ts +++ b/backend/src/auth/auth.controller.spec.ts @@ -7,16 +7,18 @@ describe('AuthController', () => { let service: AuthService; const mockAuthService = { - sendOtp: jest.fn().mockResolvedValue({ success: true, message: 'کد تایید ارسال شد' }), - verifyOtp: jest.fn().mockResolvedValue({ success: true, data: { accessToken: 'token' } }), + sendOtp: jest + .fn() + .mockResolvedValue({ success: true, message: 'کد تایید ارسال شد' }), + verifyOtp: jest + .fn() + .mockResolvedValue({ success: true, data: { accessToken: 'token' } }), }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [AuthController], - providers: [ - { provide: AuthService, useValue: mockAuthService }, - ], + providers: [{ provide: AuthService, useValue: mockAuthService }], }).compile(); controller = module.get(AuthController); diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 8043d8a..1c46ffe 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -4,7 +4,13 @@ import { SendOtpDto } from './dto/send-otp.dto'; import { VerifyOtpDto } from './dto/verify-otp.dto'; import { RegisterDto } from './dto/register.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 - احراز هویت') @Controller('auth') @@ -16,9 +22,9 @@ import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse, ApiBadRequestRespons success: false, message: 'خطای ناشناخته در سرور رخ داده است', code: 'SERVER_ERROR', - details: {} - } - } + details: {}, + }, + }, }) export class AuthController { constructor(private readonly authService: AuthService) {} @@ -32,9 +38,9 @@ export class AuthController { example: { success: true, message: 'کد تایید ارسال شد', - code: '12345' - } - } + code: '12345', + }, + }, }) @ApiBadRequestResponse({ description: 'فرمت شماره تلفن همراه نامعتبر است', @@ -44,10 +50,10 @@ export class AuthController { message: 'شماره موبایل نامعتبر است', code: 'BAD_REQUEST', details: { - message: ['شماره موبایل نامعتبر است'] - } - } - } + message: ['شماره موبایل نامعتبر است'], + }, + }, + }, }) sendOtp(@Body() sendOtpDto: SendOtpDto) { return this.authService.sendOtp(sendOtpDto); @@ -72,12 +78,12 @@ export class AuthController { walletBalance: '0.00', charityDonationTotal: '0.00', 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({ description: 'کد تایید اشتباه یا منقضی شده است', @@ -86,9 +92,9 @@ export class AuthController { success: false, message: 'کد تایید اشتباه است', code: 'OTP_INVALID', - details: {} - } - } + details: {}, + }, + }, }) verifyOtp(@Body() verifyOtpDto: VerifyOtpDto) { return this.authService.verifyOtp(verifyOtpDto); @@ -98,7 +104,9 @@ export class AuthController { @HttpCode(HttpStatus.CREATED) @ApiOperation({ summary: 'ثبت‌نام با ایمیل/موبایل و رمز عبور' }) @ApiOkResponse({ description: 'کاربر با موفقیت ثبت‌نام شد' }) - @ApiBadRequestResponse({ description: 'اطلاعات ثبت‌نام نامعتبر است یا کاربر از قبل وجود دارد' }) + @ApiBadRequestResponse({ + description: 'اطلاعات ثبت‌نام نامعتبر است یا کاربر از قبل وجود دارد', + }) register(@Body() registerDto: RegisterDto) { return this.authService.register(registerDto); } diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts index 1861062..48a5fec 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -88,11 +88,19 @@ describe('AuthService', () => { const mockUser = { id: 'user-id', mobile: '09123456789' }; 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(prisma.user.findUnique).toHaveBeenCalledWith({ where: { mobile: '09123456789' } }); - expect(jwt.sign).toHaveBeenCalledWith({ sub: 'user-id', phoneNumber: '09123456789' }); + expect(prisma.user.findUnique).toHaveBeenCalledWith({ + where: { mobile: '09123456789' }, + }); + expect(jwt.sign).toHaveBeenCalledWith({ + sub: 'user-id', + phoneNumber: '09123456789', + }); expect(result.success).toBe(true); expect(result.data.accessToken).toBe('mock-jwt-token'); expect(result.data.user).toEqual(mockUser); @@ -104,7 +112,10 @@ describe('AuthService', () => { const newUser = { id: 'new-user-id', mobile: '09123456789' }; 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(result.data.user).toEqual(newUser); }); diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 7912f6e..d25cc8a 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -33,20 +33,28 @@ export class AuthService { const savedCode = await this.redisService.get(`otp:${phoneNumber}`); if (!savedCode) { - throw new BadRequestException({ message: 'کد تایید منقضی شده است', error: 'OTP_EXPIRED' }); + throw new BadRequestException({ + message: 'کد تایید منقضی شده است', + error: 'OTP_EXPIRED', + }); } if (savedCode !== code) { - throw new BadRequestException({ message: 'کد تایید اشتباه است', error: 'OTP_INVALID' }); + throw new BadRequestException({ + message: 'کد تایید اشتباه است', + error: 'OTP_INVALID', + }); } 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) { user = await this.prisma.user.create({ - data: { + data: { mobile: phoneNumber, firstName: 'کاربر', lastName: 'جدید', @@ -70,15 +78,25 @@ export class AuthService { async register(registerDto: any) { 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) { - throw new BadRequestException({ message: 'کاربری با این شماره موبایل قبلا ثبت نام کرده است', error: 'MOBILE_EXISTS' }); + throw new BadRequestException({ + message: 'کاربری با این شماره موبایل قبلا ثبت نام کرده است', + error: 'MOBILE_EXISTS', + }); } if (email) { - const existingEmail = await this.prisma.user.findUnique({ where: { email } }); + const existingEmail = await this.prisma.user.findUnique({ + where: { email }, + }); 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 } }); if (!user) { - throw new BadRequestException({ message: 'نام کاربری یا رمز عبور اشتباه است', error: 'INVALID_CREDENTIALS' }); + throw new BadRequestException({ + message: 'نام کاربری یا رمز عبور اشتباه است', + error: 'INVALID_CREDENTIALS', + }); } 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); if (!isMatch) { - throw new BadRequestException({ message: 'نام کاربری یا رمز عبور اشتباه است', error: 'INVALID_CREDENTIALS' }); + throw new BadRequestException({ + message: 'نام کاربری یا رمز عبور اشتباه است', + error: 'INVALID_CREDENTIALS', + }); } const payload = { sub: user.id, phoneNumber: user.mobile }; @@ -136,16 +163,30 @@ export class AuthService { } async adminLogin(body: any) { - if (body.email === 'admin@canino-iran.com' && body.password === 'admin123') { - const payload = { sub: '12345678-1234-1234-1234-123456789012', email: body.email, role: 'Admin' }; + if ( + body.email === 'admin@canino-iran.com' && + body.password === 'admin123' + ) { + const payload = { + sub: '12345678-1234-1234-1234-123456789012', + email: body.email, + role: 'Admin', + }; return { success: true, 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), }, }; } - throw new BadRequestException({ message: 'ایمیل یا رمز عبور اشتباه است', error: 'INVALID_CREDENTIALS' }); + throw new BadRequestException({ + message: 'ایمیل یا رمز عبور اشتباه است', + error: 'INVALID_CREDENTIALS', + }); } } diff --git a/backend/src/auth/dto/register.dto.ts b/backend/src/auth/dto/register.dto.ts index 0a60336..55d2817 100644 --- a/backend/src/auth/dto/register.dto.ts +++ b/backend/src/auth/dto/register.dto.ts @@ -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'; export class RegisterDto { @@ -12,7 +18,10 @@ export class RegisterDto { @IsString() lastName: string; - @ApiPropertyOptional({ description: 'ایمیل (اختیاری)', example: 'ali@example.com' }) + @ApiPropertyOptional({ + description: 'ایمیل (اختیاری)', + example: 'ali@example.com', + }) @IsOptional() @IsEmail({}, { message: 'ایمیل نامعتبر است' }) email?: string; diff --git a/backend/src/auth/jwt-auth.guard.ts b/backend/src/auth/jwt-auth.guard.ts index b849976..21b0ac9 100644 --- a/backend/src/auth/jwt-auth.guard.ts +++ b/backend/src/auth/jwt-auth.guard.ts @@ -5,7 +5,9 @@ import { AuthGuard } from '@nestjs/passport'; export class JwtAuthGuard extends AuthGuard('jwt') { handleRequest(err: any, user: any, info: any) { if (err || !user) { - throw err || new UnauthorizedException('لطفا ابتدا وارد حساب کاربری خود شوید'); + throw ( + err || new UnauthorizedException('لطفا ابتدا وارد حساب کاربری خود شوید') + ); } return user; } diff --git a/backend/src/auth/roles.guard.ts b/backend/src/auth/roles.guard.ts index 23fe702..9a47894 100644 --- a/backend/src/auth/roles.guard.ts +++ b/backend/src/auth/roles.guard.ts @@ -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 { ROLES_KEY } from './roles.decorator'; @@ -7,11 +12,11 @@ export class RolesGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { - const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ - context.getHandler(), - context.getClass(), - ]); - + const requiredRoles = this.reflector.getAllAndOverride( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); + if (!requiredRoles || requiredRoles.length === 0) { return true; } diff --git a/backend/src/blogs/blogs.controller.ts b/backend/src/blogs/blogs.controller.ts index 66a70b0..b249f25 100644 --- a/backend/src/blogs/blogs.controller.ts +++ b/backend/src/blogs/blogs.controller.ts @@ -1,13 +1,20 @@ import { Controller, Get, Param, HttpStatus, Query } from '@nestjs/common'; 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'; @ApiTags('Blogs - مجله سلامت') @Controller('blogs') @ApiResponse({ status: HttpStatus.INTERNAL_SERVER_ERROR, - description: 'خطای داخلی سرور' + description: 'خطای داخلی سرور', }) export class BlogsController { constructor(private readonly blogsService: BlogsService) {} diff --git a/backend/src/blogs/blogs.service.ts b/backend/src/blogs/blogs.service.ts index 42f4b92..9a31495 100644 --- a/backend/src/blogs/blogs.service.ts +++ b/backend/src/blogs/blogs.service.ts @@ -7,8 +7,14 @@ export class BlogsService { constructor(private prisma: PrismaService) {} 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 }; if (search) { whereClause.OR = [ @@ -27,11 +33,11 @@ export class BlogsService { orderBy: { [sortBy]: sortOrder }, include: { author: { - select: { firstName: true, lastName: true } - } - } + select: { firstName: true, lastName: true }, + }, + }, }), - this.prisma.blog.count({ where: whereClause }) + this.prisma.blog.count({ where: whereClause }), ]); return { @@ -41,7 +47,7 @@ export class BlogsService { page, lastPage: Math.ceil(total / limit), limit, - } + }, }; } @@ -50,15 +56,15 @@ export class BlogsService { where: { slug, isPublished: true }, include: { author: { - select: { firstName: true, lastName: true } - } - } + select: { firstName: true, lastName: true }, + }, + }, }); - + if (!blog) { throw new NotFoundException('مقاله یافت نشد'); } - + return blog; } } diff --git a/backend/src/cms/cms.controller.ts b/backend/src/cms/cms.controller.ts index b35ed75..4984761 100644 --- a/backend/src/cms/cms.controller.ts +++ b/backend/src/cms/cms.controller.ts @@ -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 { CreateHeroBannerDto, CreateVetTestimonialDto, CreateSmartAdvisorRuleDto } from './dto/cms.dto'; +import { + CreateHeroBannerDto, + CreateVetTestimonialDto, + CreateSmartAdvisorRuleDto, +} from './dto/cms.dto'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { RolesGuard } from '../auth/roles.guard'; @@ -29,7 +42,10 @@ export class CmsController { @Put('hero-banners/:id') @ApiOperation({ summary: 'ویرایش بنر هیرو' }) - updateHeroBanner(@Param('id') id: string, @Body() dto: Partial) { + updateHeroBanner( + @Param('id') id: string, + @Body() dto: Partial, + ) { return this.cmsService.updateHeroBanner(id, dto); } @@ -54,7 +70,10 @@ export class CmsController { @Put('vet-testimonials/:id') @ApiOperation({ summary: 'ویرایش نظر دامپزشک' }) - updateVetTestimonial(@Param('id') id: string, @Body() dto: Partial) { + updateVetTestimonial( + @Param('id') id: string, + @Body() dto: Partial, + ) { return this.cmsService.updateVetTestimonial(id, dto); } @@ -79,7 +98,10 @@ export class CmsController { @Put('smart-advisor-rules/:id') @ApiOperation({ summary: 'ویرایش قانون مشاوره هوشمند' }) - updateSmartAdvisorRule(@Param('id') id: string, @Body() dto: Partial) { + updateSmartAdvisorRule( + @Param('id') id: string, + @Body() dto: Partial, + ) { return this.cmsService.updateSmartAdvisorRule(id, dto); } diff --git a/backend/src/cms/cms.service.ts b/backend/src/cms/cms.service.ts index 167c5b5..9663dd4 100644 --- a/backend/src/cms/cms.service.ts +++ b/backend/src/cms/cms.service.ts @@ -1,6 +1,10 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; -import { CreateHeroBannerDto, CreateVetTestimonialDto, CreateSmartAdvisorRuleDto } from './dto/cms.dto'; +import { + CreateHeroBannerDto, + CreateVetTestimonialDto, + CreateSmartAdvisorRuleDto, +} from './dto/cms.dto'; @Injectable() export class CmsService { @@ -38,8 +42,13 @@ export class CmsService { return this.prisma.vetTestimonial.create({ data: dto }); } - async updateVetTestimonial(id: string, dto: Partial) { - const exists = await this.prisma.vetTestimonial.findUnique({ where: { id } }); + async updateVetTestimonial( + id: string, + dto: Partial, + ) { + const exists = await this.prisma.vetTestimonial.findUnique({ + where: { id }, + }); if (!exists) throw new NotFoundException('Vet testimonial not found'); return this.prisma.vetTestimonial.update({ where: { id }, data: dto }); } @@ -59,8 +68,13 @@ export class CmsService { return this.prisma.smartAdvisorRule.create({ data: dto }); } - async updateSmartAdvisorRule(id: string, dto: Partial) { - const exists = await this.prisma.smartAdvisorRule.findUnique({ where: { id } }); + async updateSmartAdvisorRule( + id: string, + dto: Partial, + ) { + const exists = await this.prisma.smartAdvisorRule.findUnique({ + where: { id }, + }); if (!exists) throw new NotFoundException('Smart advisor rule not found'); return this.prisma.smartAdvisorRule.update({ where: { id }, data: dto }); } diff --git a/backend/src/cms/dto/cms.dto.ts b/backend/src/cms/dto/cms.dto.ts index 4fc2c76..f0c9d42 100644 --- a/backend/src/cms/dto/cms.dto.ts +++ b/backend/src/cms/dto/cms.dto.ts @@ -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'; export class CreateHeroBannerDto { diff --git a/backend/src/common/dto/pagination.dto.ts b/backend/src/common/dto/pagination.dto.ts index b8ebc23..5112087 100644 --- a/backend/src/common/dto/pagination.dto.ts +++ b/backend/src/common/dto/pagination.dto.ts @@ -8,26 +8,41 @@ export enum SortOrder { } export class PaginationDto { - @ApiPropertyOptional({ description: 'شماره صفحه (شروع از ۱)', minimum: 1, default: 1 }) + @ApiPropertyOptional({ + description: 'شماره صفحه (شروع از ۱)', + minimum: 1, + default: 1, + }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number = 1; - @ApiPropertyOptional({ description: 'تعداد آیتم‌ها در هر صفحه', minimum: 1, default: 10 }) + @ApiPropertyOptional({ + description: 'تعداد آیتم‌ها در هر صفحه', + minimum: 1, + default: 10, + }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) limit?: number = 10; - @ApiPropertyOptional({ description: 'فیلد برای مرتب‌سازی', default: 'createdAt' }) + @ApiPropertyOptional({ + description: 'فیلد برای مرتب‌سازی', + default: 'createdAt', + }) @IsOptional() @IsString() sortBy?: string = 'createdAt'; - @ApiPropertyOptional({ description: 'جهت مرتب‌سازی (asc/desc)', enum: SortOrder, default: SortOrder.DESC }) + @ApiPropertyOptional({ + description: 'جهت مرتب‌سازی (asc/desc)', + enum: SortOrder, + default: SortOrder.DESC, + }) @IsOptional() @IsEnum(SortOrder) sortOrder?: SortOrder = SortOrder.DESC; diff --git a/backend/src/common/filters/http-exception.filter.ts b/backend/src/common/filters/http-exception.filter.ts index 5472ad8..df9b5d0 100644 --- a/backend/src/common/filters/http-exception.filter.ts +++ b/backend/src/common/filters/http-exception.filter.ts @@ -1,4 +1,9 @@ -import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common'; +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, +} from '@nestjs/common'; import { Response } from 'express'; @Catch(HttpException) @@ -9,9 +14,17 @@ export class HttpExceptionFilter implements ExceptionFilter { const status = exception.getStatus(); const exceptionResponse: any = exception.getResponse(); - let message = typeof exceptionResponse === 'string' ? exceptionResponse : (exceptionResponse.message || 'خطای سرور'); - let code = typeof exceptionResponse === 'object' && exceptionResponse.error ? exceptionResponse.error : (status === 400 ? 'BAD_REQUEST' : 'ERROR'); - + let message = + 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 if (Array.isArray(message) && status === 400) { message = 'اطلاعات وارد شده نامعتبر است. لطفاً فرم را بررسی کنید.'; @@ -30,7 +43,7 @@ export class HttpExceptionFilter implements ExceptionFilter { success: false, message, code, - details: typeof exceptionResponse === 'object' ? exceptionResponse : {} + details: typeof exceptionResponse === 'object' ? exceptionResponse : {}, }); } } diff --git a/backend/src/common/metrics.controller.ts b/backend/src/common/metrics.controller.ts index e987ee0..ac3b3b9 100644 --- a/backend/src/common/metrics.controller.ts +++ b/backend/src/common/metrics.controller.ts @@ -18,7 +18,7 @@ export class MetricsController { async getMetrics(@Res() res: express.Response) { const memory = process.memoryUsage(); const cpu = process.cpuUsage(); - + let dbStatus = 1; try { await this.prisma.$queryRaw`SELECT 1`; diff --git a/backend/src/common/schemas/error-response.schema.ts b/backend/src/common/schemas/error-response.schema.ts index ae61a95..15fc604 100644 --- a/backend/src/common/schemas/error-response.schema.ts +++ b/backend/src/common/schemas/error-response.schema.ts @@ -4,12 +4,19 @@ export class ApiErrorResponse { @ApiProperty({ description: 'موفقیت‌آمیز بودن درخواست', example: false }) success: boolean; - @ApiProperty({ description: 'پیام خطا', example: 'اطلاعات وارد شده نامعتبر است. لطفاً فرم را بررسی کنید.' }) + @ApiProperty({ + description: 'پیام خطا', + example: 'اطلاعات وارد شده نامعتبر است. لطفاً فرم را بررسی کنید.', + }) message: string; @ApiProperty({ description: 'کد خطا', example: 'BAD_REQUEST' }) code: string; - @ApiProperty({ description: 'جزئیات خطا (در صورت وجود)', required: false, example: {} }) + @ApiProperty({ + description: 'جزئیات خطا (در صورت وجود)', + required: false, + example: {}, + }) details?: any; } diff --git a/backend/src/home/home.controller.ts b/backend/src/home/home.controller.ts index f3e5ec3..1b83adb 100644 --- a/backend/src/home/home.controller.ts +++ b/backend/src/home/home.controller.ts @@ -1,19 +1,26 @@ import { Controller, Get, HttpStatus } from '@nestjs/common'; import { HomeService } from './home.service'; -import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse } from '@nestjs/swagger'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiOkResponse, +} from '@nestjs/swagger'; @ApiTags('Home - صفحه اصلی') @Controller('home') @ApiResponse({ status: HttpStatus.INTERNAL_SERVER_ERROR, - description: 'خطای داخلی سرور' + description: 'خطای داخلی سرور', }) export class HomeController { constructor(private readonly homeService: HomeService) {} @Get() @ApiOperation({ summary: 'دریافت اطلاعات صفحه اصلی' }) - @ApiOkResponse({ description: 'اطلاعات ویترین، بنرها، پرفروش‌ترین‌ها، وبلاگ و غیره' }) + @ApiOkResponse({ + description: 'اطلاعات ویترین، بنرها، پرفروش‌ترین‌ها، وبلاگ و غیره', + }) getHomeData() { return this.homeService.getHomeData(); } diff --git a/backend/src/home/home.service.ts b/backend/src/home/home.service.ts index e138753..69c8761 100644 --- a/backend/src/home/home.service.ts +++ b/backend/src/home/home.service.ts @@ -31,10 +31,10 @@ export class HomeService { priceValue: true, priceDisplay: true, categorySlug: true, - categoryId: true - } - } - } + categoryId: true, + }, + }, + }, }); // We can also fetch featured products here if needed, or rely on a separate endpoint @@ -47,7 +47,7 @@ export class HomeService { heroBanners, vetTestimonials, smartAdvisorRules, - featuredProducts + featuredProducts, }; } } diff --git a/backend/src/main.ts b/backend/src/main.ts index f6804eb..a027bcc 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -9,41 +9,46 @@ import helmet from 'helmet'; async function bootstrap() { const app = await NestFactory.create(AppModule); - + // Serve static uploads folder app.useStaticAssets(join(process.cwd(), 'uploads'), { prefix: '/uploads/', }); - - app.use(helmet({ - contentSecurityPolicy: false, // Avoid blocking Swagger UI scripts and assets - })); + + app.use( + helmet({ + contentSecurityPolicy: false, // Avoid blocking Swagger UI scripts and assets + }), + ); app.setGlobalPrefix('api'); - + app.enableCors({ origin: true, credentials: true, }); - - app.useGlobalPipes(new ValidationPipe({ - transform: true, - whitelist: true, - forbidNonWhitelisted: true, - })); + + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + whitelist: true, + forbidNonWhitelisted: true, + }), + ); app.useGlobalFilters(new HttpExceptionFilter()); const config = new DocumentBuilder() .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') .addBearerAuth() .build(); - + const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api/docs', app, document); await app.listen(process.env.PORT ?? 4001); } bootstrap(); - diff --git a/backend/src/orders/dto/create-order.dto.ts b/backend/src/orders/dto/create-order.dto.ts index 55e8837..17ef40f 100644 --- a/backend/src/orders/dto/create-order.dto.ts +++ b/backend/src/orders/dto/create-order.dto.ts @@ -1,5 +1,12 @@ 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'; class OrderItemDto { @@ -25,12 +32,16 @@ export class CreateOrderDto { @IsString() couponCode?: string; - @ApiPropertyOptional({ description: 'آدرس/شناسه تصویر نسخه پزشکی (برای داروهای نیازمند نسخه)' }) + @ApiPropertyOptional({ + description: 'آدرس/شناسه تصویر نسخه پزشکی (برای داروهای نیازمند نسخه)', + }) @IsOptional() @IsString() prescriptionUrl?: string; - @ApiPropertyOptional({ description: 'مبلغ کمک به پناهگاه حیوانات (ردپای مهربانی)' }) + @ApiPropertyOptional({ + description: 'مبلغ کمک به پناهگاه حیوانات (ردپای مهربانی)', + }) @IsOptional() @IsNumber() charityDonation?: number; diff --git a/backend/src/orders/orders.controller.spec.ts b/backend/src/orders/orders.controller.spec.ts index 6d179a0..00021c9 100644 --- a/backend/src/orders/orders.controller.spec.ts +++ b/backend/src/orders/orders.controller.spec.ts @@ -8,16 +8,16 @@ describe('OrdersController', () => { const mockOrdersService = { 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 }), }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [OrdersController], - providers: [ - { provide: OrdersService, useValue: mockOrdersService }, - ], + providers: [{ provide: OrdersService, useValue: mockOrdersService }], }).compile(); controller = module.get(OrdersController); diff --git a/backend/src/orders/orders.controller.ts b/backend/src/orders/orders.controller.ts index 05eda84..02079a5 100644 --- a/backend/src/orders/orders.controller.ts +++ b/backend/src/orders/orders.controller.ts @@ -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 { CreateOrderDto } from './dto/create-order.dto'; 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 { IsString, IsNumber, IsNotEmpty } from 'class-validator'; @@ -27,9 +48,9 @@ class ValidateCouponDto { success: false, message: 'Unauthorized', code: 'UNAUTHORIZED', - details: {} - } - } + details: {}, + }, + }, }) export class OrdersController { constructor(private readonly ordersService: OrdersService) {} @@ -47,9 +68,9 @@ export class OrdersController { charityDonation: '10000.00', status: 'processing', trackingNumber: null, - createdAt: '2026-05-26T18:10:00.000Z' - } - } + createdAt: '2026-05-26T18:10:00.000Z', + }, + }, }) @ApiBadRequestResponse({ description: 'اعتبارسنجی اقلام سبد خرید با خطا مواجه شد', @@ -58,9 +79,9 @@ export class OrdersController { success: false, message: 'سبد خرید نمی‌تواند خالی باشد', code: 'BAD_REQUEST', - details: {} - } - } + details: {}, + }, + }, }) create(@Req() req: any, @Body() createOrderDto: CreateOrderDto) { return this.ordersService.create(req.user.id, createOrderDto); @@ -77,13 +98,19 @@ export class OrdersController { code: 'CANINO10', type: 'percent', discountValue: 175000, - message: 'کد تخفیف اعمال شد — ۱۷۵,۰۰۰ تومان تخفیف' - } - } + message: 'کد تخفیف اعمال شد — ۱۷۵,۰۰۰ تومان تخفیف', + }, + }, + }) + @ApiBadRequestResponse({ + description: 'کد تخفیف نامعتبر، منقضی، یا شرایط آن برقرار نیست', }) - @ApiBadRequestResponse({ description: 'کد تخفیف نامعتبر، منقضی، یا شرایط آن برقرار نیست' }) 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() @@ -99,17 +126,17 @@ export class OrdersController { totalAmount: '3500000.00', charityDonation: '10000.00', status: 'processing', - createdAt: '2026-05-26T18:10:00.000Z' - } + createdAt: '2026-05-26T18:10:00.000Z', + }, ], meta: { total: 1, page: 1, lastPage: 1, - limit: 10 - } - } - } + limit: 10, + }, + }, + }, }) findAll(@Req() req: any, @Query() query: PaginationDto) { return this.ordersService.findAllByUser(req.user.id, query); @@ -136,12 +163,12 @@ export class OrdersController { id: 'a1b2c3d4-1234-5678-abcd-ef1234567890', name: 'Canhydrox GAG (کنهیدروکس)', priceDisplay: '۱,۷۵۰,۰۰۰ تومان', - imageUrl: 'https://example.com/canhydrox.png' - } - } - ] - } - } + imageUrl: 'https://example.com/canhydrox.png', + }, + }, + ], + }, + }, }) @ApiNotFoundResponse({ description: 'سفارش یافت نشد یا متعلق به کاربر فعلی نیست', @@ -150,9 +177,9 @@ export class OrdersController { success: false, message: 'Order not found', code: 'NOT_FOUND', - details: {} - } - } + details: {}, + }, + }, }) findOne(@Req() req: any, @Param('id') id: string) { return this.ordersService.findOne(id, req.user.id); diff --git a/backend/src/orders/orders.service.spec.ts b/backend/src/orders/orders.service.spec.ts index 08ea57a..00513d7 100644 --- a/backend/src/orders/orders.service.spec.ts +++ b/backend/src/orders/orders.service.spec.ts @@ -43,23 +43,32 @@ describe('OrdersService', () => { it('should throw NotFoundException if product does not exist', async () => { mockPrisma.product.findUnique.mockResolvedValue(null); const dto = { items: [{ productId: 'invalid-prod', quantity: 2 }] }; - await expect(service.create('user-id', dto)).rejects.toThrow(NotFoundException); + await expect(service.create('user-id', dto)).rejects.toThrow( + NotFoundException, + ); }); it('should throw BadRequestException if items are empty', async () => { const dto = { items: [] }; - await expect(service.create('user-id', dto)).rejects.toThrow(BadRequestException); + await expect(service.create('user-id', dto)).rejects.toThrow( + BadRequestException, + ); }); it('should successfully create order and sum amounts', async () => { const prod = { id: 'prod-1', priceValue: 1000 }; mockPrisma.product.findUnique.mockResolvedValue(prod); - mockPrisma.order.create.mockResolvedValue({ id: 'order-1', totalAmount: 2000 }); + mockPrisma.order.create.mockResolvedValue({ + id: 'order-1', + totalAmount: 2000, + }); const dto = { items: [{ productId: 'prod-1', quantity: 2 }] }; 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({ data: { userId: 'user-id', @@ -92,7 +101,9 @@ describe('OrdersService', () => { describe('findOne', () => { it('should throw NotFoundException if order does not exist', async () => { 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 () => { diff --git a/backend/src/orders/orders.service.ts b/backend/src/orders/orders.service.ts index d34e5d2..2b5a59a 100644 --- a/backend/src/orders/orders.service.ts +++ b/backend/src/orders/orders.service.ts @@ -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 { PaginationDto } from '../common/dto/pagination.dto'; import { CreateOrderDto } from './dto/create-order.dto'; @@ -22,28 +26,43 @@ export class OrdersService { }); 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) { - throw new BadRequestException({ message: 'کد تخفیف منقضی شده است', error: 'COUPON_EXPIRED' }); + throw new BadRequestException({ + message: 'کد تخفیف منقضی شده است', + error: 'COUPON_EXPIRED', + }); } 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)) { throw new BadRequestException({ message: `حداقل مبلغ سبد خرید برای استفاده از این کد ${Number(coupon.minCartValue).toLocaleString('fa-IR')} تومان است`, - error: 'COUPON_MIN_CART' + error: 'COUPON_MIN_CART', }); } // Check user-specific targets - const userTargets = coupon.targets.filter(t => t.targetType === 'USER'); - if (userTargets.length > 0 && !userTargets.some(t => t.targetId === userId)) { - throw new BadRequestException({ message: 'این کد تخفیف برای حساب شما معتبر نیست', error: 'COUPON_NOT_FOR_USER' }); + const userTargets = coupon.targets.filter((t) => t.targetType === 'USER'); + if ( + userTargets.length > 0 && + !userTargets.some((t) => t.targetId === userId) + ) { + throw new BadRequestException({ + message: 'این کد تخفیف برای حساب شما معتبر نیست', + error: 'COUPON_NOT_FOR_USER', + }); } let discountValue: number; @@ -74,9 +93,13 @@ export class OrdersService { const orderItems = []; 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) { - throw new NotFoundException(`محصول با شناسه ${item.productId} یافت نشد`); + throw new NotFoundException( + `محصول با شناسه ${item.productId} یافت نشد`, + ); } totalAmount += Number(product.priceValue) * item.quantity; orderItems.push({ @@ -93,7 +116,11 @@ export class OrdersService { let discountAmount = 0; if (createOrderDto.couponCode) { try { - const couponResult = await this.validateCoupon(createOrderDto.couponCode, totalAmount, userId); + const couponResult = await this.validateCoupon( + createOrderDto.couponCode, + totalAmount, + userId, + ); discountAmount = couponResult.discountValue; couponId = couponResult.couponId; // Increment usedCount @@ -107,7 +134,10 @@ export class OrdersService { } 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(); // Deduct user wallet balance if payment method is wallet @@ -118,13 +148,15 @@ export class OrdersService { } const userBalance = Number(user.walletBalance || 0); if (userBalance < finalAmount) { - throw new BadRequestException('موجودی کیف پول برای پرداخت این سفارش کافی نیست'); + throw new BadRequestException( + 'موجودی کیف پول برای پرداخت این سفارش کافی نیست', + ); } await this.prisma.user.update({ where: { id: userId }, data: { - walletBalance: { decrement: finalAmount } - } + walletBalance: { decrement: finalAmount }, + }, }); await this.prisma.walletTransaction.create({ data: { @@ -132,8 +164,8 @@ export class OrdersService { amount: finalAmount, type: 'withdrawal', status: 'completed', - description: `پرداخت سفارش ${trackingNumber}` - } + description: `پرداخت سفارش ${trackingNumber}`, + }, }); } @@ -142,7 +174,7 @@ export class OrdersService { try { await this.prisma.user.update({ where: { id: userId }, - data: { charityDonationTotal: { increment: charityAmount } } + data: { charityDonationTotal: { increment: charityAmount } }, }); } catch { // Ignore if user not found or guest @@ -165,14 +197,19 @@ export class OrdersService { } as any, include: { orderItems: { - include: { product: true } - } - } + include: { product: true }, + }, + }, }); } 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 [data, total] = await Promise.all([ @@ -185,7 +222,7 @@ export class OrdersService { orderItems: { include: { product: true } }, }, }), - this.prisma.order.count({ where: { userId } }) + this.prisma.order.count({ where: { userId } }), ]); return { @@ -195,7 +232,7 @@ export class OrdersService { page, lastPage: Math.ceil(total / limit), limit, - } + }, }; } diff --git a/backend/src/pets/dto/create-health-log.dto.ts b/backend/src/pets/dto/create-health-log.dto.ts index 60eb8ad..118b96e 100644 --- a/backend/src/pets/dto/create-health-log.dto.ts +++ b/backend/src/pets/dto/create-health-log.dto.ts @@ -17,7 +17,10 @@ export class CreateHealthLogDto { @IsString() digestion: string; - @ApiPropertyOptional({ description: 'یادداشت یا توضیح اضافی', example: 'امروز فعالیت خوبی داشت.' }) + @ApiPropertyOptional({ + description: 'یادداشت یا توضیح اضافی', + example: 'امروز فعالیت خوبی داشت.', + }) @IsOptional() @IsString() note?: string; diff --git a/backend/src/pets/dto/create-pet.dto.ts b/backend/src/pets/dto/create-pet.dto.ts index 87c0399..070f8b1 100644 --- a/backend/src/pets/dto/create-pet.dto.ts +++ b/backend/src/pets/dto/create-pet.dto.ts @@ -1,5 +1,11 @@ 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 { @ApiProperty({ description: 'نام حیوان خانگی', example: 'بادی' }) diff --git a/backend/src/pets/dto/create-reminder.dto.ts b/backend/src/pets/dto/create-reminder.dto.ts index 86077e2..1d3c7d4 100644 --- a/backend/src/pets/dto/create-reminder.dto.ts +++ b/backend/src/pets/dto/create-reminder.dto.ts @@ -12,12 +12,18 @@ export class CreateReminderDto { @IsString() time: string; - @ApiProperty({ description: 'دوره زمانی (روزانه / هفتگی)', example: 'روزانه' }) + @ApiProperty({ + description: 'دوره زمانی (روزانه / هفتگی)', + example: 'روزانه', + }) @IsNotEmpty() @IsString() frequency: string; - @ApiPropertyOptional({ description: 'شناسه محصول مربوطه', example: 'a1b2c3d4-1234-5678-abcd-ef1234567890' }) + @ApiPropertyOptional({ + description: 'شناسه محصول مربوطه', + example: 'a1b2c3d4-1234-5678-abcd-ef1234567890', + }) @IsOptional() @IsString() productId?: string; diff --git a/backend/src/pets/pets.controller.spec.ts b/backend/src/pets/pets.controller.spec.ts index fd8e28a..c9dd162 100644 --- a/backend/src/pets/pets.controller.spec.ts +++ b/backend/src/pets/pets.controller.spec.ts @@ -8,7 +8,9 @@ describe('PetsController', () => { const mockPetsService = { 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' }), update: jest.fn().mockResolvedValue({ id: 'pet-id', name: 'Buddy New' }), remove: jest.fn().mockResolvedValue({ success: true }), @@ -17,9 +19,7 @@ describe('PetsController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [PetsController], - providers: [ - { provide: PetsService, useValue: mockPetsService }, - ], + providers: [{ provide: PetsService, useValue: mockPetsService }], }).compile(); controller = module.get(PetsController); diff --git a/backend/src/pets/pets.controller.ts b/backend/src/pets/pets.controller.ts index fb514b8..b6cc7cf 100644 --- a/backend/src/pets/pets.controller.ts +++ b/backend/src/pets/pets.controller.ts @@ -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 { CreatePetDto } from './dto/create-pet.dto'; import { UpdatePetDto } from './dto/update-pet.dto'; import { CreateReminderDto } from './dto/create-reminder.dto'; import { CreateHealthLogDto } from './dto/create-health-log.dto'; 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'; @ApiTags('Pets - مدیریت حیوانات خانگی') @@ -20,9 +41,9 @@ import { PaginationDto } from '../common/dto/pagination.dto'; success: false, message: 'Unauthorized', code: 'UNAUTHORIZED', - details: {} - } - } + details: {}, + }, + }, }) export class PetsController { constructor(private readonly petsService: PetsService) {} @@ -42,9 +63,9 @@ export class PetsController { weight: '25.50', activityLevel: 'متوسط', imageUrl: null, - createdAt: '2026-05-26T18:10:00.000Z' - } - } + createdAt: '2026-05-26T18:10:00.000Z', + }, + }, }) @ApiBadRequestResponse({ description: 'خطا در صحت‌سنجی فیلدهای ورودی', @@ -53,9 +74,9 @@ export class PetsController { success: false, message: 'نوع حیوان خانگی اجباری است', code: 'BAD_REQUEST', - details: {} - } - } + details: {}, + }, + }, }) create(@Req() req: any, @Body() createPetDto: CreatePetDto) { return this.petsService.create(req.user.id, createPetDto); @@ -78,17 +99,17 @@ export class PetsController { weight: '25.50', activityLevel: 'متوسط', imageUrl: null, - createdAt: '2026-05-26T18:10:00.000Z' - } + createdAt: '2026-05-26T18:10:00.000Z', + }, ], meta: { total: 1, page: 1, lastPage: 1, - limit: 10 - } - } - } + limit: 10, + }, + }, + }, }) findAll(@Req() req: any, @Query() query: PaginationDto) { return this.petsService.findAllByUser(req.user.id, query); @@ -112,9 +133,9 @@ export class PetsController { medicalConditions: [], reminders: [], healthLogs: [], - createdAt: '2026-05-26T18:10:00.000Z' - } - } + createdAt: '2026-05-26T18:10:00.000Z', + }, + }, }) @ApiNotFoundResponse({ description: 'حیوان خانگی پیدا نشد یا متعلق به کاربر جاری نیست', @@ -123,9 +144,9 @@ export class PetsController { success: false, message: 'Pet not found or unauthorized', code: 'NOT_FOUND', - details: {} - } - } + details: {}, + }, + }, }) findOne(@Req() req: any, @Param('id') id: string) { return this.petsService.findOne(id, req.user.id); @@ -143,9 +164,9 @@ export class PetsController { breed: 'ژرمن شپرد', age: 4, weight: '26.00', - activityLevel: 'زیاد' - } - } + activityLevel: 'زیاد', + }, + }, }) @ApiNotFoundResponse({ description: 'حیوان خانگی یافت نشد', @@ -154,11 +175,15 @@ export class PetsController { success: false, message: 'Pet 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); } @@ -169,9 +194,9 @@ export class PetsController { schema: { example: { success: true, - message: 'حیوان خانگی با موفقیت حذف شد' - } - } + message: 'حیوان خانگی با موفقیت حذف شد', + }, + }, }) @ApiNotFoundResponse({ description: 'حیوان خانگی یافت نشد', @@ -180,9 +205,9 @@ export class PetsController { success: false, message: 'Pet not found', code: 'NOT_FOUND', - details: {} - } - } + details: {}, + }, + }, }) remove(@Req() req: any, @Param('id') id: string) { return this.petsService.remove(id, req.user.id); @@ -206,7 +231,12 @@ export class PetsController { @Param('reminderId') reminderId: 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') @@ -216,6 +246,10 @@ export class PetsController { @Param('petId') petId: string, @Body() createHealthLogDto: CreateHealthLogDto, ) { - return this.petsService.addHealthLog(req.user.id, petId, createHealthLogDto); + return this.petsService.addHealthLog( + req.user.id, + petId, + createHealthLogDto, + ); } } diff --git a/backend/src/pets/pets.service.spec.ts b/backend/src/pets/pets.service.spec.ts index 7701fe6..c70b3dc 100644 --- a/backend/src/pets/pets.service.spec.ts +++ b/backend/src/pets/pets.service.spec.ts @@ -58,7 +58,9 @@ describe('PetsService', () => { describe('findOne', () => { it('should throw NotFoundException if pet not found', async () => { 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 () => { diff --git a/backend/src/pets/pets.service.ts b/backend/src/pets/pets.service.ts index 126e255..ecc22d5 100644 --- a/backend/src/pets/pets.service.ts +++ b/backend/src/pets/pets.service.ts @@ -9,7 +9,8 @@ export class PetsService { constructor(private prisma: PrismaService) {} 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({ data: { @@ -20,16 +21,24 @@ export class PetsService { age: age || 1, weight: weight || 0, userId, - medicalConditions: medicalConditions?.length ? { - create: medicalConditions.map(condition => ({ condition })) - } : undefined, + medicalConditions: medicalConditions?.length + ? { + create: medicalConditions.map((condition) => ({ condition })), + } + : undefined, }, include: { medicalConditions: true, reminders: true, healthLogs: true }, }); } 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 }; if (search) { @@ -48,7 +57,7 @@ export class PetsService { take: limit, orderBy: { [sortBy]: sortOrder }, }), - this.prisma.pet.count({ where: whereClause }) + this.prisma.pet.count({ where: whereClause }), ]); return { @@ -58,7 +67,7 @@ export class PetsService { page, lastPage: Math.ceil(total / limit), limit, - } + }, }; } @@ -77,7 +86,9 @@ export class PetsService { await this.findOne(id, userId); // Ensure it exists and belongs to user if (updatePetDto.medicalConditions) { - await this.prisma.petMedicalCondition.deleteMany({ where: { petId: id } }); + await this.prisma.petMedicalCondition.deleteMany({ + where: { petId: id }, + }); } return this.prisma.pet.update({ @@ -89,9 +100,13 @@ export class PetsService { weight: updatePetDto.weight, age: updatePetDto.age, activityLevel: updatePetDto.activityLevel, - medicalConditions: updatePetDto.medicalConditions ? { - create: updatePetDto.medicalConditions.map(condition => ({ condition })) - } : undefined, + medicalConditions: updatePetDto.medicalConditions + ? { + create: updatePetDto.medicalConditions.map((condition) => ({ + condition, + })), + } + : undefined, }, 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); const reminder = await this.prisma.reminder.findUnique({ @@ -128,7 +148,13 @@ export class PetsService { } 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({ where: { diff --git a/backend/src/prisma/prisma.service.ts b/backend/src/prisma/prisma.service.ts index bb6565f..7ffd32d 100644 --- a/backend/src/prisma/prisma.service.ts +++ b/backend/src/prisma/prisma.service.ts @@ -2,7 +2,10 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; @Injectable() -export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ async onModuleInit() { await this.$connect(); } diff --git a/backend/src/products/dto/get-products.dto.ts b/backend/src/products/dto/get-products.dto.ts index 8601d21..f268e30 100644 --- a/backend/src/products/dto/get-products.dto.ts +++ b/backend/src/products/dto/get-products.dto.ts @@ -8,7 +8,10 @@ export class GetProductsDto extends PaginationDto { @IsString() category?: string; - @ApiPropertyOptional({ description: 'فیلتر بر اساس نوع حیوان', enum: ['سگ', 'گربه', 'all'] }) + @ApiPropertyOptional({ + description: 'فیلتر بر اساس نوع حیوان', + enum: ['سگ', 'گربه', 'all'], + }) @IsOptional() @IsEnum(['سگ', 'گربه', 'all']) petType?: string; diff --git a/backend/src/products/products.controller.spec.ts b/backend/src/products/products.controller.spec.ts index 51b623b..c83f6cb 100644 --- a/backend/src/products/products.controller.spec.ts +++ b/backend/src/products/products.controller.spec.ts @@ -8,16 +8,16 @@ describe('ProductsController', () => { let service: ProductsService; 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(), }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [ProductsController], - providers: [ - { provide: ProductsService, useValue: mockProductsService }, - ], + providers: [{ provide: ProductsService, useValue: mockProductsService }], }).compile(); controller = module.get(ProductsController); @@ -42,7 +42,9 @@ describe('ProductsController', () => { describe('findOne', () => { it('should throw NotFoundException if product not found', async () => { 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 () => { diff --git a/backend/src/products/products.controller.ts b/backend/src/products/products.controller.ts index cdc503f..e571ba5 100644 --- a/backend/src/products/products.controller.ts +++ b/backend/src/products/products.controller.ts @@ -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 { 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 - مدیریت محصولات دارویی') @Controller('products') @@ -13,9 +26,9 @@ import { ApiTags, ApiOperation, ApiResponse, ApiOkResponse, ApiNotFoundResponse success: false, message: 'خطای داخلی سرور', code: 'SERVER_ERROR', - details: {} - } - } + details: {}, + }, + }, }) export class ProductsController { constructor(private readonly productsService: ProductsService) {} @@ -32,7 +45,8 @@ export class ProductsController { artNo: 'canhydrox-gag', name: 'Canhydrox GAG (کنهیدروکس)', scientificTagline: 'برای تقویت مفاصل و استخوان‌ها', - description: 'کنهیدروکس محصولی بی‌نظیر برای مفاصل و سیستم حرکتی سگ‌ها...', + description: + 'کنهیدروکس محصولی بی‌نظیر برای مفاصل و سیستم حرکتی سگ‌ها...', shortDescription: 'تقویت مفاصل و غضروف‌ها', category: 'سیستم حرکتی و مفاصل', categorySlug: 'joints', @@ -45,40 +59,44 @@ export class ProductsController { imageUrl: 'https://example.com/canhydrox.png', createdAt: '2026-05-26T18:10:00.000Z', ingredients: [], - symptoms: [] - } + symptoms: [], + }, ], meta: { total: 1, page: 1, lastPage: 1, - limit: 10 - } - } - } + limit: 10, + }, + }, + }, }) findAll(@Query() query: GetProductsDto) { return this.productsService.findAll(query); } @Get('filters') - @ApiOperation({ summary: 'دریافت فیلترهای فعال محصولات (دسته، علائم، نوع پت)' }) + @ApiOperation({ + summary: 'دریافت فیلترهای فعال محصولات (دسته، علائم، نوع پت)', + }) @ApiOkResponse({ description: 'لیست فیلترهای پویا استخراج شده از دیتابیس', schema: { example: { categories: [{ id: '1', name: 'مفاصل و استخوان', slug: 'joints' }], symptoms: ['لنگش', 'ریزش مو'], - petTypes: ['سگ', 'گربه', 'هر دو'] - } - } + petTypes: ['سگ', 'گربه', 'هر دو'], + }, + }, }) getActiveFilters() { return this.productsService.getActiveFilters(); } @Get('navigation-filters') - @ApiOperation({ summary: 'دریافت فیلترها و راهکارهای ناوبری (دسته با علائم مربوطه)' }) + @ApiOperation({ + summary: 'دریافت فیلترها و راهکارهای ناوبری (دسته با علائم مربوطه)', + }) @ApiOkResponse({ description: 'ساختار درختی فیلترها و تگ‌های درمانی واقعی متصل به محصولات', schema: { @@ -87,10 +105,10 @@ export class ProductsController { id: '1', name: 'مفاصل و استخوان', slug: 'joints', - symptoms: ['درد مفاصل', 'لنگش'] - } - ] - } + symptoms: ['درد مفاصل', 'لنگش'], + }, + ], + }, }) getNavigationFilters() { return this.productsService.getNavigationFilters(); @@ -119,24 +137,31 @@ export class ProductsController { imageUrl: 'https://example.com/canhydrox.png', createdAt: '2026-05-26T18:10:00.000Z', ingredients: [ - { productId: 'a1b2c3d4-1234-5678-abcd-ef1234567890', ingredient: 'صدف لب‌سبز' } + { + productId: 'a1b2c3d4-1234-5678-abcd-ef1234567890', + ingredient: 'صدف لب‌سبز', + }, ], symptoms: [ - { productId: 'a1b2c3d4-1234-5678-abcd-ef1234567890', symptom: 'لنگیدن' } - ] - } - } + { + productId: 'a1b2c3d4-1234-5678-abcd-ef1234567890', + symptom: 'لنگیدن', + }, + ], + }, + }, }) @ApiNotFoundResponse({ description: 'محصول با شناسه ارسال شده پیدا نشد', schema: { example: { 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', - details: {} - } - } + details: {}, + }, + }, }) async findOne(@Param('id') id: string) { const product = await this.productsService.findOne(id); diff --git a/backend/src/products/products.service.ts b/backend/src/products/products.service.ts index ab9617c..b4d826b 100644 --- a/backend/src/products/products.service.ts +++ b/backend/src/products/products.service.ts @@ -7,7 +7,17 @@ export class ProductsService { constructor(private prisma: PrismaService) {} 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 = {}; @@ -27,8 +37,8 @@ export class ProductsService { if (symptom) { whereClause.symptoms = { some: { - symptom: { contains: symptom, mode: 'insensitive' } - } + symptom: { contains: symptom, mode: 'insensitive' }, + }, }; } @@ -45,9 +55,9 @@ export class ProductsService { { symptoms: { 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 whereClause.AND = [ { symptoms: whereClause.symptoms }, - { OR: searchConditions.filter(c => !('symptoms' in c)) }, + { OR: searchConditions.filter((c) => !('symptoms' in c)) }, ]; delete whereClause.symptoms; } else { @@ -79,8 +89,9 @@ export class ProductsService { this.prisma.product.count({ where: whereClause }), ]); - const isWholesaleOrAdmin = userRole === 'User_Wholesale' || userRole === 'ADMIN'; - const data = rawProducts.map(p => { + const isWholesaleOrAdmin = + userRole === 'User_Wholesale' || userRole === 'ADMIN'; + const data = rawProducts.map((p) => { if (!isWholesaleOrAdmin) { const { wholesalePrice, ...rest } = p; return rest; @@ -100,7 +111,10 @@ export class ProductsService { } 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({ where: isUuid ? { id: idOrSlug } : { slug: idOrSlug }, include: { @@ -110,7 +124,8 @@ export class ProductsService { }); if (!product) return null; - const isWholesaleOrAdmin = userRole === 'User_Wholesale' || userRole === 'ADMIN'; + const isWholesaleOrAdmin = + userRole === 'User_Wholesale' || userRole === 'ADMIN'; if (!isWholesaleOrAdmin) { const { wholesalePrice, ...rest } = product; return rest; @@ -123,33 +138,33 @@ export class ProductsService { this.prisma.category.findMany({ where: { products: { - some: {} - } + some: {}, + }, }, select: { id: true, name: true, slug: true, - } + }, }), this.prisma.productSymptom.findMany({ distinct: ['symptom'], select: { symptom: true, - } + }, }), this.prisma.product.findMany({ distinct: ['suitableFor'], select: { suitableFor: true, - } - }) + }, + }), ]); return { categories, - symptoms: symptomsDb.map(s => s.symptom).filter(Boolean), - petTypes: petTypesDb.map(p => p.suitableFor).filter(Boolean), + symptoms: symptomsDb.map((s) => s.symptom).filter(Boolean), + petTypes: petTypesDb.map((p) => p.suitableFor).filter(Boolean), }; } @@ -157,22 +172,22 @@ export class ProductsService { const categories = await this.prisma.category.findMany({ where: { products: { - some: {} - } + some: {}, + }, }, include: { products: { include: { - symptoms: true - } - } - } + symptoms: true, + }, + }, + }, }); - return categories.map(cat => { + return categories.map((cat) => { const symptomSet = new Set(); - cat.products.forEach(p => { - p.symptoms.forEach(s => { + cat.products.forEach((p) => { + p.symptoms.forEach((s) => { if (s.symptom) { symptomSet.add(s.symptom); } @@ -183,7 +198,7 @@ export class ProductsService { id: cat.id, name: cat.name, slug: cat.slug, - symptoms: Array.from(symptomSet) + symptoms: Array.from(symptomSet), }; }); } diff --git a/backend/src/seo/seo.controller.ts b/backend/src/seo/seo.controller.ts index 3ab91bc..17beca9 100644 --- a/backend/src/seo/seo.controller.ts +++ b/backend/src/seo/seo.controller.ts @@ -14,7 +14,9 @@ export class SeoController { } @Get('product-schema/:idOrSlug') - @ApiOperation({ summary: 'دریافت متادیتای ساختاریافته Schema.org JSON-LD محصول' }) + @ApiOperation({ + summary: 'دریافت متادیتای ساختاریافته Schema.org JSON-LD محصول', + }) async getProductSchema(@Param('idOrSlug') idOrSlug: string) { const schema = await this.seoService.getProductSchema(idOrSlug); if (!schema) { diff --git a/backend/src/seo/seo.service.ts b/backend/src/seo/seo.service.ts index 20c0d50..df6f200 100644 --- a/backend/src/seo/seo.service.ts +++ b/backend/src/seo/seo.service.ts @@ -8,19 +8,36 @@ export class SeoService { async getSitemapUrls() { const [products, categories, blogs] = await Promise.all([ this.prisma.product.findMany({ select: { slug: true, createdAt: true } }), - this.prisma.category.findMany({ select: { slug: true, createdAt: true } }), - this.prisma.blog.findMany({ where: { isPublished: true }, select: { slug: true, updatedAt: true } }), + this.prisma.category.findMany({ + select: { slug: true, createdAt: true }, + }), + this.prisma.blog.findMany({ + where: { isPublished: true }, + select: { slug: true, updatedAt: true }, + }), ]); return { - products: products.map(p => ({ url: `/shop/${p.slug}`, 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 })), + products: products.map((p) => ({ + url: `/shop/${p.slug}`, + 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) { - 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({ where: isUuid ? { id: idOrSlug } : { slug: idOrSlug }, }); @@ -28,26 +45,26 @@ export class SeoService { if (!product) return null; return { - "@context": "https://schema.org/", - "@type": "Product", - "name": product.nameFa, - "alternateName": product.nameEn, - "image": [product.imageUrl], - "description": product.description, - "sku": product.artNo, - "gtin": product.barcode || undefined, - "brand": { - "@type": "Brand", - "name": "Canina Pharma" + '@context': 'https://schema.org/', + '@type': 'Product', + name: product.nameFa, + alternateName: product.nameEn, + image: [product.imageUrl], + description: product.description, + sku: product.artNo, + gtin: product.barcode || undefined, + brand: { + '@type': 'Brand', + 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" - } }; } } diff --git a/backend/src/settings/settings.controller.spec.ts b/backend/src/settings/settings.controller.spec.ts index ecae052..acc6681 100644 --- a/backend/src/settings/settings.controller.spec.ts +++ b/backend/src/settings/settings.controller.spec.ts @@ -17,9 +17,7 @@ describe('SettingsController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [SettingsController], - providers: [ - { provide: SettingsService, useValue: mockSettingsService }, - ], + providers: [{ provide: SettingsService, useValue: mockSettingsService }], }).compile(); controller = module.get(SettingsController); diff --git a/backend/src/settings/settings.controller.ts b/backend/src/settings/settings.controller.ts index ad33aed..259ac20 100644 --- a/backend/src/settings/settings.controller.ts +++ b/backend/src/settings/settings.controller.ts @@ -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 { 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 - تنظیمات متون پویا و واژه‌نامه علمی') @Controller('settings') @@ -11,14 +28,15 @@ export class SettingsController { @Get('ui-texts') @ApiOperation({ summary: 'دریافت تمامی متون و پیکربندی‌های رابط کاربری' }) @ApiOkResponse({ - description: 'یک آبجکت کلید-مقدار حاوی تمامی متون، شعارها و برچسب‌های دکمه‌ها', + description: + 'یک آبجکت کلید-مقدار حاوی تمامی متون، شعارها و برچسب‌های دکمه‌ها', schema: { example: { - hero_badge: "تخصص دارویی از آلمان", - hero_title: "تخصص آلمانی در خدمت سلامت پت‌های خانگی", - hero_desc: "بیش از ۴۰ سال تجربه نوآورانه..." - } - } + hero_badge: 'تخصص دارویی از آلمان', + hero_title: 'تخصص آلمانی در خدمت سلامت پت‌های خانگی', + hero_desc: 'بیش از ۴۰ سال تجربه نوآورانه...', + }, + }, }) getUiTexts() { return this.settingsService.getUiTexts(); @@ -33,13 +51,19 @@ export class SettingsController { schema: { example: { key: 'hero_badge', - value: 'تخصص دارویی ممتاز از آلمان' - } - } + value: 'تخصص دارویی ممتاز از آلمان', + }, + }, }) @ApiUnauthorizedResponse({ 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) { return this.settingsService.updateUiText(key, value); @@ -55,10 +79,10 @@ export class SettingsController { key: 'green-mussel', term: 'صدف لب‌سبز (Perna Canaliculus)', definition: 'این صدف بومی سواحل بکر نیوزیلند است...', - wikiId: 'general' - } - ] - } + wikiId: 'general', + }, + ], + }, }) getScientificTerms() { return this.settingsService.getScientificTerms(); @@ -75,13 +99,19 @@ export class SettingsController { key: 'green-mussel', term: 'صدف لب‌سبز اصل نیوزیلند', definition: 'این صدف بومی سواحل بکر نیوزیلند است...', - wikiId: 'general' - } - } + wikiId: 'general', + }, + }, }) @ApiUnauthorizedResponse({ 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) { return this.settingsService.upsertScientificTerm(key, data); @@ -96,13 +126,19 @@ export class SettingsController { schema: { example: { success: true, - message: 'Scientific term successfully deleted' - } - } + message: 'Scientific term successfully deleted', + }, + }, }) @ApiUnauthorizedResponse({ description: 'عدم دسترسی', - schema: { example: { success: false, message: 'Unauthorized', code: 'UNAUTHORIZED' } } + schema: { + example: { + success: false, + message: 'Unauthorized', + code: 'UNAUTHORIZED', + }, + }, }) deleteScientificTerm(@Param('key') key: string) { return this.settingsService.deleteScientificTerm(key); diff --git a/backend/src/users/users.controller.spec.ts b/backend/src/users/users.controller.spec.ts index 420752b..ec75987 100644 --- a/backend/src/users/users.controller.spec.ts +++ b/backend/src/users/users.controller.spec.ts @@ -8,9 +8,13 @@ describe('UsersController', () => { const mockUsersService = { 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' }), - updateAddress: jest.fn().mockResolvedValue({ id: 'addr-id', title: 'Work' }), + updateAddress: jest + .fn() + .mockResolvedValue({ id: 'addr-id', title: 'Work' }), deleteAddress: jest.fn().mockResolvedValue({ success: true }), setDefaultAddress: jest.fn().mockResolvedValue({ success: true }), }; @@ -18,9 +22,7 @@ describe('UsersController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [UsersController], - providers: [ - { provide: UsersService, useValue: mockUsersService }, - ], + providers: [{ provide: UsersService, useValue: mockUsersService }], }).compile(); controller = module.get(UsersController); @@ -78,7 +80,11 @@ describe('UsersController', () => { zipCode: '123', }; 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'); }); @@ -92,7 +98,10 @@ describe('UsersController', () => { it('should setDefaultAddress', async () => { const req = { user: { id: 'user-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(); }); }); diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index 1b5844c..d5ee94f 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -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 { 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 { AddressDto } from './dto/address.dto'; @@ -16,9 +36,9 @@ import { AddressDto } from './dto/address.dto'; success: false, message: 'Unauthorized', code: 'UNAUTHORIZED', - details: {} - } - } + details: {}, + }, + }, }) export class UsersController { constructor(private readonly usersService: UsersService) {} @@ -41,9 +61,9 @@ export class UsersController { addresses: [], pets: [], 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) { return this.usersService.findById(req.user.id); @@ -63,9 +83,9 @@ export class UsersController { mobile: '09123456789', role: 'User_PetOwner', walletBalance: '1500000.00', - charityDonationTotal: '25000.00' - } - } + charityDonationTotal: '25000.00', + }, + }, }) @ApiBadRequestResponse({ description: 'خطا در صحت‌سنجی فیلدهای ورودی', @@ -75,10 +95,10 @@ export class UsersController { message: 'ایمیل نامعتبر است', code: 'BAD_REQUEST', details: { - message: ['ایمیل نامعتبر است'] - } - } - } + message: ['ایمیل نامعتبر است'], + }, + }, + }, }) updateProfile(@Req() req: any, @Body() updateProfileDto: UpdateProfileDto) { return this.usersService.update(req.user.id, updateProfileDto); @@ -101,9 +121,9 @@ export class UsersController { detail: 'خیابان آزادی، کوچه مریم، پلاک ۱۰', zipCode: '1456789012', isDefault: false, - createdAt: '2026-05-26T18:00:00.000Z' - } - } + createdAt: '2026-05-26T18:00:00.000Z', + }, + }, }) addAddress(@Req() req: any, @Body() addressDto: AddressDto) { return this.usersService.addAddress(req.user.id, addressDto); @@ -126,9 +146,9 @@ export class UsersController { detail: 'خیابان ولیعصر، برج سپهر، طبقه ۴', zipCode: '1456789012', isDefault: false, - createdAt: '2026-05-26T18:00:00.000Z' - } - } + createdAt: '2026-05-26T18:00:00.000Z', + }, + }, }) updateAddress( @Req() req: any, @@ -146,9 +166,9 @@ export class UsersController { schema: { example: { success: true, - message: 'آدرس با موفقیت حذف شد' - } - } + message: 'آدرس با موفقیت حذف شد', + }, + }, }) deleteAddress(@Req() req: any, @Param('addressId') addressId: string) { return this.usersService.deleteAddress(req.user.id, addressId); @@ -162,9 +182,9 @@ export class UsersController { schema: { example: { success: true, - message: 'آدرس پیش‌فرض با موفقیت تغییر کرد' - } - } + message: 'آدرس پیش‌فرض با موفقیت تغییر کرد', + }, + }, }) setDefaultAddress(@Req() req: any, @Param('addressId') addressId: string) { return this.usersService.setDefaultAddress(req.user.id, addressId); @@ -183,9 +203,9 @@ export class UsersController { type: 'deposit', status: 'completed', description: 'شارژ کیف پول', - createdAt: '2026-07-11T08:00:00.000Z' - } - } + createdAt: '2026-07-11T08:00:00.000Z', + }, + }, }) @ApiBadRequestResponse({ description: 'مبلغ نامعتبر است' }) async topUpWallet(@Req() req: any, @Body() body: { amount: number }) { diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts index 20c9e39..5d52b32 100644 --- a/backend/src/users/users.service.spec.ts +++ b/backend/src/users/users.service.spec.ts @@ -57,8 +57,11 @@ describe('UsersService', () => { it('should addAddress', async () => { 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); expect(prisma.userAddress.updateMany).toHaveBeenCalledWith({ where: { userId: 'user-id' }, @@ -70,9 +73,16 @@ describe('UsersService', () => { it('should updateAddress', async () => { 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.update).toHaveBeenCalled(); expect(result.title).toBe('Work'); @@ -86,7 +96,10 @@ describe('UsersService', () => { }); 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'); expect(prisma.userAddress.updateMany).toHaveBeenCalledWith({ where: { userId: 'user-id' }, diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 76edd5c..df67101 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -8,32 +8,32 @@ export class UsersService { async findById(id: string) { return this.prisma.user.findUnique({ where: { id }, - include: { + include: { pets: { include: { medicalConditions: true, reminders: { include: { - completions: true - } + completions: true, + }, }, healthLogs: true, - } - }, + }, + }, orders: { orderBy: { createdAt: 'desc' }, include: { orderItems: { - include: { product: true } - } - } + include: { product: true }, + }, + }, }, addresses: true, walletTransactions: { orderBy: { createdAt: 'desc' }, - take: 50 - } - } + take: 50, + }, + }, }); } @@ -46,13 +46,13 @@ export class UsersService { orders: { include: { orderItems: { - include: { product: true } - } - } + include: { product: true }, + }, + }, }, addresses: true, - walletTransactions: true - } + walletTransactions: true, + }, }); } diff --git a/backend/src/videos/dto/create-video.dto.ts b/backend/src/videos/dto/create-video.dto.ts index ccad3d5..9de7c3d 100644 --- a/backend/src/videos/dto/create-video.dto.ts +++ b/backend/src/videos/dto/create-video.dto.ts @@ -2,12 +2,18 @@ import { IsString, IsNotEmpty, IsOptional, IsBoolean } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class CreateVideoDto { - @ApiProperty({ description: 'عنوان ویدئوی آموزشی', example: 'نحوه آماده‌سازی کانی‌هیدروکس GAG' }) + @ApiProperty({ + description: 'عنوان ویدئوی آموزشی', + example: 'نحوه آماده‌سازی کانی‌هیدروکس GAG', + }) @IsString() @IsNotEmpty() title: string; - @ApiProperty({ description: 'نام دکتر/ارائه‌دهنده', example: 'دکتر کلاوس هنینگ' }) + @ApiProperty({ + description: 'نام دکتر/ارائه‌دهنده', + example: 'دکتر کلاوس هنینگ', + }) @IsString() @IsNotEmpty() doctor: string; @@ -17,12 +23,18 @@ export class CreateVideoDto { @IsOptional() duration?: string; - @ApiPropertyOptional({ description: 'آدرس تصویر کاور', example: 'https://example.com/thumb.jpg' }) + @ApiPropertyOptional({ + description: 'آدرس تصویر کاور', + example: 'https://example.com/thumb.jpg', + }) @IsString() @IsOptional() thumbnail?: string; - @ApiProperty({ description: 'آدرس فایل ویدئو', example: 'https://example.com/video.mp4' }) + @ApiProperty({ + description: 'آدرس فایل ویدئو', + example: 'https://example.com/video.mp4', + }) @IsString() @IsNotEmpty() videoUrl: string; @@ -32,7 +44,10 @@ export class CreateVideoDto { @IsOptional() description?: string; - @ApiPropertyOptional({ description: 'آیا ویدئوی ویژه/صفحه اصلی است؟', example: true }) + @ApiPropertyOptional({ + description: 'آیا ویدئوی ویژه/صفحه اصلی است؟', + example: true, + }) @IsBoolean() @IsOptional() isFeatured?: boolean; diff --git a/backend/src/videos/videos.controller.ts b/backend/src/videos/videos.controller.ts index aa33e06..422d195 100644 --- a/backend/src/videos/videos.controller.ts +++ b/backend/src/videos/videos.controller.ts @@ -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 { CreateVideoDto } from './dto/create-video.dto'; 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 - مشاوره ویدئویی و آکادمی') @Controller('videos') @@ -37,7 +52,10 @@ export class VideosController { @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'ویرایش ویدئو (ادمین)' }) - update(@Param('id') id: string, @Body() updateVideoDto: Partial) { + update( + @Param('id') id: string, + @Body() updateVideoDto: Partial, + ) { return this.videosService.update(id, updateVideoDto); } diff --git a/backend/src/videos/videos.service.ts b/backend/src/videos/videos.service.ts index 8babfb8..de53004 100644 --- a/backend/src/videos/videos.service.ts +++ b/backend/src/videos/videos.service.ts @@ -56,10 +56,12 @@ export class VideosService { throw new NotFoundException('ویدئوی مورد نظر یافت نشد'); } if (incrementView) { - await this.video.update({ - where: { id }, - data: { viewsCount: { increment: 1 } }, - }).catch(() => {}); + await this.video + .update({ + where: { id }, + data: { viewsCount: { increment: 1 } }, + }) + .catch(() => {}); } return video; } diff --git a/backend/src/wholesale/wholesale.controller.ts b/backend/src/wholesale/wholesale.controller.ts index 63c7bff..cc971ca 100644 --- a/backend/src/wholesale/wholesale.controller.ts +++ b/backend/src/wholesale/wholesale.controller.ts @@ -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 { WholesaleApplyDto } from './dto/wholesale-apply.dto'; import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; @@ -14,7 +23,9 @@ export class WholesaleController { @Post('apply') @UseGuards(JwtAuthGuard) @ApiBearerAuth() - @ApiOperation({ summary: 'ثبت درخواست همکاری عمده‌فروشی (ارسال پروانه کلینیک/داروخانه)' }) + @ApiOperation({ + summary: 'ثبت درخواست همکاری عمده‌فروشی (ارسال پروانه کلینیک/داروخانه)', + }) applyForWholesale(@Request() req: any, @Body() dto: WholesaleApplyDto) { return this.wholesaleService.applyForWholesale(req.user.id, dto); } @@ -23,7 +34,9 @@ export class WholesaleController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles('ADMIN') @ApiBearerAuth() - @ApiOperation({ summary: 'لیست تمام درخواست‌های همکاری عمده‌فروشی (مخصوص ادمین)' }) + @ApiOperation({ + summary: 'لیست تمام درخواست‌های همکاری عمده‌فروشی (مخصوص ادمین)', + }) getWholesaleRequests() { return this.wholesaleService.getWholesaleRequests(); } @@ -32,7 +45,9 @@ export class WholesaleController { @UseGuards(JwtAuthGuard, RolesGuard) @Roles('ADMIN') @ApiBearerAuth() - @ApiOperation({ summary: 'تایید درخواست و ارتقا به خریدار عمده (User_Wholesale)' }) + @ApiOperation({ + summary: 'تایید درخواست و ارتقا به خریدار عمده (User_Wholesale)', + }) approveWholesaleRequest(@Param('userId') userId: string) { return this.wholesaleService.approveWholesaleRequest(userId); } diff --git a/backend/src/wholesale/wholesale.service.ts b/backend/src/wholesale/wholesale.service.ts index 3feb43b..5009796 100644 --- a/backend/src/wholesale/wholesale.service.ts +++ b/backend/src/wholesale/wholesale.service.ts @@ -20,7 +20,8 @@ export class WholesaleService { return { success: true, - message: 'درخواست همکاری عمده‌فروشی با موفقیت ثبت شد و در حال بررسی توسط ادمین است.', + message: + 'درخواست همکاری عمده‌فروشی با موفقیت ثبت شد و در حال بررسی توسط ادمین است.', user: { id: updatedUser.id, role: updatedUser.role, @@ -47,7 +48,8 @@ export class WholesaleService { return { success: true, - message: 'حساب کاربری با موفقیت به خریدار عمده (User_Wholesale) ارتقا یافت.', + message: + 'حساب کاربری با موفقیت به خریدار عمده (User_Wholesale) ارتقا یافت.', user: updated, }; } diff --git a/backend/src/wiki/wiki.controller.ts b/backend/src/wiki/wiki.controller.ts index a66ede4..4cef5a0 100644 --- a/backend/src/wiki/wiki.controller.ts +++ b/backend/src/wiki/wiki.controller.ts @@ -1,13 +1,19 @@ import { Controller, Get, Param, HttpStatus, Query } from '@nestjs/common'; 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'; @ApiTags('Wiki - دانشنامه ترکیبات') @Controller('wiki') @ApiResponse({ status: HttpStatus.INTERNAL_SERVER_ERROR, - description: 'خطای داخلی سرور' + description: 'خطای داخلی سرور', }) export class WikiController { constructor(private readonly wikiService: WikiService) {} diff --git a/backend/src/wiki/wiki.service.ts b/backend/src/wiki/wiki.service.ts index 13fbca0..0d7ec18 100644 --- a/backend/src/wiki/wiki.service.ts +++ b/backend/src/wiki/wiki.service.ts @@ -7,10 +7,16 @@ export class WikiService { constructor(private prisma: PrismaService) {} 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 sortBy = allowedSortFields.includes(rawSortBy) ? rawSortBy : 'term'; - + const whereClause: any = {}; if (search) { whereClause.OR = [ @@ -28,7 +34,7 @@ export class WikiService { take: limit, orderBy: { [sortBy]: sortOrder }, }), - this.prisma.scientificTerm.count({ where: whereClause }) + this.prisma.scientificTerm.count({ where: whereClause }), ]); return { @@ -38,13 +44,13 @@ export class WikiService { page, lastPage: Math.ceil(total / limit), limit, - } + }, }; } async findOneByKey(key: string) { const term = await this.prisma.scientificTerm.findUnique({ - where: { key } + where: { key }, }); if (!term) throw new NotFoundException('Wiki term not found'); return term; From 84d9ed3a9dab2267d66e423db5549a2cde62723e Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Wed, 29 Jul 2026 15:48:26 +0330 Subject: [PATCH 18/19] perf(docker): replace slow RUN chown -R with fast COPY --chown in backend Dockerfile --- backend/Dockerfile | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 7534bdc..7ba8d58 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -9,14 +9,13 @@ RUN npx prisma generate && npm run build FROM node:20-alpine RUN mkdir -p /app/uploads && chown node:node /app/uploads WORKDIR /app -COPY --from=builder /app/package*.json ./ -COPY --from=builder /app/tsconfig.json ./tsconfig.json -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/dist ./dist -COPY --from=builder /app/prisma ./prisma -COPY --from=builder /app/prisma/tsconfig.seed.json ./prisma/tsconfig.seed.json -COPY --from=builder /app/prisma/scientificTerms.ts ./prisma/scientificTerms.ts -RUN chown -R node:node /app +COPY --chown=node:node --from=builder /app/package*.json ./ +COPY --chown=node:node --from=builder /app/tsconfig.json ./tsconfig.json +COPY --chown=node:node --from=builder /app/node_modules ./node_modules +COPY --chown=node:node --from=builder /app/dist ./dist +COPY --chown=node:node --from=builder /app/prisma ./prisma +COPY --chown=node:node --from=builder /app/prisma/tsconfig.seed.json ./prisma/tsconfig.seed.json +COPY --chown=node:node --from=builder /app/prisma/scientificTerms.ts ./prisma/scientificTerms.ts ENV PRISMA_CLI_BINARY_TARGETS=linux-musl-openssl-3.0.x EXPOSE 3000 USER node From bf60df34efdcbcb38d46e648adfc628ae40208e6 Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Wed, 29 Jul 2026 15:49:39 +0330 Subject: [PATCH 19/19] fix(seed): safely handle blog upsert in try-catch block --- backend/prisma/seed-products.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/backend/prisma/seed-products.ts b/backend/prisma/seed-products.ts index c0f52a1..c03d11f 100644 --- a/backend/prisma/seed-products.ts +++ b/backend/prisma/seed-products.ts @@ -499,11 +499,20 @@ export async function main() { ]; for (const b of blogs) { - await prisma.blog.upsert({ - where: { slug: b.slug }, - update: {}, - create: b, - }); + try { + await prisma.blog.upsert({ + where: { slug: b.slug }, + 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.'); }