@@ -139,21 +139,21 @@
تنظیمات تب «اتصال» را ذخیره کنید، بعد «دریافت گروهها از حسابداری» را بزنید تا فهرستها از حسابداری پر شوند.
- گروههای حسابی که مشتری {{ brand.name }} حساب میشوند: در حسابداری صندوق، کارمند و سرمایهگذار هم «حساب» هستند. فقط گروههایی را انتخاب کنید که
- واقعاً مشتریاند؛ سینک مشتری فقط حسابهای این گروهها را با شماره موبایل به کاربران اپ مشتری وصل میکند. اگر خالی بماند سینک مشتری اجرا نمیشود.
+ کدام گروههای حسابداری مشتریاند؟ اینها گروههای خودِ نرمافزار حسابداریاند (نه {{ brand.name }}). صندوق، کارمند و سرمایهگذار را نزنید؛
+ فقط گروههای واقعی مشتری. سینک فقط همینها را با موبایل به کاربران اپ وصل میکند. خالی = سینک مشتری اجرا نمیشود.
- گروه مشتری جدید: وقتی کاربری در اپ مشتری احراز هویت شود و در حسابداری حساب نداشته باشد، حسابش در این گروه ساخته میشود.
- اگر تعیین نشود، ساخت حساب در «لاگ ارسال» با وضعیت «نیاز به بررسی» میماند.
+ حساب جدید اپ در کدام گروه ساخته شود؟ اگر کسی در اپ مشتری احراز هویت کند و در حسابداری هنوز حساب نداشته باشد، حسابش اینجا ساخته میشود.
+ خالی = ساخت حساب در «لاگ ارسال» میماند تا خودتان تعیین کنید.
عیار پایه: عیار وزنهایی که در {{ brand.name }} ثبت میشود (وزن محصولها و سفارشها؛ معمولاً ۷۵۰ = ۱۸ عیار). در سند سفارش، وزن با همین عیار به
حسابداری میرود؛ در تهحساب اجرت هم روی آن اعمال میشود (مثلاً ۷۵۰ با اجرت ۱۰٪ میشود ۸۲۵).
- کارساخته (فقط زرین): وقتی در پنل مدیریت {{ brand.name }} محصول جدیدی تعریف شود، برایش یک «کارساخته» در زرین ساخته میشود. «نوع» تعیین میکند
- گرمی است یا تعدادی، و «گروه ساخته» جای آن در زرین است. در جدول پایین برای هر دستهبندی محصولِ {{ brand.name }} میتوانید گروه جدا بدهید؛ دستهای که
- «پیشفرض» بماند در «گروه ساختهی پیشفرض» میرود. اگر نوع یا گروه خالی باشد، ساخت کارساخته در «لاگ ارسال» منتظر بررسی میماند.
+ کارساخته (فقط زرین): وقتی در پنل مدیریت {{ brand.name }} محصول جدیدی تعریف شود، برایش یک «کارساخته» در زرین ساخته میشود.
+ نوع (گرمی/تعدادی) و گروه ساخته را اینجا مشخص کنید. در جدول برای هر دسته میتوانید گروه جدا بدهید؛ بقیه به گروه پیشفرض میروند.
+ اگر نوع یا گروه خالی باشد، ساخت کارساخته در «لاگ ارسال» منتظر بررسی میماند.
@@ -167,25 +167,23 @@
حساب مشتریها
-
+
- هر حسابی که در گروههای انتخابشده باشد و موبایلش با یک کاربر اپ مشتری {{ brand.name }} یکی باشد، به آن کاربر وصل میشود و ماندهاش در اپ مشتری
- نمایش داده میشود. صندوق، کارمند و سرمایهگذار را انتخاب نکنید.
+ فیلد اول: حسابهای همین گروهها (با موبایل یکسان) به کاربر اپ وصل میشوند و ماندهشان در اپ دیده میشود. صندوق، کارمند و سرمایهگذار را نزنید.
- مثال: «بنکدار» و «مغازهدار» را در فیلد اول انتخاب کنید و در فیلد دوم «مغازهدار» را بگذارید. در این حالت کسی که در اپ مشتری ثبتنام و
- احراز هویت کند و در حسابداری حساب نداشته باشد، در گروه «مغازهدار» برایش حساب ساخته میشود.
+ فیلد دوم: اگر کاربر اپ هنوز در حسابداری حساب ندارد، آنجا ساخته میشود. مثال: اولی «بنکدار» و «مغازهدار»، دومی «مغازهدار».
-
+
@@ -205,7 +203,7 @@
ستون «دستهبندی محصول در {{ brand.name }}» همان دستهبندیهایی است که در پنل مدیریت برای محصولات تعریف کردهاید. مثال: اگر روبهروی «انگشتر»
گروه «انگشتر» زرین را بگذارید، هر محصول تازه در دستهی انگشتر {{ brand.name }}، در گروه انگشترِ زرین ساخته میشود.
- دستههایی که «پیشفرض» بمانند در «گروه ساختهی پیشفرض» میروند.
+ دستههایی که در جدول «پیشفرض» بمانند، به همان گروه پیشفرض پایین میروند.
اگر نوع یا گروه خالی باشد، محصول در {{ brand.name }} ساخته میشود ولی ساخت کارساخته در «لاگ ارسال» منتظر میماند تا آن را تعیین کنید و «تلاش دوباره» بزنید.
@@ -214,10 +212,10 @@
diff --git a/apps/admin-panel/src/views/manage/cart-expiry.vue b/apps/admin-panel/src/views/manage/cart-expiry.vue
new file mode 100644
index 0000000..cee2e8f
--- /dev/null
+++ b/apps/admin-panel/src/views/manage/cart-expiry.vue
@@ -0,0 +1,173 @@
+
+
+
+
+
+
+
+
+ خاموش = رفتار فعلی (بدون کرون و بدون پیامک). سبد مهمان فقط در کلاینت پاک میشود.
+
+
+
+
+
+ اگر بین شروع تعویق تا ساعت خالیشدن چیزی به سبد اضافه شود، انقضا میرود روی
+ خالیشدن فردا
+ (پیشفرض: ۰۸:۰۰ تا ۱۳:۰۰ → ۱۳:۰۰ فردا). قبل از این پنجره همانروز خالی میشود.
+
+
+
+
+
+
+
+
+
+
+
+
+ جایگزین `%count` با تعداد ساعت قبل از خالیشدن.
+
+
+
+ ذخیره
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/admin-panel/src/views/statics/index.vue b/apps/admin-panel/src/views/statics/index.vue
index 23ccfa4..9c0cd05 100644
--- a/apps/admin-panel/src/views/statics/index.vue
+++ b/apps/admin-panel/src/views/statics/index.vue
@@ -1,91 +1,339 @@
-
-
-
-
-
-
-
-
- {{ c.name }} - {{ c.mobile }}
-
-
-
-
-
+
+
+
+
+
-
-
-
- {{ jdateTime(record.caj) }}
+
+
+ تلاش دوباره
+
+
+
+
+
+
+ {{ k.label }}
+ {{ fa(k.value) }}
+
+
+
+
+ پرفروشترین محصولات
+
+
+
+ {{ fa(index + 1) }}
+
+
+
+ {{ record.title }}
+
+ {{ record.title }}
+ {{ record.code }}
+
+
+
+ {{ fa(record.qty) }}
+
+
+
+ {{ fa(record.orders) }}
-
-
-
-
+
+
+
+
+
+ {{ viewsNotice }}
+
+
+
+
+ پربازدیدترین محصولات
+
+
+
+ {{ fa(index + 1) }}
+
+
+
+ {{ record.title }}
+
+ {{ record.title }}
+ {{ record.code }}
+
+
+
+ {{ fa(record.views) }}
+
+
+
+
+
+
+
+
+ پربازدیدترین برندها
+
+
+
+ {{ fa(index + 1) }}
+
+
+ {{ record.title }}
+
+
+
+ {{ fa(record.views) }}
+
+
+
+
+
+
+
+
+
-
+
diff --git a/apps/admin-panel/src/views/user/permissionConst.ts b/apps/admin-panel/src/views/user/permissionConst.ts
index 90ff4b3..0f27bf9 100644
--- a/apps/admin-panel/src/views/user/permissionConst.ts
+++ b/apps/admin-panel/src/views/user/permissionConst.ts
@@ -838,6 +838,10 @@ export const treeData: PermissionNode[] = [
"title": "مدیریت کیف پول",
"key": "config.wallet",
},
+ {
+ "title": "انقضای سبد خرید",
+ "key": "config.cart",
+ },
{
"title": "گزارش",
"key": "config.report",
diff --git a/apps/api/prisma/migrations/20261111120000_product_view/migration.sql b/apps/api/prisma/migrations/20261111120000_product_view/migration.sql
new file mode 100644
index 0000000..32e05c0
--- /dev/null
+++ b/apps/api/prisma/migrations/20261111120000_product_view/migration.sql
@@ -0,0 +1,13 @@
+-- Product detail opens in the customer app, for the admin «آمار» screen.
+CREATE TABLE "ProductView" (
+ "id" TEXT NOT NULL,
+ "productId" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "ProductView_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "ProductView_productId_createdAt_idx" ON "ProductView"("productId", "createdAt");
+CREATE INDEX "ProductView_createdAt_idx" ON "ProductView"("createdAt");
+
+ALTER TABLE "ProductView" ADD CONSTRAINT "ProductView_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/apps/api/prisma/migrations/20261111130000_product_tags_column/migration.sql b/apps/api/prisma/migrations/20261111130000_product_tags_column/migration.sql
new file mode 100644
index 0000000..b7e19da
--- /dev/null
+++ b/apps/api/prisma/migrations/20261111130000_product_tags_column/migration.sql
@@ -0,0 +1,8 @@
+-- Product labels from the «برچسبها» list. IF NOT EXISTS: some databases
+-- already carry this column from an earlier, unmerged migration.
+ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "tags" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
+UPDATE "Product" SET "tags" = ARRAY[]::TEXT[] WHERE "tags" IS NULL;
+ALTER TABLE "Product" ALTER COLUMN "tags" SET DEFAULT ARRAY[]::TEXT[];
+ALTER TABLE "Product" ALTER COLUMN "tags" SET NOT NULL;
+
+CREATE INDEX IF NOT EXISTS "Product_tags_idx" ON "Product" USING GIN ("tags");
diff --git a/apps/api/prisma/migrations/20261112130000_customer_cart_expiry/migration.sql b/apps/api/prisma/migrations/20261112130000_customer_cart_expiry/migration.sql
new file mode 100644
index 0000000..4090731
--- /dev/null
+++ b/apps/api/prisma/migrations/20261112130000_customer_cart_expiry/migration.sql
@@ -0,0 +1,13 @@
+-- Server-side customer cart for daily expiry + reminder SMS (cart.expiry).
+CREATE TABLE "CustomerCart" (
+ "customerId" TEXT NOT NULL,
+ "items" JSONB NOT NULL DEFAULT '[]',
+ "reminderSentForClearAt" TIMESTAMP(3),
+ "clearedAt" TIMESTAMP(3),
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "CustomerCart_pkey" PRIMARY KEY ("customerId")
+);
+
+ALTER TABLE "CustomerCart" ADD CONSTRAINT "CustomerCart_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/apps/api/prisma/migrations/20261112140000_customer_buy_later/migration.sql b/apps/api/prisma/migrations/20261112140000_customer_buy_later/migration.sql
new file mode 100644
index 0000000..e10c0f9
--- /dev/null
+++ b/apps/api/prisma/migrations/20261112140000_customer_buy_later/migration.sql
@@ -0,0 +1,12 @@
+-- Deferred «خرید بعدی» list — separate from CustomerCart so daily expiry
+-- and order-clear never touch it, and stock is never reserved.
+CREATE TABLE "CustomerBuyLater" (
+ "customerId" TEXT NOT NULL,
+ "items" JSONB NOT NULL DEFAULT '[]',
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "CustomerBuyLater_pkey" PRIMARY KEY ("customerId")
+);
+
+ALTER TABLE "CustomerBuyLater" ADD CONSTRAINT "CustomerBuyLater_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/apps/api/prisma/migrations/20261112150000_product_survey/migration.sql b/apps/api/prisma/migrations/20261112150000_product_survey/migration.sql
new file mode 100644
index 0000000..8b3f4f4
--- /dev/null
+++ b/apps/api/prisma/migrations/20261112150000_product_survey/migration.sql
@@ -0,0 +1,49 @@
+-- Product design survey (like/dislike before manufacturing).
+CREATE TYPE "ProductSurveyStatus" AS ENUM ('PENDING_REVIEW', 'OPEN', 'CLOSED', 'APPROVED', 'REJECTED');
+CREATE TYPE "ProductSurveyVoteValue" AS ENUM ('LIKE', 'DISLIKE');
+
+CREATE TABLE "ProductSurvey" (
+ "id" TEXT NOT NULL,
+ "title" TEXT,
+ "imageUrl" TEXT NOT NULL,
+ "suggestedWeightGrams" DECIMAL(14,4) NOT NULL,
+ "status" "ProductSurveyStatus" NOT NULL DEFAULT 'PENDING_REVIEW',
+ "createdByCustomerId" TEXT,
+ "createdByAdminId" TEXT,
+ "publishedAt" TIMESTAMP(3),
+ "decidedAt" TIMESTAMP(3),
+ "decidedByAdminId" TEXT,
+ "decisionNote" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "ProductSurvey_pkey" PRIMARY KEY ("id")
+);
+
+CREATE TABLE "ProductSurveyVote" (
+ "id" TEXT NOT NULL,
+ "surveyId" TEXT NOT NULL,
+ "customerId" TEXT NOT NULL,
+ "value" "ProductSurveyVoteValue" NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "ProductSurveyVote_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "ProductSurvey_status_idx" ON "ProductSurvey"("status");
+CREATE INDEX "ProductSurvey_createdByCustomerId_idx" ON "ProductSurvey"("createdByCustomerId");
+CREATE INDEX "ProductSurvey_createdAt_idx" ON "ProductSurvey"("createdAt");
+CREATE INDEX "ProductSurveyVote_customerId_idx" ON "ProductSurveyVote"("customerId");
+CREATE UNIQUE INDEX "ProductSurveyVote_surveyId_customerId_key" ON "ProductSurveyVote"("surveyId", "customerId");
+
+ALTER TABLE "ProductSurvey" ADD CONSTRAINT "ProductSurvey_createdByCustomerId_fkey" FOREIGN KEY ("createdByCustomerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "ProductSurvey" ADD CONSTRAINT "ProductSurvey_createdByAdminId_fkey" FOREIGN KEY ("createdByAdminId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "ProductSurvey" ADD CONSTRAINT "ProductSurvey_decidedByAdminId_fkey" FOREIGN KEY ("decidedByAdminId") REFERENCES "Admin"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+ALTER TABLE "ProductSurveyVote" ADD CONSTRAINT "ProductSurveyVote_surveyId_fkey" FOREIGN KEY ("surveyId") REFERENCES "ProductSurvey"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+ALTER TABLE "ProductSurveyVote" ADD CONSTRAINT "ProductSurveyVote_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+
+INSERT INTO "permission" ("rid", "userTypeId")
+SELECT 'product.survey', 85
+WHERE EXISTS (SELECT 1 FROM "user_type" WHERE "id" = 85)
+ON CONFLICT DO NOTHING;
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index 9095e12..567f978 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -181,6 +181,8 @@ model Admin {
sentSmsMessages SmsMessage[] @relation("SmsAuthor")
sentNotifications Notification[] @relation("NotificationAuthor")
uploadedFiles UploadedFile[] @relation("FileUploader")
+ createdProductSurveys ProductSurvey[] @relation("SurveyAdminCreator")
+ decidedProductSurveys ProductSurvey[] @relation("SurveyDecider")
/// Issued admin-panel tokens, recorded only while session tracking is on
/// (AUTH_SESSION_TRACKING, or implicitly whenever the IAM is enabled) so a
@@ -564,12 +566,51 @@ model Customer {
activityLogs CustomerActivityLog[]
otpChallenges CustomerOtp[]
favorites CustomerFavorite[]
+ productSurveys ProductSurvey[] @relation("SurveyCustomerCreator")
+ productSurveyVotes ProductSurveyVote[]
+ /// App shopping cart synced from user-app-v2 so daily expiry + reminder SMS
+ /// can run on the server (localStorage alone cannot).
+ cart CustomerCart?
+ /// Deferred «خرید بعدی» list — never reserved against stock and never
+ /// cleared by the daily cart-expiry worker.
+ buyLater CustomerBuyLater?
@@index([referredByAgentId])
@@index([firstName])
@@index([lastName])
}
+/// Server-side mirror of the customer-app cart (`appCart` in localStorage).
+/// Empty `items` means no basket; the daily expiry worker clears it and may
+/// SMS a reminder before the configured clear time (Setting `cart.expiry`).
+model CustomerCart {
+ customerId String @id
+ customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
+
+ /// Cart lines as JSON — same shape the app stores (pid, vid, qty, …).
+ items Json @default("[]")
+
+ /// Which clear-deadline this cart already got a reminder SMS for (ISO of
+ /// that deadline). Null = never reminded for the current cycle.
+ reminderSentForClearAt DateTime?
+
+ clearedAt DateTime?
+ updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now())
+}
+
+/// «سبد خرید بعدی» — wish/defer list. Same line shape as the cart, but:
+/// never decrements stock, never participates in checkout, never cleared by
+/// cart.expiry. Moving a line here only removes it from the active cart.
+model CustomerBuyLater {
+ customerId String @id
+ customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
+
+ items Json @default("[]")
+ updatedAt DateTime @updatedAt
+ createdAt DateTime @default(now())
+}
+
/// Legacy `fav` (251 rows) — saved products. The target had no model for this,
/// so every customer's favourites would have been lost in the migration; the
/// owner confirmed on 2026-10-06 that they matter.
@@ -694,6 +735,9 @@ model Product {
showType Int @default(3)
isActive Boolean @default(true)
createdAt DateTime @default(now())
+ /// Labels picked from the «ثابتها → برچسبها» list (Setting `__list__tags`).
+ /// Shown on the app's product cards; tagged products get their own home section.
+ tags String[] @default([])
/// Stock of the linked کارساخته as last read from the accounting software
/// (D27). Weight for gram-type items, count for count-type ones — whichever
@@ -710,6 +754,7 @@ model Product {
promotions Promotion[]
allocationItems BranchAllocationItem[]
favoritedBy CustomerFavorite[]
+ views ProductView[]
// Storefront paging (owner: the real catalogue reaches ~16k products, so
// GET /catalog/products must page rather than return everything). This model
@@ -722,6 +767,19 @@ model Product {
@@index([isActive, createdAt])
@@index([categoryId])
@@index([brandId])
+ @@index([tags], type: Gin)
+}
+
+/// One opening of a product's detail screen in the customer app. Read by the
+/// admin «آمار» screen for the most-viewed products and brands.
+model ProductView {
+ id String @id @default(cuid())
+ productId String
+ product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
+ createdAt DateTime @default(now())
+
+ @@index([productId, createdAt])
+ @@index([createdAt])
}
model ProductImage {
@@ -1097,6 +1155,69 @@ model Comment {
@@index([status])
}
+// ─────────────────────────────────────────────────────────────────────────
+// Product design survey — before manufacturing, a design (photo + suggested
+// weight) is shown for like/dislike. Admin or customer can create; customer
+// submissions need admin publish. Admin decides APPROVED/REJECTED by hand.
+// Not linked to Product / PreOrder.
+// ─────────────────────────────────────────────────────────────────────────
+
+enum ProductSurveyStatus {
+ PENDING_REVIEW
+ OPEN
+ CLOSED
+ APPROVED
+ REJECTED
+}
+
+enum ProductSurveyVoteValue {
+ LIKE
+ DISLIKE
+}
+
+model ProductSurvey {
+ id String @id @default(cuid())
+ title String?
+ imageUrl String
+ suggestedWeightGrams Decimal @db.Decimal(14, 4)
+ status ProductSurveyStatus @default(PENDING_REVIEW)
+
+ createdByCustomerId String?
+ createdByCustomer Customer? @relation("SurveyCustomerCreator", fields: [createdByCustomerId], references: [id])
+ createdByAdminId String?
+ createdByAdmin Admin? @relation("SurveyAdminCreator", fields: [createdByAdminId], references: [id])
+
+ publishedAt DateTime?
+ decidedAt DateTime?
+ decidedByAdminId String?
+ decidedByAdmin Admin? @relation("SurveyDecider", fields: [decidedByAdminId], references: [id])
+ decisionNote String?
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ votes ProductSurveyVote[]
+
+ @@index([status])
+ @@index([createdByCustomerId])
+ @@index([createdAt])
+}
+
+model ProductSurveyVote {
+ id String @id @default(cuid())
+ surveyId String
+ survey ProductSurvey @relation(fields: [surveyId], references: [id], onDelete: Cascade)
+ customerId String
+ customer Customer @relation(fields: [customerId], references: [id])
+ value ProductSurveyVoteValue
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@unique([surveyId, customerId])
+ @@index([customerId])
+}
+
// ─────────────────────────────────────────────────────────────────────────
// Settings — replaces legacy `config_static`, a key/value table the admin
// panel read and wrote through endpoints like /wallet/config and
diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts
index 50420f0..88c091f 100644
--- a/apps/api/src/app.module.ts
+++ b/apps/api/src/app.module.ts
@@ -10,6 +10,7 @@ import { CustomerFavoritesModule } from './modules/customer-favorites/customer-f
import { CustomerReportModule } from './modules/customer-report/customer-report.module';
import { SmsModule } from './modules/sms/sms.module';
import { OrdersModule } from './modules/orders/orders.module';
+import { CartModule } from './modules/cart/cart.module';
import { AccountingModule } from './modules/accounting/accounting.module';
import { AccountingSyncModule } from './modules/accounting-sync/accounting-sync.module';
import { PricingModule } from './modules/pricing/pricing.module';
@@ -21,6 +22,7 @@ import { AdminOrdersModule } from './modules/admin-orders/admin-orders.module';
import { AdminCitiesModule } from './modules/admin-cities/admin-cities.module';
import { AdminPreOrdersModule } from './modules/admin-preorders/admin-preorders.module';
import { CustomerPreOrdersModule } from './modules/customer-preorders/customer-preorders.module';
+import { ProductSurveyModule } from './modules/product-survey/product-survey.module';
import { AdminWorkshopsModule } from './modules/admin-workshops/admin-workshops.module';
import { AdminCustomersModule } from './modules/admin-customers/admin-customers.module';
import { AdminCommentsModule } from './modules/admin-comments/admin-comments.module';
@@ -50,6 +52,7 @@ import { SalesAgentModule } from './modules/sales-agent/sales-agent.module';
AdminCitiesModule,
AdminPreOrdersModule,
CustomerPreOrdersModule,
+ ProductSurveyModule,
AdminWorkshopsModule,
AdminCustomersModule,
AdminCommentsModule,
@@ -74,6 +77,7 @@ import { SalesAgentModule } from './modules/sales-agent/sales-agent.module';
CustomerReportModule,
SalesAgentModule,
OrdersModule,
+ CartModule,
AccountingModule,
// Tahesab / Zarrin integration (D27) — inert until switched on in the admin panel
AccountingSyncModule,
diff --git a/apps/api/src/modules/accounting-sync/accounting-debug-log.ts b/apps/api/src/modules/accounting-sync/accounting-debug-log.ts
new file mode 100644
index 0000000..0587f7d
--- /dev/null
+++ b/apps/api/src/modules/accounting-sync/accounting-debug-log.ts
@@ -0,0 +1,62 @@
+import { appendFileSync, mkdirSync } from 'fs';
+import { dirname, resolve } from 'path';
+import { envOrNull } from '../../common/env';
+import { AccountingProviderError } from './providers/accounting-provider';
+
+/**
+ * Optional trace of the order-document path, one JSON object per line, for
+ * testing the integration against a real installation. Off unless
+ * ACCOUNTING_DEBUG_LOG names a file. Tokens never reach it: callers pass
+ * params without them, and error text from Zarrin is already scrubbed.
+ *
+ * Each failure is tagged with who is at fault:
+ * ZARRIN — the software refused the call or answered badly
+ * NETWORK — the server could not be reached or did not answer in time
+ * DATA — the order cannot be turned into a document (NeedsReviewError)
+ * CODE — anything else: a bug on our side, logged with its stack
+ */
+
+let warned = false;
+
+function target(): string | null {
+ const file = envOrNull('ACCOUNTING_DEBUG_LOG');
+ return file ? resolve(file) : null;
+}
+
+export function accountingDebugEnabled() {
+ return target() !== null;
+}
+
+export function accountingDebug(event: string, data: Record
= {}) {
+ const file = target();
+ if (!file) return;
+ try {
+ mkdirSync(dirname(file), { recursive: true });
+ appendFileSync(file, JSON.stringify({ at: new Date().toISOString(), event, ...data }) + '\n', 'utf8');
+ } catch (err) {
+ if (!warned) {
+ warned = true;
+ // eslint-disable-next-line no-console
+ console.warn(`ACCOUNTING_DEBUG_LOG: cannot write ${file}: ${(err as Error).message}`);
+ }
+ }
+}
+
+export function faultOf(err: unknown): 'ZARRIN' | 'NETWORK' | 'DATA' | 'CODE' {
+ if (err instanceof AccountingProviderError) {
+ return /timeout|ECONN|ENOTFOUND|EHOSTUNREACH|socket hang up|برقرار نشد|در زمان مقرر/.test(err.message) ? 'NETWORK' : 'ZARRIN';
+ }
+ if ((err as Error)?.name === 'NeedsReviewError') return 'DATA';
+ return 'CODE';
+}
+
+export function errorDetails(err: unknown): Record {
+ const fault = faultOf(err);
+ const e = err as Error & { response?: unknown; ambiguous?: boolean };
+ return {
+ fault,
+ error: e?.message ?? String(err),
+ ...(e instanceof AccountingProviderError ? { ambiguous: e.ambiguous, zarrinResponse: e.response } : {}),
+ ...(fault === 'CODE' ? { stack: e?.stack } : {}),
+ };
+}
diff --git a/apps/api/src/modules/accounting-sync/accounting-hooks.service.ts b/apps/api/src/modules/accounting-sync/accounting-hooks.service.ts
index 7a20075..acba813 100644
--- a/apps/api/src/modules/accounting-sync/accounting-hooks.service.ts
+++ b/apps/api/src/modules/accounting-sync/accounting-hooks.service.ts
@@ -6,6 +6,7 @@ import { AccountingFeatures, documentStatusRank } from './accounting-config';
import { AccountingSyncService } from './accounting-sync.service';
import { AccountingWorkerService } from './accounting-worker.service';
import { ACCOUNTING_PROVIDERS } from './providers';
+import { accountingDebug } from './accounting-debug-log';
type Tx = Prisma.TransactionClient | PrismaService;
@@ -54,11 +55,18 @@ export class AccountingHooksService {
*/
async orderStatusReached(tx: Tx, orderId: string, status: string) {
const c = await this.config.get();
- if (c.provider === 'none' || !c.features.documents) return;
+ const trace = (decision: string) =>
+ accountingDebug('order.status', { orderId, status, provider: c.provider, triggerStatus: c.documents.triggerStatus, decision });
+ if (c.provider === 'none' || !c.features.documents) return trace('ignored: provider none or documents switched off');
const rank = documentStatusRank(status);
- if (!rank) return;
- if (rank >= documentStatusRank(c.documents.triggerStatus)) await this.queueOrderDocument(tx, c.provider, orderId);
- else await this.orderCancelled(tx, orderId);
+ if (!rank) return trace('ignored: status never triggers a document');
+ if (rank >= documentStatusRank(c.documents.triggerStatus)) {
+ await this.queueOrderDocument(tx, c.provider, orderId);
+ trace('document queued (unless one was already queued)');
+ } else {
+ await this.orderCancelled(tx, orderId);
+ trace('below trigger status: any sent document withdrawn');
+ }
}
private async queueOrderDocument(tx: Tx, provider: string, orderId: string) {
diff --git a/apps/api/src/modules/accounting-sync/accounting-sync.service.ts b/apps/api/src/modules/accounting-sync/accounting-sync.service.ts
index e5f7e8d..9380bb8 100644
--- a/apps/api/src/modules/accounting-sync/accounting-sync.service.ts
+++ b/apps/api/src/modules/accounting-sync/accounting-sync.service.ts
@@ -6,6 +6,7 @@ import { ActiveProvider, AccountingConfigService } from './accounting-config.ser
import { AccountingConfig } from './accounting-config';
import { JalaliDate, parseJalali, toJalali } from './jalali';
import { documentWeightTolerance } from './document-check';
+import { accountingDebug } from './accounting-debug-log';
import {
AccountingProviderError,
CustomerPayload,
@@ -890,6 +891,7 @@ export class AccountingSyncService {
const doc = await this.buildOrderDocument(a, job.entityId);
const request = { ...doc.input, lines: doc.input.lines.map((l) => ({ ...l, weightGrams: l.weightGrams.toFixed(3), wagePercent: l.wagePercent.toString(), flatRial: l.flatRial.toFixed(0) })), check: doc.check };
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data: { lastRequest: request as unknown as Prisma.InputJsonValue } });
+ accountingDebug('document.built', { jobId: job.id, orderId: job.entityId, orderNumber: doc.order.orderNumber, progress: job.progress, document: request });
const result = await a.provider.postOrderDocument(
a.settings,
doc.input,
diff --git a/apps/api/src/modules/accounting-sync/accounting-worker.service.ts b/apps/api/src/modules/accounting-sync/accounting-worker.service.ts
index 90bf623..553e09c 100644
--- a/apps/api/src/modules/accounting-sync/accounting-worker.service.ts
+++ b/apps/api/src/modules/accounting-sync/accounting-worker.service.ts
@@ -6,6 +6,7 @@ import { PrismaService } from '../../prisma/prisma.service';
import { ActiveProvider, AccountingConfigService, JobName } from './accounting-config.service';
import { AccountingSyncService, NeedsReviewError } from './accounting-sync.service';
import { AccountingNotSupportedError, AccountingProviderError } from './providers';
+import { accountingDebug, errorDetails } from './accounting-debug-log';
/** Scheduled pulls (customers, full balances, full products): the safety net. */
const TICK_MS = 30_000;
@@ -75,7 +76,11 @@ export class AccountingWorkerService implements OnApplicationBootstrap, OnModule
) {}
onApplicationBootstrap() {
- if (envOr('ACCOUNTING_WORKER', 'on') === 'off' || process.env.NODE_ENV === 'test') return;
+ if (envOr('ACCOUNTING_WORKER', 'on') === 'off' || process.env.NODE_ENV === 'test') {
+ accountingDebug('worker.off', { note: 'ACCOUNTING_WORKER=off: queued documents will not be sent' });
+ return;
+ }
+ accountingDebug('worker.on');
this.timer = setInterval(() => void this.tick(), TICK_MS);
this.timer.unref?.();
this.fastTimer = setInterval(() => void this.fastTick(), FAST_TICK_MS);
@@ -170,6 +175,7 @@ export class AccountingWorkerService implements OnApplicationBootstrap, OnModule
this.lastChangePollAt = Date.now();
await this.pollChanges(a);
} catch (err) {
+ accountingDebug('worker.tickFailed', errorDetails(err));
this.logger.error(`Accounting fast tick failed: ${(err as Error).message}`);
} finally {
this.fastRunning = false;
@@ -335,8 +341,10 @@ export class AccountingWorkerService implements OnApplicationBootstrap, OnModule
const attempts = job.attempts + 1;
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data: { status: 'SENDING', attempts } });
const fresh = { ...job, attempts };
+ accountingDebug('job.start', { jobId: job.id, kind: job.kind, entityId: job.entityId, attempt: attempts });
try {
const res = await this.sync.runJob(a, fresh);
+ accountingDebug('job.sent', { jobId: job.id, kind: job.kind, entityId: job.entityId, externalRef: res.externalRef ?? null, response: res.response });
await this.prisma.accountingOutbox.update({
where: { id: job.id },
data: {
@@ -368,6 +376,7 @@ export class AccountingWorkerService implements OnApplicationBootstrap, OnModule
}
if (response !== undefined) data.lastResponse = toJson(response);
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data });
+ accountingDebug('job.failed', { jobId: job.id, kind: job.kind, entityId: job.entityId, nextStatus: String(data.status), ...errorDetails(err) });
this.logger.warn(`Accounting job ${job.kind} ${job.entityId} → ${String(data.status)}: ${msg}`);
return false;
}
diff --git a/apps/api/src/modules/accounting-sync/providers/zarrin.provider.ts b/apps/api/src/modules/accounting-sync/providers/zarrin.provider.ts
index 60185e5..de0bd0a 100644
--- a/apps/api/src/modules/accounting-sync/providers/zarrin.provider.ts
+++ b/apps/api/src/modules/accounting-sync/providers/zarrin.provider.ts
@@ -1,5 +1,6 @@
import { Prisma } from '@prisma/client';
import { AccountingHttpError, httpRequest, parseLooseJson } from '../accounting-http';
+import { accountingDebug, accountingDebugEnabled } from '../accounting-debug-log';
import { JalaliDate, jalaliToGregorian, jalaliToIsoDate, toIsoDateTehran, toJalali } from '../jalali';
const jalaliKey = (d: JalaliDate) => d.jy * 10000 + d.jm * 100 + d.jd;
@@ -80,6 +81,14 @@ function isMissingKind(err: unknown) {
return err instanceof ZarrinHttpError && err.status === 400;
}
+function scrubToken(text: string, token: string) {
+ if (token) for (const t of new Set([token, encodeURIComponent(token)])) text = text.split(t).join('***');
+ return text;
+}
+
+/** Polled every few seconds; traced only when they fail. */
+const QUIET_KINDS = new Set(['parLastTenRadif', 'listMandeHesabha', 'listhesabha', 'listKarSakhte', 'mojoodiKala', 'version', 'listGorooheHesabha', 'parvande']);
+
/**
* What the server said on a non-2xx reply, short enough for the admin screen.
* Error pages may echo the request URL, so the token is scrubbed.
@@ -94,8 +103,7 @@ function errorBodyText(body: string, token: string) {
} catch {
text = body.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]+>/g, ' ');
}
- if (token) for (const t of new Set([token, encodeURIComponent(token)])) text = text.split(t).join('***');
- text = text.replace(/\s+/g, ' ').trim();
+ text = scrubToken(text, token).replace(/\s+/g, ' ').trim();
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
}
@@ -133,15 +141,28 @@ async function call(
if (v !== null && v !== undefined && v !== '') url.searchParams.set(k, String(v));
}
+ const started = Date.now();
+ const trace = (extra: Record) => {
+ if (!accountingDebugEnabled()) return;
+ if (QUIET_KINDS.has(kind) && !extra.error) return;
+ accountingDebug('zarrin.call', { kind, params, ms: Date.now() - started, ...extra });
+ };
+
let res;
try {
res = await httpRequest({ method: 'GET', url: url.toString(), timeoutMs: opts.timeoutMs ?? s.timeoutMs, ca: s.caCert });
} catch (err) {
+ trace({ error: (err as Error).message, code: (err as AccountingHttpError).code });
if (err instanceof AccountingHttpError) {
throw new AccountingProviderError(`زرین (${kind}): ${err.message}`, err.ambiguous);
}
throw err;
}
+ trace({
+ status: res.status,
+ body: scrubToken(res.body, s.token).slice(0, 2000),
+ ...(res.status < 200 || res.status >= 300 ? { error: `HTTP ${res.status}` } : {}),
+ });
if (res.status < 200 || res.status >= 300) {
// v228 answers goroohSakhte with HTTP 400 and a perfectly good body.
diff --git a/apps/api/src/modules/admin-misc/admin-misc.controller.ts b/apps/api/src/modules/admin-misc/admin-misc.controller.ts
index a8cc25d..e37974a 100644
--- a/apps/api/src/modules/admin-misc/admin-misc.controller.ts
+++ b/apps/api/src/modules/admin-misc/admin-misc.controller.ts
@@ -8,6 +8,7 @@ import {
Param,
Post,
Put,
+ Query,
} from '@nestjs/common';
import { AdminMiscService } from './admin-misc.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
@@ -142,6 +143,13 @@ export class AdminMiscController {
return this.misc.customerLog(id);
}
+ @Get('stats/top')
+ @RequirePermission('statics')
+ topStats(@Query('days') days?: string) {
+ const n = Number(days);
+ return this.misc.topStats(Number.isFinite(n) && n > 0 ? Math.min(Math.floor(n), 3650) : 0);
+ }
+
// ── settings + brands ("مدیریت ثابت ها") ─────────────────────────
@Get('dashboard-links')
@RequirePermission('config.report')
diff --git a/apps/api/src/modules/admin-misc/admin-misc.service.ts b/apps/api/src/modules/admin-misc/admin-misc.service.ts
index 83fe6ec..abd4387 100644
--- a/apps/api/src/modules/admin-misc/admin-misc.service.ts
+++ b/apps/api/src/modules/admin-misc/admin-misc.service.ts
@@ -6,6 +6,7 @@ import { SmsService } from '../sms/sms.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { ACCOUNTING_SETTING_PREFIX } from '../accounting-sync/accounting-config';
import { PRICING_SETTING_PREFIX } from '../pricing/wage.service';
+import { TAG_LIST_KEY } from '../admin-products/admin-products.service';
/**
* The remaining admin screens — legacy views/city, views/sms, views/notify,
@@ -542,6 +543,68 @@ export class AdminMiscService {
return rows.map((c) => ({ id: c.id, name: c.fullName, mobile: c.mobile }));
}
+ /**
+ * Best-selling products, most-viewed products and most-viewed brands.
+ * `days` 0 means all time. Cancelled orders do not count as sales.
+ */
+ async topStats(days: number, limit = 10) {
+ const since = days > 0 ? new Date(Date.now() - days * 86_400_000) : new Date(0);
+
+ const [soldTotals, viewTotals, firstView, sold, viewedProducts, viewedBrands] = await Promise.all([
+ this.prisma.$queryRaw<{ qty: bigint | null; orders: bigint }[]>`
+ SELECT SUM(oi."quantity") AS "qty", COUNT(DISTINCT o."id") AS "orders"
+ FROM "OrderItem" oi
+ JOIN "Order" o ON o."id" = oi."orderId"
+ WHERE o."status" <> 'CANCELLED' AND o."createdAt" >= ${since}`,
+ this.prisma.$queryRaw<{ views: bigint; products: bigint }[]>`
+ SELECT COUNT(*) AS "views", COUNT(DISTINCT pv."productId") AS "products"
+ FROM "ProductView" pv
+ WHERE pv."createdAt" >= ${since}`,
+ this.prisma.productView.findFirst({ orderBy: { createdAt: 'asc' }, select: { createdAt: true } }),
+ this.prisma.$queryRaw<{ id: string; title: string; code: string | null; qty: bigint; orders: bigint }[]>`
+ SELECT p."id", p."title", p."productCode" AS "code",
+ SUM(oi."quantity") AS "qty", COUNT(DISTINCT o."id") AS "orders"
+ FROM "OrderItem" oi
+ JOIN "Order" o ON o."id" = oi."orderId"
+ JOIN "ProductVariant" v ON v."id" = oi."productVariantId"
+ JOIN "Product" p ON p."id" = v."productId"
+ WHERE o."status" <> 'CANCELLED' AND o."createdAt" >= ${since}
+ GROUP BY p."id", p."title", p."productCode"
+ ORDER BY "qty" DESC
+ LIMIT ${limit}`,
+ this.prisma.$queryRaw<{ id: string; title: string; code: string | null; views: bigint }[]>`
+ SELECT p."id", p."title", p."productCode" AS "code", COUNT(*) AS "views"
+ FROM "ProductView" pv
+ JOIN "Product" p ON p."id" = pv."productId"
+ WHERE pv."createdAt" >= ${since}
+ GROUP BY p."id", p."title", p."productCode"
+ ORDER BY "views" DESC
+ LIMIT ${limit}`,
+ this.prisma.$queryRaw<{ id: string; title: string; views: bigint }[]>`
+ SELECT b."id", b."title", COUNT(*) AS "views"
+ FROM "ProductView" pv
+ JOIN "Product" p ON p."id" = pv."productId"
+ JOIN "Brand" b ON b."id" = p."brandId"
+ WHERE pv."createdAt" >= ${since}
+ GROUP BY b."id", b."title"
+ ORDER BY "views" DESC
+ LIMIT ${limit}`,
+ ]);
+
+ return {
+ totals: {
+ soldQty: Number(soldTotals[0]?.qty ?? 0),
+ orders: Number(soldTotals[0]?.orders ?? 0),
+ views: Number(viewTotals[0]?.views ?? 0),
+ viewedProducts: Number(viewTotals[0]?.products ?? 0),
+ },
+ viewsTrackedSince: firstView?.createdAt ?? null,
+ topSoldProducts: sold.map((r) => ({ id: r.id, title: r.title, code: r.code, qty: Number(r.qty), orders: Number(r.orders) })),
+ topViewedProducts: viewedProducts.map((r) => ({ id: r.id, title: r.title, code: r.code, views: Number(r.views) })),
+ topViewedBrands: viewedBrands.map((r) => ({ id: r.id, title: r.title, views: Number(r.views) })),
+ };
+ }
+
/** Legacy `GET log/ago/{customerId}` — that customer's recent activity. */
async customerLog(customerId: string) {
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
@@ -570,6 +633,7 @@ export class AdminMiscService {
/** Legacy `POST const` / `PUT const/{key}`. */
async setConst(key: string, value: unknown) {
+ if (key === TAG_LIST_KEY) await this.dropRemovedTags(value);
const row = await this.prisma.setting.upsert({
where: { key },
update: { value: value as Prisma.InputJsonValue },
@@ -578,6 +642,25 @@ export class AdminMiscService {
return row.value;
}
+ /** A label deleted from the tag list is taken off every product that carried it. */
+ private async dropRemovedTags(next: unknown) {
+ const keep = new Set(Array.isArray(next) ? next.map((t) => String(t).trim()) : []);
+ const before = await this.getConst(TAG_LIST_KEY);
+ const removed = (Array.isArray(before) ? before.map((t) => String(t).trim()) : []).filter((t) => !keep.has(t));
+ if (!removed.length) return;
+
+ const products = await this.prisma.product.findMany({
+ where: { tags: { hasSome: removed } },
+ select: { id: true, tags: true },
+ });
+ for (const p of products) {
+ await this.prisma.product.update({
+ where: { id: p.id },
+ data: { tags: { set: p.tags.filter((t) => !removed.includes(t)) } },
+ });
+ }
+ }
+
async listConsts() {
// The accounting integration's rows (connection, encrypted token, worker
// lease) have their own `config.accounting` screen and never appear here
diff --git a/apps/api/src/modules/admin-products/admin-products.controller.ts b/apps/api/src/modules/admin-products/admin-products.controller.ts
index e528c72..84f1f9d 100644
--- a/apps/api/src/modules/admin-products/admin-products.controller.ts
+++ b/apps/api/src/modules/admin-products/admin-products.controller.ts
@@ -91,6 +91,12 @@ export class AdminProductsController {
return this.products.brands();
}
+ @Get('tags')
+ @RequirePermission('product.list')
+ tags() {
+ return this.products.tagOptions();
+ }
+
/** Brands as an XTable grid — the exclusion picker needs a selectable table. */
@Post('brands-table')
@HttpCode(200)
diff --git a/apps/api/src/modules/admin-products/admin-products.service.ts b/apps/api/src/modules/admin-products/admin-products.service.ts
index 9676ba4..71fc880 100644
--- a/apps/api/src/modules/admin-products/admin-products.service.ts
+++ b/apps/api/src/modules/admin-products/admin-products.service.ts
@@ -20,6 +20,9 @@ const SHOW_TYPE_LABELS: Record = {
3: 'سایت و گوشی',
};
+/** Setting key of the «ثابتها → برچسبها» list. */
+export const TAG_LIST_KEY = '__list__tags';
+
export const VARIANT_COLORS: Record = {
0: 'سفید',
1: 'زرد',
@@ -185,6 +188,7 @@ export class AdminProductsService {
: null,
body_html: p.description,
brand_id: p.brandId,
+ tags: p.tags,
cats: p.categoryId ? [p.categoryId] : [],
ciny_ids: p.collections.map((c) => c.collectionId),
variants_all: p.variants.map((v) => this.variantRow(v)),
@@ -447,6 +451,7 @@ export class AdminProductsService {
const images = this.imageCreateData(data);
const productCode = (data.product_code as string) || null;
+ const tags = await this.normalizeTags(data.tags);
// Images are created with the product so their ids exist before variants
// are attached — a variant's `image_id` has to reference a real row.
@@ -459,6 +464,7 @@ export class AdminProductsService {
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
brandId: (data.brand_id as string) || null,
categoryId: cats[0] ?? null,
+ tags,
collections: cinyIds.length
? { create: cinyIds.map((collectionId) => ({ collectionId })) }
: undefined,
@@ -518,6 +524,7 @@ export class AdminProductsService {
if (data.product_code !== undefined) patch.productCode = (data.product_code as string) || null;
if (data.show_type !== undefined) patch.showType = Number(data.show_type);
if (data.is_active !== undefined) patch.isActive = Boolean(data.is_active);
+ if (data.tags !== undefined) patch.tags = { set: await this.normalizeTags(data.tags) };
if (data.brand_id !== undefined) {
patch.brand = data.brand_id ? { connect: { id: String(data.brand_id) } } : { disconnect: true };
}
@@ -728,6 +735,21 @@ export class AdminProductsService {
return this.prisma.brand.findMany({ orderBy: { title: 'asc' } });
}
+ /** The labels defined under «ثابتها → برچسبها» — the product form's tag picker. */
+ async tagOptions(): Promise {
+ const row = await this.prisma.setting.findUnique({ where: { key: TAG_LIST_KEY } });
+ const value = row?.value;
+ return Array.isArray(value) ? value.map((t) => String(t).trim()).filter(Boolean) : [];
+ }
+
+ /** Keeps only labels that exist in the tag list, trimmed and de-duplicated. */
+ private async normalizeTags(raw: unknown): Promise {
+ if (!Array.isArray(raw)) return [];
+ const allowed = new Set(await this.tagOptions());
+ const picked = raw.map((t) => String(t).trim()).filter((t) => allowed.has(t));
+ return [...new Set(picked)];
+ }
+
/** Brands in the XTable envelope, for the promotion-exclusion picker. */
async brandsTable(query: TableQueryDto) {
this.table.assertNotExport(query);
diff --git a/apps/api/src/modules/auth/acl.constants.ts b/apps/api/src/modules/auth/acl.constants.ts
index e154f99..5111539 100644
--- a/apps/api/src/modules/auth/acl.constants.ts
+++ b/apps/api/src/modules/auth/acl.constants.ts
@@ -46,6 +46,7 @@ export const ALL_RIDS = [
'config.statics.tags.edit',
'config.statics.tags.remove',
'config.wallet',
+ 'config.cart',
'custom.config.statics.slider',
'custom.config.statics.tags.banner.ids',
'dashboard.view',
@@ -137,6 +138,7 @@ export const ALL_RIDS = [
'product.list.variants.remove',
'product.list.view',
'product.percent',
+ 'product.survey',
'report.order.item',
'report.product',
'sms.create',
diff --git a/apps/api/src/modules/auth/super-admin.ts b/apps/api/src/modules/auth/super-admin.ts
index 08cf543..a00568e 100644
--- a/apps/api/src/modules/auth/super-admin.ts
+++ b/apps/api/src/modules/auth/super-admin.ts
@@ -36,6 +36,7 @@ export const SUPER_ADMIN_RIDS = [
'offers.list.tak.edit',
'offers.list.tak.remove',
'config.wallet',
+ 'config.cart',
] as const;
export function isSuperAdmin(mobile: string | null | undefined): boolean {
diff --git a/apps/api/src/modules/cart/admin-cart-expiry.controller.ts b/apps/api/src/modules/cart/admin-cart-expiry.controller.ts
new file mode 100644
index 0000000..f53ca46
--- /dev/null
+++ b/apps/api/src/modules/cart/admin-cart-expiry.controller.ts
@@ -0,0 +1,21 @@
+import { Body, Controller, Get, Put } from '@nestjs/common';
+import { RequirePermission } from '../auth/guards';
+import { CartService } from './cart.service';
+import { UpdateCartExpiryConfigDto } from './cart.dto';
+
+@Controller('admin/cart-expiry')
+export class AdminCartExpiryController {
+ constructor(private readonly cart: CartService) {}
+
+ @Get('config')
+ @RequirePermission('config.cart')
+ getConfig() {
+ return this.cart.getConfig();
+ }
+
+ @Put('config')
+ @RequirePermission('config.cart')
+ putConfig(@Body() dto: UpdateCartExpiryConfigDto) {
+ return this.cart.setConfig(dto as Record);
+ }
+}
diff --git a/apps/api/src/modules/cart/cart-expiry.worker.spec.ts b/apps/api/src/modules/cart/cart-expiry.worker.spec.ts
new file mode 100644
index 0000000..1c90f9b
--- /dev/null
+++ b/apps/api/src/modules/cart/cart-expiry.worker.spec.ts
@@ -0,0 +1,124 @@
+import { CartExpiryWorkerService } from './cart-expiry.worker';
+import { tehranWallToUtc } from './cart-schedule';
+
+describe('CartExpiryWorkerService', () => {
+ function build(opts: {
+ cfg?: Record;
+ carts?: Array>;
+ }) {
+ const carts = opts.carts ?? [];
+ const updates: Array<{ customerId: string; data: Record }> = [];
+ const sms = { send: jest.fn(async () => ({ ok: true })) };
+ const prisma = {
+ customerCart: {
+ findMany: jest.fn(async () => carts),
+ update: jest.fn(async ({ where, data }: { where: { customerId: string }; data: Record }) => {
+ updates.push({ customerId: where.customerId, data });
+ return {};
+ }),
+ },
+ $executeRaw: jest.fn(async () => 1),
+ };
+ const cart = {
+ getConfig: jest.fn(async () => ({
+ enabled: true,
+ clearHour: 13,
+ clearMinute: 0,
+ sameDayDeferFromHour: 8,
+ sameDayDeferFromMinute: 0,
+ reminderEnabled: true,
+ reminderHoursBefore: 2,
+ reminderBody: 'تا %count ساعت',
+ worker: 'on' as const,
+ ...opts.cfg,
+ })),
+ };
+ const worker = new CartExpiryWorkerService(prisma as never, cart as never, sms as never);
+ return { worker, sms, updates, prisma, cart };
+ }
+
+ it('no-ops when disabled', async () => {
+ const { worker, sms, prisma } = build({ cfg: { enabled: false } });
+ await worker.tick(tehranWallToUtc(2026, 11, 12, 12, 0, 0));
+ expect(sms.send).not.toHaveBeenCalled();
+ expect(prisma.customerCart.findMany).not.toHaveBeenCalled();
+ });
+
+ it('reminds carts due today (touched before 08:00), not deferred ones', async () => {
+ const todayClear = tehranWallToUtc(2026, 11, 12, 13, 0, 0);
+ const { worker, sms, updates } = build({
+ carts: [
+ {
+ customerId: 'early',
+ items: [{ pid: 'p1', vid: 'v1', qty: 1 }],
+ reminderSentForClearAt: null,
+ updatedAt: tehranWallToUtc(2026, 11, 12, 7, 0, 0),
+ customer: { mobile: '09120000000', isActive: true },
+ },
+ {
+ customerId: 'deferred',
+ items: [{ pid: 'p2', vid: 'v2', qty: 1 }],
+ reminderSentForClearAt: null,
+ // 10:00 → deadline tomorrow 13 — no reminder yet at 11:30 today
+ updatedAt: tehranWallToUtc(2026, 11, 12, 10, 0, 0),
+ customer: { mobile: '09121111111', isActive: true },
+ },
+ ],
+ });
+ await worker.tick(tehranWallToUtc(2026, 11, 12, 11, 30, 0));
+ expect(sms.send).toHaveBeenCalledTimes(1);
+ expect(sms.send).toHaveBeenCalledWith('09120000000', expect.any(String));
+ expect(updates[0]?.customerId).toBe('early');
+ expect(updates[0]?.data.reminderSentForClearAt).toEqual(todayClear);
+ });
+
+ it('does not clear 08–13 carts at today 13; clears pre-08 carts', async () => {
+ const { worker, updates, sms } = build({
+ carts: [
+ {
+ customerId: 'early',
+ items: [{ pid: 'p1', vid: 'v1', qty: 1 }],
+ reminderSentForClearAt: null,
+ updatedAt: tehranWallToUtc(2026, 11, 12, 7, 0, 0),
+ customer: { mobile: '09120000000', isActive: true },
+ },
+ {
+ customerId: 'deferred',
+ items: [{ pid: 'p2', vid: 'v2', qty: 1 }],
+ reminderSentForClearAt: null,
+ updatedAt: tehranWallToUtc(2026, 11, 12, 10, 0, 0),
+ customer: { mobile: '09121111111', isActive: true },
+ },
+ {
+ customerId: 'afternoon',
+ items: [{ pid: 'p3', vid: 'v3', qty: 1 }],
+ reminderSentForClearAt: null,
+ updatedAt: tehranWallToUtc(2026, 11, 12, 14, 0, 0),
+ customer: { mobile: '09122222222', isActive: true },
+ },
+ ],
+ });
+ await worker.tick(tehranWallToUtc(2026, 11, 12, 13, 5, 0));
+ expect(sms.send).not.toHaveBeenCalled();
+ expect(updates).toHaveLength(1);
+ expect(updates[0].customerId).toBe('early');
+ expect(updates[0].data.items).toEqual([]);
+ });
+
+ it('clears deferred (08–13) carts at tomorrow 13', async () => {
+ const { worker, updates } = build({
+ carts: [
+ {
+ customerId: 'deferred',
+ items: [{ pid: 'p2', vid: 'v2', qty: 1 }],
+ reminderSentForClearAt: null,
+ updatedAt: tehranWallToUtc(2026, 11, 12, 10, 0, 0),
+ customer: { mobile: '09121111111', isActive: true },
+ },
+ ],
+ });
+ await worker.tick(tehranWallToUtc(2026, 11, 13, 13, 5, 0));
+ expect(updates).toHaveLength(1);
+ expect(updates[0].customerId).toBe('deferred');
+ });
+});
diff --git a/apps/api/src/modules/cart/cart-expiry.worker.ts b/apps/api/src/modules/cart/cart-expiry.worker.ts
new file mode 100644
index 0000000..7db4dc6
--- /dev/null
+++ b/apps/api/src/modules/cart/cart-expiry.worker.ts
@@ -0,0 +1,140 @@
+import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
+import { Prisma } from '@prisma/client';
+import { randomUUID } from 'crypto';
+import { PrismaService } from '../../prisma/prisma.service';
+import { SmsService } from '../sms/sms.service';
+import {
+ CART_EXPIRY_LEASE_KEY,
+ cartItemsNonEmpty,
+ clearDeadlineForTouchedAt,
+ formatReminderBody,
+ reminderAtForDeadline,
+ type CartExpiryConfig,
+} from './cart-schedule';
+import { CartService } from './cart.service';
+
+const TICK_MS = 60_000;
+const LEASE_MS = 90_000;
+
+/**
+ * Daily cart clear + reminder SMS. Same in-process lease pattern as
+ * AccountingWorkerService — no @nestjs/schedule.
+ *
+ * Clear/reminder deadlines are per cart from `updatedAt` (same-day defer
+ * window: touch in [08:00, 13:00) → clear tomorrow 13:00).
+ */
+@Injectable()
+export class CartExpiryWorkerService implements OnApplicationBootstrap, OnModuleDestroy {
+ private readonly logger = new Logger('CartExpiry');
+ private readonly owner = randomUUID();
+ private timer: NodeJS.Timeout | null = null;
+ private running = false;
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly cart: CartService,
+ private readonly sms: SmsService,
+ ) {}
+
+ onApplicationBootstrap() {
+ if (process.env.NODE_ENV === 'test') return;
+ this.timer = setInterval(() => void this.tick(), TICK_MS);
+ this.timer.unref?.();
+ }
+
+ onModuleDestroy() {
+ if (this.timer) clearInterval(this.timer);
+ }
+
+ /** Exposed for unit tests. */
+ async tick(now = new Date()): Promise {
+ if (this.running) return;
+ this.running = true;
+ try {
+ const cfg = await this.cart.getConfig();
+ if (!cfg.enabled || cfg.worker === 'off') return;
+ if (!(await this.acquireLease())) return;
+ await this.sendReminders(cfg, now);
+ await this.clearDueCarts(cfg, now);
+ } catch (err) {
+ this.logger.error(
+ `cart expiry tick failed: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ } finally {
+ this.running = false;
+ }
+ }
+
+ private async sendReminders(cfg: CartExpiryConfig, now: Date): Promise {
+ if (!cfg.reminderEnabled) return;
+
+ const carts = await this.prisma.customerCart.findMany({
+ include: {
+ customer: { select: { mobile: true, isActive: true } },
+ },
+ });
+
+ const body = formatReminderBody(cfg.reminderBody, cfg.reminderHoursBefore);
+
+ for (const row of carts) {
+ if (!cartItemsNonEmpty(row.items)) continue;
+ if (!row.customer.isActive || !row.customer.mobile) continue;
+
+ const deadline = clearDeadlineForTouchedAt(row.updatedAt, cfg);
+ const reminderAt = reminderAtForDeadline(deadline, cfg.reminderHoursBefore);
+ if (now.getTime() < reminderAt.getTime()) continue;
+ if (now.getTime() >= deadline.getTime()) continue;
+ if (
+ row.reminderSentForClearAt &&
+ row.reminderSentForClearAt.getTime() === deadline.getTime()
+ ) {
+ continue;
+ }
+ try {
+ await this.sms.send(row.customer.mobile, body);
+ await this.prisma.customerCart.update({
+ where: { customerId: row.customerId },
+ data: { reminderSentForClearAt: deadline },
+ });
+ } catch (err) {
+ this.logger.warn(
+ `reminder SMS failed for ${row.customerId}: ${
+ err instanceof Error ? err.message : String(err)
+ }`,
+ );
+ }
+ }
+ }
+
+ private async clearDueCarts(cfg: CartExpiryConfig, now: Date): Promise {
+ const carts = await this.prisma.customerCart.findMany();
+ for (const row of carts) {
+ if (!cartItemsNonEmpty(row.items)) continue;
+ const deadline = clearDeadlineForTouchedAt(row.updatedAt, cfg);
+ if (now.getTime() < deadline.getTime()) continue;
+ await this.prisma.customerCart.update({
+ where: { customerId: row.customerId },
+ data: {
+ items: [] as Prisma.InputJsonValue,
+ clearedAt: now,
+ reminderSentForClearAt: null,
+ },
+ });
+ }
+ }
+
+ private async acquireLease(): Promise {
+ const value = JSON.stringify({
+ owner: this.owner,
+ until: new Date(Date.now() + LEASE_MS).toISOString(),
+ });
+ await this.prisma.$executeRaw`
+ INSERT INTO "Setting" ("key", "value", "updatedAt") VALUES (${CART_EXPIRY_LEASE_KEY}, ${value}::jsonb, now())
+ ON CONFLICT ("key") DO NOTHING`;
+ const n = await this.prisma.$executeRaw`
+ UPDATE "Setting" SET "value" = ${value}::jsonb, "updatedAt" = now()
+ WHERE "key" = ${CART_EXPIRY_LEASE_KEY}
+ AND ("value"->>'owner' = ${this.owner} OR ("value"->>'until')::timestamptz < now())`;
+ return n > 0;
+ }
+}
diff --git a/apps/api/src/modules/cart/cart-schedule.spec.ts b/apps/api/src/modules/cart/cart-schedule.spec.ts
new file mode 100644
index 0000000..7ab6848
--- /dev/null
+++ b/apps/api/src/modules/cart/cart-schedule.spec.ts
@@ -0,0 +1,100 @@
+import {
+ DEFAULT_CART_EXPIRY,
+ clearDeadlineForTouchedAt,
+ formatReminderBody,
+ nextClearAt,
+ nextReminderAt,
+ parseCartExpiryConfig,
+ tehranParts,
+ tehranWallToUtc,
+ todayClearAt,
+} from './cart-schedule';
+
+const deferCfg = {
+ clearHour: 13,
+ clearMinute: 0,
+ sameDayDeferFromHour: 8,
+ sameDayDeferFromMinute: 0,
+};
+
+describe('cart-schedule', () => {
+ it('parses defaults including same-day defer from 08:00', () => {
+ const cfg = parseCartExpiryConfig({});
+ expect(cfg.enabled).toBe(false);
+ expect(cfg.clearHour).toBe(13);
+ expect(cfg.sameDayDeferFromHour).toBe(8);
+ expect(cfg.reminderHoursBefore).toBe(2);
+ });
+
+ it('clamps hour/minute and keeps custom body', () => {
+ const cfg = parseCartExpiryConfig({
+ enabled: true,
+ clearHour: 99,
+ clearMinute: -3,
+ reminderHoursBefore: '4',
+ reminderBody: 'سبد تا %count ساعت دیگر',
+ worker: 'off',
+ });
+ expect(cfg.enabled).toBe(true);
+ expect(cfg.clearHour).toBe(23);
+ expect(cfg.clearMinute).toBe(0);
+ expect(cfg.reminderHoursBefore).toBe(4);
+ expect(cfg.reminderBody).toContain('%count');
+ expect(cfg.worker).toBe('off');
+ });
+
+ it('todayClearAt is today at the configured Tehran wall time', () => {
+ const now = tehranWallToUtc(2026, 11, 12, 10, 0, 0);
+ const clear = todayClearAt(now, 13, 0);
+ expect(tehranParts(clear)).toMatchObject({
+ year: 2026,
+ month: 11,
+ day: 12,
+ hour: 13,
+ minute: 0,
+ });
+ });
+
+ it('nextClearAt is today before the clear, else tomorrow', () => {
+ const morning = tehranWallToUtc(2026, 11, 12, 11, 0, 0);
+ const afternoon = tehranWallToUtc(2026, 11, 12, 14, 0, 0);
+ expect(tehranParts(nextClearAt(morning, 13, 0))).toMatchObject({ day: 12, hour: 13 });
+ expect(tehranParts(nextClearAt(afternoon, 13, 0))).toMatchObject({ day: 13, hour: 13 });
+ });
+
+ it('nextReminderAt is clear minus reminderHoursBefore', () => {
+ const morning = tehranWallToUtc(2026, 11, 12, 10, 0, 0);
+ const reminder = nextReminderAt(morning, 13, 0, 2);
+ expect(tehranParts(reminder)).toMatchObject({ day: 12, hour: 11, minute: 0 });
+ });
+
+ it('clearDeadline: before 08:00 → same-day 13:00', () => {
+ const touched = tehranWallToUtc(2026, 11, 12, 7, 30, 0);
+ const deadline = clearDeadlineForTouchedAt(touched, deferCfg);
+ expect(tehranParts(deadline)).toMatchObject({ day: 12, hour: 13 });
+ });
+
+ it('clearDeadline: 08:00..13:00 → tomorrow 13:00', () => {
+ const atEight = tehranWallToUtc(2026, 11, 12, 8, 0, 0);
+ const atTen = tehranWallToUtc(2026, 11, 12, 10, 0, 0);
+ const atNoon = tehranWallToUtc(2026, 11, 12, 12, 59, 0);
+ for (const touched of [atEight, atTen, atNoon]) {
+ expect(tehranParts(clearDeadlineForTouchedAt(touched, deferCfg))).toMatchObject({
+ day: 13,
+ hour: 13,
+ });
+ }
+ });
+
+ it('clearDeadline: at/after 13:00 → tomorrow 13:00', () => {
+ const touched = tehranWallToUtc(2026, 11, 12, 14, 0, 0);
+ expect(tehranParts(clearDeadlineForTouchedAt(touched, deferCfg))).toMatchObject({
+ day: 13,
+ hour: 13,
+ });
+ });
+
+ it('formatReminderBody replaces %count', () => {
+ expect(formatReminderBody(DEFAULT_CART_EXPIRY.reminderBody, 2)).toContain('2');
+ });
+});
diff --git a/apps/api/src/modules/cart/cart-schedule.ts b/apps/api/src/modules/cart/cart-schedule.ts
new file mode 100644
index 0000000..ed6e202
--- /dev/null
+++ b/apps/api/src/modules/cart/cart-schedule.ts
@@ -0,0 +1,238 @@
+/**
+ * Tehran wall-clock helpers for daily cart expiry.
+ * No luxon/dayjs — Intl + UTC offset correction only.
+ */
+
+export const CART_EXPIRY_SETTING_KEY = 'cart.expiry';
+export const CART_EXPIRY_LEASE_KEY = 'cart.expiry.lease';
+
+export type CartExpiryConfig = {
+ enabled: boolean;
+ clearHour: number;
+ clearMinute: number;
+ /**
+ * Same-day defer window start (Tehran). Items touched in
+ * [sameDayDeferFrom, clear) expire at tomorrow's clear, not today's.
+ * Default 08:00 → 13:00 same day maps to 13:00 next day.
+ */
+ sameDayDeferFromHour: number;
+ sameDayDeferFromMinute: number;
+ reminderEnabled: boolean;
+ reminderHoursBefore: number;
+ reminderBody: string;
+ worker: 'on' | 'off';
+};
+
+export const DEFAULT_CART_EXPIRY: CartExpiryConfig = {
+ enabled: false,
+ clearHour: 13,
+ clearMinute: 0,
+ sameDayDeferFromHour: 8,
+ sameDayDeferFromMinute: 0,
+ reminderEnabled: true,
+ reminderHoursBefore: 2,
+ reminderBody:
+ 'سبد خرید شما تا %count ساعت دیگر خالی میشود. برای ثبت سفارش همین امروز اقدام کنید.',
+ worker: 'on',
+};
+
+export function parseCartExpiryConfig(raw: unknown): CartExpiryConfig {
+ const o = raw && typeof raw === 'object' ? (raw as Record) : {};
+ const clearHour = clampInt(o.clearHour, 0, 23, DEFAULT_CART_EXPIRY.clearHour);
+ const clearMinute = clampInt(o.clearMinute, 0, 59, DEFAULT_CART_EXPIRY.clearMinute);
+ const sameDayDeferFromHour = clampInt(
+ o.sameDayDeferFromHour,
+ 0,
+ 23,
+ DEFAULT_CART_EXPIRY.sameDayDeferFromHour,
+ );
+ const sameDayDeferFromMinute = clampInt(
+ o.sameDayDeferFromMinute,
+ 0,
+ 59,
+ DEFAULT_CART_EXPIRY.sameDayDeferFromMinute,
+ );
+ const reminderHoursBefore = Math.max(
+ 0,
+ clampInt(o.reminderHoursBefore, 0, 48, DEFAULT_CART_EXPIRY.reminderHoursBefore),
+ );
+ return {
+ enabled: o.enabled === true,
+ clearHour,
+ clearMinute,
+ sameDayDeferFromHour,
+ sameDayDeferFromMinute,
+ reminderEnabled: o.reminderEnabled !== false,
+ reminderHoursBefore,
+ reminderBody:
+ typeof o.reminderBody === 'string' && o.reminderBody.trim()
+ ? o.reminderBody.trim()
+ : DEFAULT_CART_EXPIRY.reminderBody,
+ worker: o.worker === 'off' ? 'off' : 'on',
+ };
+}
+
+function clampInt(v: unknown, min: number, max: number, fallback: number): number {
+ const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN;
+ if (!Number.isFinite(n)) return fallback;
+ return Math.min(max, Math.max(min, Math.trunc(n)));
+}
+
+type TehranParts = {
+ year: number;
+ month: number;
+ day: number;
+ hour: number;
+ minute: number;
+ second: number;
+};
+
+export function tehranParts(date: Date): TehranParts {
+ const fmt = new Intl.DateTimeFormat('en-US', {
+ timeZone: 'Asia/Tehran',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hourCycle: 'h23',
+ });
+ const bag: Record = {};
+ for (const p of fmt.formatToParts(date)) {
+ if (p.type !== 'literal') bag[p.type] = p.value;
+ }
+ return {
+ year: Number(bag.year),
+ month: Number(bag.month),
+ day: Number(bag.day),
+ hour: Number(bag.hour),
+ minute: Number(bag.minute),
+ second: Number(bag.second),
+ };
+}
+
+/** Instant corresponding to a Tehran civil date-time. */
+export function tehranWallToUtc(
+ year: number,
+ month: number,
+ day: number,
+ hour: number,
+ minute: number,
+ second = 0,
+): Date {
+ const utcGuess = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
+ const shown = tehranParts(utcGuess);
+ const wantedMs = Date.UTC(year, month - 1, day, hour, minute, second);
+ const shownMs = Date.UTC(
+ shown.year,
+ shown.month - 1,
+ shown.day,
+ shown.hour,
+ shown.minute,
+ shown.second,
+ );
+ return new Date(utcGuess.getTime() + (wantedMs - shownMs));
+}
+
+export function addTehranDays(
+ year: number,
+ month: number,
+ day: number,
+ delta: number,
+): {
+ year: number;
+ month: number;
+ day: number;
+} {
+ // Noon UTC avoids DST edge when shifting calendar days via Date.
+ const noon = tehranWallToUtc(year, month, day, 12, 0, 0);
+ const shifted = new Date(noon.getTime() + delta * 24 * 60 * 60 * 1000);
+ const p = tehranParts(shifted);
+ return { year: p.year, month: p.month, day: p.day };
+}
+
+/** Today's clear instant in Tehran for the given hour/minute. */
+export function todayClearAt(now: Date, clearHour: number, clearMinute: number): Date {
+ const p = tehranParts(now);
+ return tehranWallToUtc(p.year, p.month, p.day, clearHour, clearMinute, 0);
+}
+
+/** Nearest upcoming wall clear (ignores per-cart defer). */
+export function nextClearAt(now: Date, clearHour: number, clearMinute: number): Date {
+ const today = todayClearAt(now, clearHour, clearMinute);
+ if (now.getTime() < today.getTime()) return today;
+ const p = tehranParts(now);
+ const tmr = addTehranDays(p.year, p.month, p.day, 1);
+ return tehranWallToUtc(tmr.year, tmr.month, tmr.day, clearHour, clearMinute, 0);
+}
+
+export function nextReminderAt(
+ now: Date,
+ clearHour: number,
+ clearMinute: number,
+ reminderHoursBefore: number,
+): Date {
+ const clear = nextClearAt(now, clearHour, clearMinute);
+ return new Date(clear.getTime() - reminderHoursBefore * 60 * 60 * 1000);
+}
+
+/**
+ * Per-cart clear deadline from last touch time.
+ *
+ * With defer 08:00 and clear 13:00:
+ * - touched 07:00 → clear same day 13:00
+ * - touched 08:00..12:59 → clear next day 13:00
+ * - touched ≥ 13:00 → clear next day 13:00
+ */
+export function clearDeadlineForTouchedAt(
+ touchedAt: Date,
+ cfg: Pick<
+ CartExpiryConfig,
+ | 'clearHour'
+ | 'clearMinute'
+ | 'sameDayDeferFromHour'
+ | 'sameDayDeferFromMinute'
+ >,
+): Date {
+ const p = tehranParts(touchedAt);
+ const dayClear = tehranWallToUtc(p.year, p.month, p.day, cfg.clearHour, cfg.clearMinute, 0);
+ const deferFrom = tehranWallToUtc(
+ p.year,
+ p.month,
+ p.day,
+ cfg.sameDayDeferFromHour,
+ cfg.sameDayDeferFromMinute,
+ 0,
+ );
+ const tomorrowClear = (): Date => {
+ const tmr = addTehranDays(p.year, p.month, p.day, 1);
+ return tehranWallToUtc(tmr.year, tmr.month, tmr.day, cfg.clearHour, cfg.clearMinute, 0);
+ };
+
+ if (touchedAt.getTime() >= dayClear.getTime()) {
+ return tomorrowClear();
+ }
+ // In [deferFrom, clear) → skip today's clear.
+ if (touchedAt.getTime() >= deferFrom.getTime()) {
+ return tomorrowClear();
+ }
+ return dayClear;
+}
+
+export function reminderAtForDeadline(
+ deadline: Date,
+ reminderHoursBefore: number,
+): Date {
+ return new Date(deadline.getTime() - reminderHoursBefore * 60 * 60 * 1000);
+}
+
+/** Format reminder body; `%count` → hours until clear (at least 1 when >0). */
+export function formatReminderBody(template: string, reminderHoursBefore: number): string {
+ const count = reminderHoursBefore <= 0 ? '۰' : String(reminderHoursBefore);
+ return template.replace(/%count/g, count);
+}
+
+export function cartItemsNonEmpty(items: unknown): boolean {
+ return Array.isArray(items) && items.length > 0;
+}
diff --git a/apps/api/src/modules/cart/cart.controller.ts b/apps/api/src/modules/cart/cart.controller.ts
new file mode 100644
index 0000000..e1cdce9
--- /dev/null
+++ b/apps/api/src/modules/cart/cart.controller.ts
@@ -0,0 +1,52 @@
+import { Body, Controller, Get, Put } from '@nestjs/common';
+import { Public } from '../auth/guards';
+import {
+ CustomerAuth,
+ CurrentCustomer,
+} from '../customer-auth/customer.guards';
+import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strategy';
+import { CartService } from './cart.service';
+import { PutCartDto } from './cart.dto';
+
+@Controller('cart')
+export class CartController {
+ constructor(private readonly cart: CartService) {}
+
+ /** Safe expiry policy for guest + logged-in clients. */
+ @Public()
+ @Get('policy')
+ getPolicy() {
+ return this.cart.getPolicy();
+ }
+
+ @CustomerAuth()
+ @Get()
+ getMine(@CurrentCustomer() me: AuthenticatedCustomer) {
+ return this.cart.getCart(me.id);
+ }
+
+ @CustomerAuth()
+ @Put()
+ putMine(
+ @CurrentCustomer() me: AuthenticatedCustomer,
+ @Body() dto: PutCartDto,
+ ) {
+ return this.cart.putCart(me.id, dto.items);
+ }
+
+ /** Deferred «خرید بعدی» — not cleared by cart expiry or order submit. */
+ @CustomerAuth()
+ @Get('buy-later')
+ getBuyLater(@CurrentCustomer() me: AuthenticatedCustomer) {
+ return this.cart.getBuyLater(me.id);
+ }
+
+ @CustomerAuth()
+ @Put('buy-later')
+ putBuyLater(
+ @CurrentCustomer() me: AuthenticatedCustomer,
+ @Body() dto: PutCartDto,
+ ) {
+ return this.cart.putBuyLater(me.id, dto.items);
+ }
+}
diff --git a/apps/api/src/modules/cart/cart.dto.ts b/apps/api/src/modules/cart/cart.dto.ts
new file mode 100644
index 0000000..8f1a64e
--- /dev/null
+++ b/apps/api/src/modules/cart/cart.dto.ts
@@ -0,0 +1,70 @@
+import { Type } from 'class-transformer';
+import {
+ IsArray,
+ IsBoolean,
+ IsInt,
+ IsOptional,
+ IsString,
+ Max,
+ MaxLength,
+ Min,
+} from 'class-validator';
+
+export class PutCartDto {
+ @IsArray()
+ items!: unknown[];
+}
+
+export class UpdateCartExpiryConfigDto {
+ @IsOptional()
+ @IsBoolean()
+ enabled?: boolean;
+
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(0)
+ @Max(23)
+ clearHour?: number;
+
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(0)
+ @Max(59)
+ clearMinute?: number;
+
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(0)
+ @Max(23)
+ sameDayDeferFromHour?: number;
+
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(0)
+ @Max(59)
+ sameDayDeferFromMinute?: number;
+
+ @IsOptional()
+ @IsBoolean()
+ reminderEnabled?: boolean;
+
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(0)
+ @Max(48)
+ reminderHoursBefore?: number;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(500)
+ reminderBody?: string;
+
+ @IsOptional()
+ @IsString()
+ worker?: 'on' | 'off';
+}
diff --git a/apps/api/src/modules/cart/cart.module.ts b/apps/api/src/modules/cart/cart.module.ts
new file mode 100644
index 0000000..62f52b2
--- /dev/null
+++ b/apps/api/src/modules/cart/cart.module.ts
@@ -0,0 +1,14 @@
+import { Module } from '@nestjs/common';
+import { SmsModule } from '../sms/sms.module';
+import { AdminCartExpiryController } from './admin-cart-expiry.controller';
+import { CartController } from './cart.controller';
+import { CartExpiryWorkerService } from './cart-expiry.worker';
+import { CartService } from './cart.service';
+
+@Module({
+ imports: [SmsModule],
+ controllers: [CartController, AdminCartExpiryController],
+ providers: [CartService, CartExpiryWorkerService],
+ exports: [CartService],
+})
+export class CartModule {}
diff --git a/apps/api/src/modules/cart/cart.service.spec.ts b/apps/api/src/modules/cart/cart.service.spec.ts
new file mode 100644
index 0000000..e7bbfa3
--- /dev/null
+++ b/apps/api/src/modules/cart/cart.service.spec.ts
@@ -0,0 +1,104 @@
+import { BadRequestException } from '@nestjs/common';
+import { CartService } from './cart.service';
+import { CART_EXPIRY_SETTING_KEY } from './cart-schedule';
+
+describe('CartService', () => {
+ function build(settingValue: unknown = {}) {
+ const carts = new Map>();
+ const prisma = {
+ setting: {
+ findUnique: jest.fn(async ({ where }: { where: { key: string } }) =>
+ where.key === CART_EXPIRY_SETTING_KEY ? { value: settingValue } : null,
+ ),
+ upsert: jest.fn(async ({ create, update }: { create: { value: unknown }; update: { value: unknown } }) => ({
+ value: update?.value ?? create.value,
+ })),
+ },
+ customerCart: {
+ findUnique: jest.fn(async ({ where }: { where: { customerId: string } }) =>
+ carts.get(where.customerId) ?? null,
+ ),
+ upsert: jest.fn(async ({ where, create, update }: {
+ where: { customerId: string };
+ create: Record;
+ update: Record;
+ }) => {
+ const prev = carts.get(where.customerId);
+ const row = {
+ customerId: where.customerId,
+ updatedAt: new Date('2026-11-12T08:00:00.000Z'),
+ ...(prev ?? {}),
+ ...(prev ? update : create),
+ };
+ carts.set(where.customerId, row);
+ return row;
+ }),
+ },
+ };
+ const service = new CartService(prisma as never);
+ return { service, prisma, carts };
+ }
+
+ it('getPolicy exposes todayClearAt and nextClearAt when enabled', async () => {
+ const { service } = build({ enabled: true, clearHour: 13, clearMinute: 0 });
+ const policy = await service.getPolicy(new Date('2026-11-12T07:00:00.000Z'));
+ expect(policy.enabled).toBe(true);
+ expect(policy.clearHour).toBe(13);
+ expect(policy.todayClearAt).toBeTruthy();
+ expect(policy.nextClearAt).toBeTruthy();
+ });
+
+ it('putCart rejects non-array items', async () => {
+ const { service } = build();
+ await expect(service.putCart('c1', {} as never)).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it('putCart upserts and clearForCustomer empties', async () => {
+ const { service, carts } = build();
+ await service.putCart('c1', [{ pid: 'p', vid: 'v', qty: 2 }]);
+ expect((carts.get('c1')?.items as unknown[]).length).toBe(1);
+ await service.clearForCustomer('c1');
+ expect(carts.get('c1')?.items).toEqual([]);
+ });
+
+ it('setConfig writes a parsed config', async () => {
+ const { service, prisma } = build();
+ const cfg = await service.setConfig({ enabled: true, clearHour: 14, reminderHoursBefore: 3 });
+ expect(cfg.enabled).toBe(true);
+ expect(cfg.clearHour).toBe(14);
+ expect(cfg.reminderHoursBefore).toBe(3);
+ expect(prisma.setting.upsert).toHaveBeenCalled();
+ });
+
+ it('buy-later upserts independently of the active cart', async () => {
+ const { service, prisma, carts } = build();
+ const buyLater = new Map>();
+ (prisma as any).customerBuyLater = {
+ findUnique: jest.fn(async ({ where }: { where: { customerId: string } }) =>
+ buyLater.get(where.customerId) ?? null,
+ ),
+ upsert: jest.fn(async ({ where, create, update }: {
+ where: { customerId: string };
+ create: Record;
+ update: Record;
+ }) => {
+ const prev = buyLater.get(where.customerId);
+ const row = {
+ customerId: where.customerId,
+ updatedAt: new Date(),
+ ...(prev ?? {}),
+ ...(prev ? update : create),
+ };
+ buyLater.set(where.customerId, row);
+ return row;
+ }),
+ };
+ await service.putBuyLater('c1', [{ pid: 'p', vid: 'v', qty: 3 }]);
+ expect((buyLater.get('c1')?.items as unknown[]).length).toBe(1);
+ await service.putCart('c1', [{ pid: 'p2', vid: 'v2', qty: 1 }]);
+ // Order clear must not wipe buy-later — clearForCustomer only touches CustomerCart.
+ await service.clearForCustomer('c1');
+ expect(carts.get('c1')?.items).toEqual([]);
+ expect((buyLater.get('c1')?.items as unknown[]).length).toBe(1);
+ });
+});
diff --git a/apps/api/src/modules/cart/cart.service.ts b/apps/api/src/modules/cart/cart.service.ts
new file mode 100644
index 0000000..817e21d
--- /dev/null
+++ b/apps/api/src/modules/cart/cart.service.ts
@@ -0,0 +1,180 @@
+import { BadRequestException, Injectable } from '@nestjs/common';
+import { Prisma } from '@prisma/client';
+import { PrismaService } from '../../prisma/prisma.service';
+import {
+ CART_EXPIRY_SETTING_KEY,
+ DEFAULT_CART_EXPIRY,
+ nextClearAt,
+ nextReminderAt,
+ parseCartExpiryConfig,
+ todayClearAt,
+ type CartExpiryConfig,
+} from './cart-schedule';
+
+@Injectable()
+export class CartService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ async getConfig(): Promise {
+ const row = await this.prisma.setting.findUnique({
+ where: { key: CART_EXPIRY_SETTING_KEY },
+ });
+ return parseCartExpiryConfig(row?.value ?? {});
+ }
+
+ async setConfig(raw: Record): Promise {
+ const next = parseCartExpiryConfig({ ...DEFAULT_CART_EXPIRY, ...raw });
+ await this.prisma.setting.upsert({
+ where: { key: CART_EXPIRY_SETTING_KEY },
+ update: { value: next as unknown as Prisma.InputJsonValue },
+ create: {
+ key: CART_EXPIRY_SETTING_KEY,
+ value: next as unknown as Prisma.InputJsonValue,
+ },
+ });
+ return next;
+ }
+
+ /** Public policy for the app (no secrets). */
+ async getPolicy(now = new Date()) {
+ const cfg = await this.getConfig();
+ const today = todayClearAt(now, cfg.clearHour, cfg.clearMinute);
+ const clear = nextClearAt(now, cfg.clearHour, cfg.clearMinute);
+ const reminder = nextReminderAt(
+ now,
+ cfg.clearHour,
+ cfg.clearMinute,
+ cfg.reminderHoursBefore,
+ );
+ return {
+ enabled: cfg.enabled,
+ clearHour: cfg.clearHour,
+ clearMinute: cfg.clearMinute,
+ sameDayDeferFromHour: cfg.sameDayDeferFromHour,
+ sameDayDeferFromMinute: cfg.sameDayDeferFromMinute,
+ reminderEnabled: cfg.reminderEnabled,
+ reminderHoursBefore: cfg.reminderHoursBefore,
+ /** Today's clear wall-time (may already be in the past). */
+ todayClearAt: today.toISOString(),
+ nextClearAt: clear.toISOString(),
+ nextReminderAt: reminder.toISOString(),
+ };
+ }
+
+ async getCart(customerId: string) {
+ const row = await this.prisma.customerCart.findUnique({
+ where: { customerId },
+ });
+ return {
+ items: (row?.items as unknown[]) ?? [],
+ updatedAt: row?.updatedAt?.toISOString() ?? null,
+ clearedAt: row?.clearedAt?.toISOString() ?? null,
+ };
+ }
+
+ async putCart(customerId: string, items: unknown) {
+ if (!Array.isArray(items)) {
+ throw new BadRequestException('items must be an array');
+ }
+ // Cap size so a buggy client cannot dump megabytes into Setting-adjacent JSON.
+ if (items.length > 200) {
+ throw new BadRequestException('cart too large');
+ }
+ const empty = items.length === 0;
+ const row = await this.prisma.customerCart.upsert({
+ where: { customerId },
+ create: {
+ customerId,
+ items: items as Prisma.InputJsonValue,
+ clearedAt: empty ? new Date() : null,
+ reminderSentForClearAt: null,
+ },
+ update: {
+ items: items as Prisma.InputJsonValue,
+ ...(empty
+ ? { clearedAt: new Date(), reminderSentForClearAt: null }
+ : { clearedAt: null }),
+ },
+ });
+ return {
+ items: (row.items as unknown[]) ?? [],
+ updatedAt: row.updatedAt.toISOString(),
+ clearedAt: row.clearedAt?.toISOString() ?? null,
+ };
+ }
+
+ /** After a successful order — mirror client cart.clear(). */
+ async clearForCustomer(customerId: string): Promise {
+ await this.prisma.customerCart.upsert({
+ where: { customerId },
+ create: {
+ customerId,
+ items: [] as Prisma.InputJsonValue,
+ clearedAt: new Date(),
+ reminderSentForClearAt: null,
+ },
+ update: {
+ items: [] as Prisma.InputJsonValue,
+ clearedAt: new Date(),
+ reminderSentForClearAt: null,
+ },
+ });
+ }
+
+ /**
+ * «خرید بعدی» — deferred list. No stock touch, no expiry worker.
+ * Order clear leaves this alone on purpose.
+ */
+ async getBuyLater(customerId: string) {
+ const row = await this.buyLaterTable().findUnique({
+ where: { customerId },
+ });
+ return {
+ items: (row?.items as unknown[]) ?? [],
+ updatedAt: row?.updatedAt?.toISOString() ?? null,
+ };
+ }
+
+ async putBuyLater(customerId: string, items: unknown) {
+ if (!Array.isArray(items)) {
+ throw new BadRequestException('items must be an array');
+ }
+ if (items.length > 200) {
+ throw new BadRequestException('buy-later list too large');
+ }
+ const row = await this.buyLaterTable().upsert({
+ where: { customerId },
+ create: {
+ customerId,
+ items: items as Prisma.InputJsonValue,
+ },
+ update: {
+ items: items as Prisma.InputJsonValue,
+ },
+ });
+ return {
+ items: (row.items as unknown[]) ?? [],
+ updatedAt: row.updatedAt.toISOString(),
+ };
+ }
+
+ /**
+ * Prisma client may lag schema until `prisma generate` (e.g. Windows file
+ * lock on the query engine). Access by name so TS still type-checks.
+ */
+ private buyLaterTable() {
+ return (this.prisma as unknown as {
+ customerBuyLater: {
+ findUnique: (args: { where: { customerId: string } }) => Promise<{
+ items: unknown;
+ updatedAt: Date;
+ } | null>;
+ upsert: (args: {
+ where: { customerId: string };
+ create: { customerId: string; items: Prisma.InputJsonValue };
+ update: { items: Prisma.InputJsonValue };
+ }) => Promise<{ items: unknown; updatedAt: Date }>;
+ };
+ }).customerBuyLater;
+ }
+}
diff --git a/apps/api/src/modules/catalog/catalog.controller.ts b/apps/api/src/modules/catalog/catalog.controller.ts
index 725ceff..d9234a4 100644
--- a/apps/api/src/modules/catalog/catalog.controller.ts
+++ b/apps/api/src/modules/catalog/catalog.controller.ts
@@ -1,4 +1,4 @@
-import { Controller, Get, Param, Query } from '@nestjs/common';
+import { Controller, Get, HttpCode, Param, Post, Query } from '@nestjs/common';
import { CatalogService } from './catalog.service';
import { Public } from '../auth/guards';
import { ListProductsDto } from './dto/list-products.dto';
@@ -29,6 +29,8 @@ export class CatalogController {
query.categoryId !== undefined ||
query.brandId !== undefined ||
query.collectionId !== undefined ||
+ query.tag !== undefined ||
+ query.tagged !== undefined ||
query.minGram !== undefined ||
query.maxGram !== undefined ||
query.sort !== undefined ||
@@ -55,4 +57,11 @@ export class CatalogController {
byId(@Param('id') id: string) {
return this.catalog.findProductById(id);
}
+
+ /** The customer app opened this product's detail screen. */
+ @Post(':id/view')
+ @HttpCode(204)
+ async view(@Param('id') id: string) {
+ await this.catalog.recordView(id);
+ }
}
diff --git a/apps/api/src/modules/catalog/catalog.service.ts b/apps/api/src/modules/catalog/catalog.service.ts
index 621592c..257e0b4 100644
--- a/apps/api/src/modules/catalog/catalog.service.ts
+++ b/apps/api/src/modules/catalog/catalog.service.ts
@@ -75,6 +75,13 @@ export class CatalogService {
}
/** One product for its own screen, instead of fetching the catalogue to find it. */
+ /** Counts one opening of a storefront product's detail screen. Unknown or hidden ids are ignored. */
+ async recordView(id: string) {
+ const exists = await this.prisma.product.count({ where: { id, isActive: true } });
+ if (!exists) return;
+ await this.prisma.productView.create({ data: { productId: id } });
+ }
+
async findProductById(id: string) {
const [product] = await this.buildProductList({ where: { ...this.storefrontWhere({}), id } });
if (!product) throw new NotFoundException('محصول یافت نشد');
@@ -90,7 +97,7 @@ export class CatalogService {
*/
async facets() {
const where = this.storefrontWhere({});
- const [categories, brands, collections, gram] = await Promise.all([
+ const [categories, brands, collections, gram, tagged] = await Promise.all([
this.prisma.category.findMany({
where: { products: { some: where } },
select: { id: true, title: true },
@@ -111,12 +118,17 @@ export class CatalogService {
_min: { rashWeightGrams: true },
_max: { rashWeightGrams: true },
}),
+ this.prisma.product.findMany({
+ where: { ...where, NOT: { tags: { isEmpty: true } } },
+ select: { tags: true },
+ }),
]);
return {
categories,
brands,
collections,
+ tags: [...new Set(tagged.flatMap((p) => p.tags))].sort((a, b) => a.localeCompare(b, 'fa')),
// Strings, like every other weight this API returns (Decimal, not float).
gramRange: {
min: gram._min.rashWeightGrams?.toString() ?? '0',
@@ -155,6 +167,8 @@ export class CatalogService {
else if (brandIds.length > 1) where.brandId = { in: brandIds };
}
if (query.collectionId) where.collections = { some: { collectionId: query.collectionId } };
+ if (query.tag?.trim()) where.tags = { has: query.tag.trim() };
+ else if (query.tagged) where.NOT = { tags: { isEmpty: true } };
// A product is in range when at least ONE of its variants is, which is what
// the app's `limit_gram` filter meant (SearchView.vue:189-195).
diff --git a/apps/api/src/modules/catalog/dto/list-products.dto.ts b/apps/api/src/modules/catalog/dto/list-products.dto.ts
index 91452ff..097ca80 100644
--- a/apps/api/src/modules/catalog/dto/list-products.dto.ts
+++ b/apps/api/src/modules/catalog/dto/list-products.dto.ts
@@ -1,5 +1,5 @@
import { Transform, Type } from 'class-transformer';
-import { ArrayMaxSize, IsArray, IsIn, IsInt, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
+import { ArrayMaxSize, IsArray, IsBoolean, IsIn, IsInt, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
/** Page size when the caller asks for paging without naming one. */
export const DEFAULT_PAGE_SIZE = 20;
@@ -60,6 +60,17 @@ export class ListProductsDto {
@IsString()
collectionId?: string;
+ /** One label from the «برچسبها» list. */
+ @IsOptional()
+ @IsString()
+ tag?: string;
+
+ /** `1`/`true`: only products that carry at least one label — the home section. */
+ @IsOptional()
+ @Transform(({ value }: { value: unknown }) => value === true || value === '1' || value === 'true')
+ @IsBoolean()
+ tagged?: boolean;
+
/**
* Rash-weight range, matched against the product's VARIANTS: a product is in
* range when at least one of its variants is, which is what the app's own
diff --git a/apps/api/src/modules/orders/orders.module.ts b/apps/api/src/modules/orders/orders.module.ts
index 652fdb7..43c14f2 100644
--- a/apps/api/src/modules/orders/orders.module.ts
+++ b/apps/api/src/modules/orders/orders.module.ts
@@ -5,9 +5,10 @@ import { CatalogModule } from '../catalog/catalog.module';
import { AccountingModule } from '../accounting/accounting.module';
import { PricingModule } from '../pricing/pricing.module';
import { SmsModule } from '../sms/sms.module';
+import { CartModule } from '../cart/cart.module';
@Module({
- imports: [CatalogModule, AccountingModule, PricingModule, SmsModule],
+ imports: [CatalogModule, AccountingModule, PricingModule, SmsModule, CartModule],
controllers: [OrdersController],
providers: [OrdersService],
exports: [OrdersService],
diff --git a/apps/api/src/modules/orders/orders.service.spec.ts b/apps/api/src/modules/orders/orders.service.spec.ts
index 029fa61..918695e 100644
--- a/apps/api/src/modules/orders/orders.service.spec.ts
+++ b/apps/api/src/modules/orders/orders.service.spec.ts
@@ -40,7 +40,7 @@ describe('OrdersService.createOrder — sales expert credit', () => {
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
const accounting = { orderStatusReached: jest.fn() };
const wage = { quote: jest.fn() };
- const service = new OrdersService(prisma as never, catalog as never, {} as never, accounting as never, wage as never, smsStub as never);
+ const service = new OrdersService(prisma as never, catalog as never, {} as never, accounting as never, wage as never, smsStub as never, { clearForCustomer: jest.fn(async () => undefined) } as never);
return { service, created };
}
@@ -90,7 +90,7 @@ describe('OrdersService.createOrder — order ban', () => {
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
};
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
- return new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, smsStub as never);
+ return new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, smsStub as never, { clearForCustomer: jest.fn(async () => undefined) } as never);
}
const dto = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 3 }] } as never;
@@ -131,7 +131,7 @@ describe('OrdersService.createOrder — server-side making charge', () => {
...(rialWage !== undefined ? { rialWage } : {}),
})),
};
- const service = new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, smsStub as never);
+ const service = new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, smsStub as never, { clearForCustomer: jest.fn(async () => undefined) } as never);
return { service, created };
}
// A tampered request: zero making charge, a 50% discount and a rial charge.
@@ -289,6 +289,7 @@ describe('OrdersService.createOrder — channel', () => {
{ orderStatusReached: jest.fn() } as never,
wageStub as never,
smsStub as never,
+ { clearForCustomer: jest.fn(async () => undefined) } as never,
);
return { service, created };
}
@@ -354,7 +355,7 @@ describe('OrdersService.createOrder — order SMS', () => {
};
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
const sms = { send: jest.fn(async (to: string, body: string) => { sent.push({ to, body }); return { ok: true }; }) };
- const service = new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, sms as never);
+ const service = new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never, sms as never, { clearForCustomer: jest.fn(async () => undefined) } as never);
return { service, sent };
}
diff --git a/apps/api/src/modules/orders/orders.service.ts b/apps/api/src/modules/orders/orders.service.ts
index a6fb490..28f9f59 100644
--- a/apps/api/src/modules/orders/orders.service.ts
+++ b/apps/api/src/modules/orders/orders.service.ts
@@ -7,6 +7,7 @@ import { AccountingHooksService } from '../accounting-sync/accounting-hooks.serv
import { CreateOrderDto, CreateOrderItemInputDto } from './dto/create-order.dto';
import { WageService } from '../pricing/wage.service';
import { SmsService } from '../sms/sms.service';
+import { CartService } from '../cart/cart.service';
import { brandName } from '../../common/brand';
interface ComputedItem {
@@ -30,6 +31,7 @@ export class OrdersService {
private readonly accounting: AccountingHooksService,
private readonly wage: WageService,
private readonly sms: SmsService,
+ private readonly cart: CartService,
) {}
/**
@@ -215,6 +217,17 @@ export class OrdersService {
this.notifyOrderPlaced(placed.orderNumber, customer, totalGoldWeightGrams, isSuspendedOverCreditLimit);
+ // Keep server cart in sync with the app's cart.clear() after checkout.
+ if (opts.placedBy === 'customer') {
+ void this.cart.clearForCustomer(customerId).catch((err) => {
+ this.logger.warn(
+ `Failed to clear server cart after order ${placed.orderNumber}: ${
+ err instanceof Error ? err.message : String(err)
+ }`,
+ );
+ });
+ }
+
return placed;
}
diff --git a/apps/api/src/modules/product-survey/admin-product-survey.controller.ts b/apps/api/src/modules/product-survey/admin-product-survey.controller.ts
new file mode 100644
index 0000000..f4ac9c8
--- /dev/null
+++ b/apps/api/src/modules/product-survey/admin-product-survey.controller.ts
@@ -0,0 +1,60 @@
+import { Body, Controller, Get, HttpCode, Param, Patch, Post, Query } from '@nestjs/common';
+import { ProductSurveyService, type CreateSurveyInput } from './product-survey.service';
+import { CurrentAdmin, RequirePermission } from '../auth/guards';
+import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
+
+@Controller('admin/product-surveys')
+export class AdminProductSurveyController {
+ constructor(private readonly surveys: ProductSurveyService) {}
+
+ @Get()
+ @RequirePermission('product.survey')
+ list(@Query('status') status?: string) {
+ return this.surveys.listAdmin(status);
+ }
+
+ /** Surveys that already have majority likes and still need a decision. */
+ @Get('majority-alerts')
+ @RequirePermission('product.survey')
+ majorityAlerts() {
+ return this.surveys.listMajorityAlerts();
+ }
+
+ @Post()
+ @HttpCode(201)
+ @RequirePermission('product.survey')
+ create(@Body() body: CreateSurveyInput, @CurrentAdmin() admin: AuthenticatedAdmin) {
+ return this.surveys.createByAdmin(admin.id, body ?? {});
+ }
+
+ @Patch(':id')
+ @RequirePermission('product.survey')
+ update(@Param('id') id: string, @Body() body: CreateSurveyInput) {
+ return this.surveys.update(id, body ?? {});
+ }
+
+ @Post(':id/publish')
+ @HttpCode(200)
+ @RequirePermission('product.survey')
+ publish(@Param('id') id: string) {
+ return this.surveys.publish(id);
+ }
+
+ @Post(':id/close')
+ @HttpCode(200)
+ @RequirePermission('product.survey')
+ close(@Param('id') id: string) {
+ return this.surveys.close(id);
+ }
+
+ @Post(':id/decide')
+ @HttpCode(200)
+ @RequirePermission('product.survey')
+ decide(
+ @Param('id') id: string,
+ @Body() body: { decision?: unknown; note?: unknown },
+ @CurrentAdmin() admin: AuthenticatedAdmin,
+ ) {
+ return this.surveys.decide(id, admin.id, body ?? {});
+ }
+}
diff --git a/apps/api/src/modules/product-survey/customer-product-survey.controller.ts b/apps/api/src/modules/product-survey/customer-product-survey.controller.ts
new file mode 100644
index 0000000..af3c616
--- /dev/null
+++ b/apps/api/src/modules/product-survey/customer-product-survey.controller.ts
@@ -0,0 +1,39 @@
+import { Body, Controller, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
+import { ProductSurveyService, type CreateSurveyInput } from './product-survey.service';
+import { CurrentCustomer, CustomerAuth } from '../customer-auth/customer.guards';
+import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strategy';
+
+@Controller()
+export class CustomerProductSurveyController {
+ constructor(private readonly surveys: ProductSurveyService) {}
+
+ /** Open surveys currently accepting votes. */
+ @CustomerAuth()
+ @Get('customer/product-surveys')
+ listOpen(@CurrentCustomer() me: AuthenticatedCustomer) {
+ return this.surveys.listOpenForCustomer(me.id);
+ }
+
+ @CustomerAuth()
+ @Put('customer/product-surveys/:id/vote')
+ vote(
+ @Param('id') id: string,
+ @Body() body: { value?: unknown },
+ @CurrentCustomer() me: AuthenticatedCustomer,
+ ) {
+ return this.surveys.vote(id, me.id, body?.value);
+ }
+
+ @CustomerAuth()
+ @Get('customer/me/product-surveys')
+ listMine(@CurrentCustomer() me: AuthenticatedCustomer) {
+ return this.surveys.listMine(me.id);
+ }
+
+ @CustomerAuth()
+ @Post('customer/me/product-surveys')
+ @HttpCode(201)
+ create(@Body() body: CreateSurveyInput, @CurrentCustomer() me: AuthenticatedCustomer) {
+ return this.surveys.createByCustomer(me.id, body ?? {});
+ }
+}
diff --git a/apps/api/src/modules/product-survey/product-survey.module.ts b/apps/api/src/modules/product-survey/product-survey.module.ts
new file mode 100644
index 0000000..3dc4c5d
--- /dev/null
+++ b/apps/api/src/modules/product-survey/product-survey.module.ts
@@ -0,0 +1,10 @@
+import { Module } from '@nestjs/common';
+import { ProductSurveyService } from './product-survey.service';
+import { AdminProductSurveyController } from './admin-product-survey.controller';
+import { CustomerProductSurveyController } from './customer-product-survey.controller';
+
+@Module({
+ controllers: [AdminProductSurveyController, CustomerProductSurveyController],
+ providers: [ProductSurveyService],
+})
+export class ProductSurveyModule {}
diff --git a/apps/api/src/modules/product-survey/product-survey.service.spec.ts b/apps/api/src/modules/product-survey/product-survey.service.spec.ts
new file mode 100644
index 0000000..789b148
--- /dev/null
+++ b/apps/api/src/modules/product-survey/product-survey.service.spec.ts
@@ -0,0 +1,185 @@
+import { BadRequestException } from '@nestjs/common';
+import { ProductSurveyStatus, ProductSurveyVoteValue } from '@prisma/client';
+import { ProductSurveyService, SURVEY_IMAGE_PURPOSE } from './product-survey.service';
+
+function makePrisma(opts: {
+ survey?: Record | null;
+ votes?: { surveyId: string; value: ProductSurveyVoteValue; _count: { _all: number } }[];
+ file?: { url: string } | null;
+ upserted?: unknown[];
+ created?: unknown[];
+ updated?: unknown[];
+}) {
+ const created = opts.created ?? [];
+ const updated = opts.updated ?? [];
+ const upserted = opts.upserted ?? [];
+ const survey = opts.survey ?? null;
+
+ const prisma = {
+ customer: {
+ count: jest.fn(async () => 10),
+ },
+ uploadedFile: {
+ findFirst: jest.fn(async () => opts.file ?? null),
+ },
+ productSurvey: {
+ findUnique: jest.fn(async () => survey),
+ findMany: jest.fn(async () => (survey ? [survey] : [])),
+ create: jest.fn(async ({ data }: { data: Record }) => {
+ const row = {
+ id: 's1',
+ title: data.title ?? null,
+ imageUrl: data.imageUrl,
+ suggestedWeightGrams: data.suggestedWeightGrams,
+ status: data.status,
+ publishedAt: data.publishedAt ?? null,
+ decidedAt: null,
+ decisionNote: null,
+ createdAt: new Date('2026-01-01'),
+ updatedAt: new Date('2026-01-01'),
+ createdByCustomer: null,
+ createdByAdmin: null,
+ decidedByAdmin: null,
+ ...data,
+ };
+ created.push(row);
+ return row;
+ }),
+ update: jest.fn(async ({ data }: { data: Record }) => {
+ const row = { ...survey, ...data, updatedAt: new Date() };
+ updated.push(row);
+ return row;
+ }),
+ },
+ productSurveyVote: {
+ groupBy: jest.fn(async () => opts.votes ?? []),
+ findMany: jest.fn(async () => []),
+ upsert: jest.fn(async (args: unknown) => {
+ upserted.push(args);
+ return args;
+ }),
+ },
+ };
+
+ return {
+ service: new ProductSurveyService(prisma as never),
+ prisma,
+ created,
+ updated,
+ upserted,
+ };
+}
+
+describe('ProductSurveyService', () => {
+ it('admin create goes straight to OPEN', async () => {
+ const t = makePrisma({});
+ const row = await t.service.createByAdmin('a1', {
+ imageUrl: '/uploads/x.jpg',
+ suggestedWeightGrams: 12.5,
+ title: 'حلقه',
+ });
+ expect(t.created[0]).toMatchObject({
+ status: ProductSurveyStatus.OPEN,
+ createdByAdminId: 'a1',
+ imageUrl: '/uploads/x.jpg',
+ });
+ expect(row.status).toBe(ProductSurveyStatus.OPEN);
+ expect(row.likes).toBe(0);
+ });
+
+ it('customer create stays PENDING_REVIEW and requires purpose-tagged file', async () => {
+ const t = makePrisma({ file: { url: '/uploads/c.jpg' } });
+ await t.service.createByCustomer('c1', {
+ file_id: 'f1',
+ suggestedWeightGrams: 8,
+ });
+ expect(t.prisma.uploadedFile.findFirst).toHaveBeenCalledWith({
+ where: { id: 'f1', purpose: SURVEY_IMAGE_PURPOSE },
+ select: { url: true },
+ });
+ expect(t.created[0]).toMatchObject({
+ status: ProductSurveyStatus.PENDING_REVIEW,
+ createdByCustomerId: 'c1',
+ imageUrl: '/uploads/c.jpg',
+ });
+ });
+
+ it('publish only from PENDING_REVIEW', async () => {
+ const t = makePrisma({
+ survey: {
+ id: 's1',
+ status: ProductSurveyStatus.OPEN,
+ title: null,
+ imageUrl: '/u.jpg',
+ suggestedWeightGrams: 1,
+ publishedAt: new Date(),
+ decidedAt: null,
+ decisionNote: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ });
+ await expect(t.service.publish('s1')).rejects.toBeInstanceOf(BadRequestException);
+ });
+
+ it('vote upserts and rejects when not OPEN', async () => {
+ const closed = makePrisma({
+ survey: {
+ id: 's1',
+ status: ProductSurveyStatus.CLOSED,
+ title: null,
+ imageUrl: '/u.jpg',
+ suggestedWeightGrams: 1,
+ publishedAt: null,
+ decidedAt: null,
+ decisionNote: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ });
+ await expect(closed.service.vote('s1', 'c1', 'LIKE')).rejects.toBeInstanceOf(BadRequestException);
+
+ const open = makePrisma({
+ survey: {
+ id: 's1',
+ status: ProductSurveyStatus.OPEN,
+ title: null,
+ imageUrl: '/u.jpg',
+ suggestedWeightGrams: 1,
+ publishedAt: new Date(),
+ decidedAt: null,
+ decisionNote: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ votes: [{ surveyId: 's1', value: ProductSurveyVoteValue.LIKE, _count: { _all: 1 } }],
+ });
+ const r = await open.service.vote('s1', 'c1', 'like');
+ expect(open.upserted[0]).toMatchObject({
+ where: { surveyId_customerId: { surveyId: 's1', customerId: 'c1' } },
+ create: { value: ProductSurveyVoteValue.LIKE },
+ update: { value: ProductSurveyVoteValue.LIKE },
+ });
+ expect(r).toMatchObject({ myVote: 'LIKE', likes: 1, dislikes: 0 });
+ });
+
+ it('decide only when OPEN or CLOSED', async () => {
+ const t = makePrisma({
+ survey: {
+ id: 's1',
+ status: ProductSurveyStatus.APPROVED,
+ title: null,
+ imageUrl: '/u.jpg',
+ suggestedWeightGrams: 1,
+ publishedAt: null,
+ decidedAt: null,
+ decisionNote: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ },
+ });
+ await expect(t.service.decide('s1', 'a1', { decision: 'REJECTED' })).rejects.toBeInstanceOf(
+ BadRequestException,
+ );
+ });
+});
diff --git a/apps/api/src/modules/product-survey/product-survey.service.ts b/apps/api/src/modules/product-survey/product-survey.service.ts
new file mode 100644
index 0000000..e6b1d41
--- /dev/null
+++ b/apps/api/src/modules/product-survey/product-survey.service.ts
@@ -0,0 +1,432 @@
+import {
+ BadRequestException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import {
+ Prisma,
+ ProductSurveyStatus,
+ ProductSurveyVoteValue,
+} from '@prisma/client';
+import { PrismaService } from '../../prisma/prisma.service';
+
+/** Tag the customer app uses when uploading a survey design photo. */
+export const SURVEY_IMAGE_PURPOSE = 'product-survey';
+
+const STATUS_LABEL: Record = {
+ PENDING_REVIEW: 'منتظر انتشار',
+ OPEN: 'در حال رأیگیری',
+ CLOSED: 'بسته شده',
+ APPROVED: 'تأیید برای ساخت',
+ REJECTED: 'رد شده',
+};
+
+const EDITABLE: ProductSurveyStatus[] = [
+ ProductSurveyStatus.PENDING_REVIEW,
+ ProductSurveyStatus.OPEN,
+];
+
+const DECIDABLE: ProductSurveyStatus[] = [
+ ProductSurveyStatus.OPEN,
+ ProductSurveyStatus.CLOSED,
+];
+
+export type CreateSurveyInput = {
+ title?: unknown;
+ imageUrl?: unknown;
+ file_id?: unknown;
+ suggestedWeightGrams?: unknown;
+};
+
+@Injectable()
+export class ProductSurveyService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ private text(value: unknown, label: string, max: number, required = false): string | null {
+ const s = value === undefined || value === null ? '' : String(value).trim();
+ if (!s) {
+ if (required) throw new BadRequestException(`${label} الزامی است`);
+ return null;
+ }
+ if (s.length > max) throw new BadRequestException(`${label} نباید بیشتر از ${max} نویسه باشد`);
+ return s;
+ }
+
+ private weight(value: unknown): Prisma.Decimal {
+ const n = Number(value);
+ if (!Number.isFinite(n) || n <= 0 || n > 100000) {
+ throw new BadRequestException('وزن پیشنهادی باید عددی مثبت (گرم) باشد');
+ }
+ return new Prisma.Decimal(n.toFixed(4));
+ }
+
+ private async resolveImageUrl(input: CreateSurveyInput, purpose?: string): Promise {
+ const url = this.text(input.imageUrl, 'تصویر', 2000);
+ if (url) return url;
+
+ if (input.file_id !== undefined && input.file_id !== null && String(input.file_id) !== '') {
+ const where: Prisma.UploadedFileWhereInput = { id: String(input.file_id) };
+ if (purpose) where.purpose = purpose;
+ const file = await this.prisma.uploadedFile.findFirst({
+ where,
+ select: { url: true },
+ });
+ if (!file) throw new BadRequestException('تصویر معتبر نیست؛ دوباره بارگذاری کنید');
+ return file.url;
+ }
+
+ throw new BadRequestException('تصویر الزامی است');
+ }
+
+ private async voteCounts(surveyIds: string[]) {
+ if (surveyIds.length === 0) return new Map();
+ const rows = await this.prisma.productSurveyVote.groupBy({
+ by: ['surveyId', 'value'],
+ where: { surveyId: { in: surveyIds } },
+ _count: { _all: true },
+ });
+ const map = new Map();
+ for (const id of surveyIds) map.set(id, { likes: 0, dislikes: 0 });
+ for (const r of rows) {
+ const cur = map.get(r.surveyId)!;
+ if (r.value === ProductSurveyVoteValue.LIKE) cur.likes = r._count._all;
+ else cur.dislikes = r._count._all;
+ }
+ return map;
+ }
+
+ /** Active customers; majority = floor(n/2)+1 likes. */
+ private async majorityMeta() {
+ const customerCount = await this.prisma.customer.count({
+ where: { isActive: true, isArchived: false },
+ });
+ return {
+ customerCount,
+ majorityThreshold: Math.floor(customerCount / 2) + 1,
+ };
+ }
+
+ private toRow(
+ s: Prisma.ProductSurveyGetPayload<{
+ include?: {
+ createdByCustomer?: true;
+ createdByAdmin?: true;
+ decidedByAdmin?: true;
+ };
+ }> & {
+ createdByCustomer?: { id: string; fullName: string; mobile: string } | null;
+ createdByAdmin?: { id: string; fullName: string } | null;
+ decidedByAdmin?: { id: string; fullName: string } | null;
+ },
+ counts: { likes: number; dislikes: number },
+ myVote: ProductSurveyVoteValue | null = null,
+ majority?: { customerCount: number; majorityThreshold: number },
+ ) {
+ const majorityThreshold = majority?.majorityThreshold ?? null;
+ const customerCount = majority?.customerCount ?? null;
+ const reachedMajority =
+ majorityThreshold !== null ? counts.likes >= majorityThreshold : false;
+ return {
+ id: s.id,
+ title: s.title,
+ imageUrl: s.imageUrl,
+ suggestedWeightGrams: Number(s.suggestedWeightGrams),
+ status: s.status,
+ statusLabel: STATUS_LABEL[s.status],
+ likes: counts.likes,
+ dislikes: counts.dislikes,
+ myVote,
+ customerCount,
+ majorityThreshold,
+ reachedMajority,
+ publishedAt: s.publishedAt,
+ decidedAt: s.decidedAt,
+ decisionNote: s.decisionNote,
+ createdAt: s.createdAt,
+ updatedAt: s.updatedAt,
+ createdByCustomer: s.createdByCustomer
+ ? {
+ id: s.createdByCustomer.id,
+ fullName: s.createdByCustomer.fullName,
+ mobile: s.createdByCustomer.mobile,
+ }
+ : null,
+ createdByAdmin: s.createdByAdmin
+ ? { id: s.createdByAdmin.id, fullName: s.createdByAdmin.fullName }
+ : null,
+ decidedByAdmin: s.decidedByAdmin
+ ? { id: s.decidedByAdmin.id, fullName: s.decidedByAdmin.fullName }
+ : null,
+ };
+ }
+
+ async listAdmin(status?: string) {
+ const where: Prisma.ProductSurveyWhereInput = {};
+ if (status) {
+ if (!Object.values(ProductSurveyStatus).includes(status as ProductSurveyStatus)) {
+ throw new BadRequestException('وضعیت نامعتبر است');
+ }
+ where.status = status as ProductSurveyStatus;
+ }
+ const [majority, rows] = await Promise.all([
+ this.majorityMeta(),
+ this.prisma.productSurvey.findMany({
+ where,
+ orderBy: { createdAt: 'desc' },
+ take: 500,
+ include: {
+ createdByCustomer: { select: { id: true, fullName: true, mobile: true } },
+ createdByAdmin: { select: { id: true, fullName: true } },
+ decidedByAdmin: { select: { id: true, fullName: true } },
+ },
+ }),
+ ]);
+ const counts = await this.voteCounts(rows.map((r) => r.id));
+ const mapped = rows.map((r) => this.toRow(r, counts.get(r.id)!, null, majority));
+ const majorityHits = mapped.filter(
+ (r) =>
+ r.reachedMajority &&
+ (r.status === ProductSurveyStatus.OPEN || r.status === ProductSurveyStatus.CLOSED),
+ );
+ return {
+ rows: mapped,
+ majorityHits,
+ customerCount: majority.customerCount,
+ majorityThreshold: majority.majorityThreshold,
+ statusOptions: Object.entries(STATUS_LABEL).map(([value, label]) => ({ value, label })),
+ };
+ }
+
+ /** Undecided surveys that already have majority likes — for dashboard alerts. */
+ async listMajorityAlerts() {
+ const majority = await this.majorityMeta();
+ const rows = await this.prisma.productSurvey.findMany({
+ where: { status: { in: [ProductSurveyStatus.OPEN, ProductSurveyStatus.CLOSED] } },
+ orderBy: { publishedAt: 'desc' },
+ take: 200,
+ include: {
+ createdByCustomer: { select: { id: true, fullName: true, mobile: true } },
+ createdByAdmin: { select: { id: true, fullName: true } },
+ decidedByAdmin: { select: { id: true, fullName: true } },
+ },
+ });
+ const counts = await this.voteCounts(rows.map((r) => r.id));
+ const mapped = rows.map((r) => this.toRow(r, counts.get(r.id)!, null, majority));
+ return {
+ rows: mapped.filter((r) => r.reachedMajority),
+ customerCount: majority.customerCount,
+ majorityThreshold: majority.majorityThreshold,
+ };
+ }
+
+ async createByAdmin(adminId: string, input: CreateSurveyInput) {
+ const imageUrl = await this.resolveImageUrl(input);
+ const created = await this.prisma.productSurvey.create({
+ data: {
+ title: this.text(input.title, 'عنوان', 200),
+ imageUrl,
+ suggestedWeightGrams: this.weight(input.suggestedWeightGrams),
+ status: ProductSurveyStatus.OPEN,
+ createdByAdminId: adminId,
+ publishedAt: new Date(),
+ },
+ include: {
+ createdByCustomer: { select: { id: true, fullName: true, mobile: true } },
+ createdByAdmin: { select: { id: true, fullName: true } },
+ decidedByAdmin: { select: { id: true, fullName: true } },
+ },
+ });
+ return this.toRow(created, { likes: 0, dislikes: 0 });
+ }
+
+ async createByCustomer(customerId: string, input: CreateSurveyInput) {
+ const imageUrl = await this.resolveImageUrl(input, SURVEY_IMAGE_PURPOSE);
+ const created = await this.prisma.productSurvey.create({
+ data: {
+ title: this.text(input.title, 'عنوان', 200),
+ imageUrl,
+ suggestedWeightGrams: this.weight(input.suggestedWeightGrams),
+ status: ProductSurveyStatus.PENDING_REVIEW,
+ createdByCustomerId: customerId,
+ },
+ });
+ return this.toRow(created, { likes: 0, dislikes: 0 });
+ }
+
+ async update(id: string, input: CreateSurveyInput) {
+ const existing = await this.prisma.productSurvey.findUnique({ where: { id } });
+ if (!existing) throw new NotFoundException('طرح پیدا نشد');
+ if (!EDITABLE.includes(existing.status)) {
+ throw new BadRequestException('این طرح دیگر قابل ویرایش نیست');
+ }
+
+ const data: Prisma.ProductSurveyUpdateInput = {};
+ if (input.title !== undefined) data.title = this.text(input.title, 'عنوان', 200);
+ if (input.suggestedWeightGrams !== undefined) {
+ data.suggestedWeightGrams = this.weight(input.suggestedWeightGrams);
+ }
+ if (input.imageUrl !== undefined || input.file_id !== undefined) {
+ data.imageUrl = await this.resolveImageUrl(input);
+ }
+
+ const updated = await this.prisma.productSurvey.update({
+ where: { id },
+ data,
+ include: {
+ createdByCustomer: { select: { id: true, fullName: true, mobile: true } },
+ createdByAdmin: { select: { id: true, fullName: true } },
+ decidedByAdmin: { select: { id: true, fullName: true } },
+ },
+ });
+ const counts = await this.voteCounts([id]);
+ return this.toRow(updated, counts.get(id)!);
+ }
+
+ async publish(id: string) {
+ const existing = await this.prisma.productSurvey.findUnique({ where: { id } });
+ if (!existing) throw new NotFoundException('طرح پیدا نشد');
+ if (existing.status !== ProductSurveyStatus.PENDING_REVIEW) {
+ throw new BadRequestException('فقط طرحهای منتظر انتشار قابل انتشارند');
+ }
+ const updated = await this.prisma.productSurvey.update({
+ where: { id },
+ data: { status: ProductSurveyStatus.OPEN, publishedAt: new Date() },
+ include: {
+ createdByCustomer: { select: { id: true, fullName: true, mobile: true } },
+ createdByAdmin: { select: { id: true, fullName: true } },
+ decidedByAdmin: { select: { id: true, fullName: true } },
+ },
+ });
+ const counts = await this.voteCounts([id]);
+ return this.toRow(updated, counts.get(id)!);
+ }
+
+ async close(id: string) {
+ const existing = await this.prisma.productSurvey.findUnique({ where: { id } });
+ if (!existing) throw new NotFoundException('طرح پیدا نشد');
+ if (existing.status !== ProductSurveyStatus.OPEN) {
+ throw new BadRequestException('فقط طرح در حال رأیگیری قابل بستن است');
+ }
+ const updated = await this.prisma.productSurvey.update({
+ where: { id },
+ data: { status: ProductSurveyStatus.CLOSED },
+ include: {
+ createdByCustomer: { select: { id: true, fullName: true, mobile: true } },
+ createdByAdmin: { select: { id: true, fullName: true } },
+ decidedByAdmin: { select: { id: true, fullName: true } },
+ },
+ });
+ const counts = await this.voteCounts([id]);
+ return this.toRow(updated, counts.get(id)!);
+ }
+
+ async decide(id: string, adminId: string, body: { decision?: unknown; note?: unknown }) {
+ const decision = String(body.decision ?? '').toUpperCase();
+ if (decision !== 'APPROVED' && decision !== 'REJECTED') {
+ throw new BadRequestException('تصمیم باید APPROVED یا REJECTED باشد');
+ }
+ const existing = await this.prisma.productSurvey.findUnique({ where: { id } });
+ if (!existing) throw new NotFoundException('طرح پیدا نشد');
+ if (!DECIDABLE.includes(existing.status)) {
+ throw new BadRequestException('برای این طرح دیگر نمیتوان تصمیم گرفت');
+ }
+ const updated = await this.prisma.productSurvey.update({
+ where: { id },
+ data: {
+ status: decision as ProductSurveyStatus,
+ decidedAt: new Date(),
+ decidedByAdminId: adminId,
+ decisionNote: this.text(body.note, 'یادداشت', 1000),
+ },
+ include: {
+ createdByCustomer: { select: { id: true, fullName: true, mobile: true } },
+ createdByAdmin: { select: { id: true, fullName: true } },
+ decidedByAdmin: { select: { id: true, fullName: true } },
+ },
+ });
+ const counts = await this.voteCounts([id]);
+ return this.toRow(updated, counts.get(id)!);
+ }
+
+ async listOpenForCustomer(customerId: string) {
+ const [majority, rows] = await Promise.all([
+ this.majorityMeta(),
+ this.prisma.productSurvey.findMany({
+ where: { status: ProductSurveyStatus.OPEN },
+ orderBy: { publishedAt: 'desc' },
+ take: 200,
+ }),
+ ]);
+ const ids = rows.map((r) => r.id);
+ const counts = await this.voteCounts(ids);
+ const myVotes = await this.prisma.productSurveyVote.findMany({
+ where: { customerId, surveyId: { in: ids } },
+ select: { surveyId: true, value: true },
+ });
+ const mine = new Map(myVotes.map((v) => [v.surveyId, v.value]));
+ const mapped = rows.map((r) =>
+ this.toRow(r, counts.get(r.id)!, mine.get(r.id) ?? null, majority),
+ );
+ return {
+ rows: mapped,
+ majorityHits: mapped.filter((r) => r.reachedMajority),
+ customerCount: majority.customerCount,
+ majorityThreshold: majority.majorityThreshold,
+ };
+ }
+
+ async listMine(customerId: string) {
+ const [majority, rows] = await Promise.all([
+ this.majorityMeta(),
+ this.prisma.productSurvey.findMany({
+ where: { createdByCustomerId: customerId },
+ orderBy: { createdAt: 'desc' },
+ take: 200,
+ }),
+ ]);
+ const counts = await this.voteCounts(rows.map((r) => r.id));
+ return {
+ rows: rows.map((r) => this.toRow(r, counts.get(r.id)!, null, majority)),
+ customerCount: majority.customerCount,
+ majorityThreshold: majority.majorityThreshold,
+ };
+ }
+
+ async vote(surveyId: string, customerId: string, rawValue: unknown) {
+ const value = String(rawValue ?? '').toUpperCase();
+ if (value !== 'LIKE' && value !== 'DISLIKE') {
+ throw new BadRequestException('رأی باید LIKE یا DISLIKE باشد');
+ }
+ const survey = await this.prisma.productSurvey.findUnique({ where: { id: surveyId } });
+ if (!survey) throw new NotFoundException('طرح پیدا نشد');
+ if (survey.status !== ProductSurveyStatus.OPEN) {
+ throw new BadRequestException('رأیگیری برای این طرح باز نیست');
+ }
+
+ await this.prisma.productSurveyVote.upsert({
+ where: { surveyId_customerId: { surveyId, customerId } },
+ create: {
+ surveyId,
+ customerId,
+ value: value as ProductSurveyVoteValue,
+ },
+ update: { value: value as ProductSurveyVoteValue },
+ });
+
+ const [counts, majority] = await Promise.all([
+ this.voteCounts([surveyId]),
+ this.majorityMeta(),
+ ]);
+ const likes = counts.get(surveyId)!.likes;
+ const dislikes = counts.get(surveyId)!.dislikes;
+ return {
+ surveyId,
+ myVote: value as ProductSurveyVoteValue,
+ likes,
+ dislikes,
+ customerCount: majority.customerCount,
+ majorityThreshold: majority.majorityThreshold,
+ reachedMajority: likes >= majority.majorityThreshold,
+ };
+ }
+}
diff --git a/apps/user-app-v2/src/api/client.ts b/apps/user-app-v2/src/api/client.ts
index da29386..5fb9e94 100644
--- a/apps/user-app-v2/src/api/client.ts
+++ b/apps/user-app-v2/src/api/client.ts
@@ -135,6 +135,8 @@ export interface ProductSummary {
category: CategorySummary | null;
brand: BrandSummary | null;
collections: Array<{ collectionId: string; collection: CollectionSummary }>;
+ /** Labels from the admin «برچسبها» list. */
+ tags?: string[];
}
/** Query for the paged storefront list; every field is optional. */
@@ -145,6 +147,10 @@ export interface ProductQuery {
categoryId?: string;
brandId?: string;
collectionId?: string;
+ /** One label. */
+ tag?: string;
+ /** Only products carrying at least one label. */
+ tagged?: boolean;
minGram?: number;
maxGram?: number;
sort?: 'newest' | 'oldest' | 'title';
@@ -167,6 +173,8 @@ export interface CatalogFacets {
categories: CategorySummary[];
brands: BrandSummary[];
collections: CollectionSummary[];
+ /** Labels in use on the storefront. */
+ tags?: string[];
/** Decimal strings, like every other weight this API returns. */
gramRange: { min: string; max: string };
}
@@ -394,6 +402,29 @@ export interface PreOrderInput {
description?: string;
}
+/** One product-design survey card (like/dislike before manufacturing). */
+export interface ProductSurveyRow {
+ id: string;
+ title: string | null;
+ imageUrl: string;
+ suggestedWeightGrams: number;
+ status: string;
+ statusLabel: string;
+ likes: number;
+ dislikes: number;
+ myVote: 'LIKE' | 'DISLIKE' | null;
+ customerCount?: number | null;
+ majorityThreshold?: number | null;
+ reachedMajority?: boolean;
+ createdAt: string;
+}
+
+export interface ProductSurveyInput {
+ title?: string;
+ file_id: string;
+ suggestedWeightGrams: number;
+}
+
/** One row of "سفارش مشتریان". */
export interface ExpertChildOrder {
id: string;
@@ -516,6 +547,13 @@ export const api = {
*/
getProduct: (id: string) => request(`/catalog/products/${encodeURIComponent(id)}`),
+ /** Counts a detail-screen opening for the admin «آمار» screen. Never throws. */
+ recordProductView: (id: string) => {
+ void fetch(`${BASE}/catalog/products/${encodeURIComponent(id)}/view`, { method: 'POST', keepalive: true }).catch(
+ () => undefined,
+ );
+ },
+
/**
* Specific products by id — the cart, favourites, order items.
*
@@ -661,6 +699,26 @@ export const api = {
createPreOrder: (body: PreOrderInput) =>
request('/customer/me/pre-orders', { method: 'POST', body: JSON.stringify(body) }),
getMyPreOrders: () => request<{ rows: PreOrderRow[] }>('/customer/me/pre-orders'),
+
+ listOpenProductSurveys: () =>
+ request<{
+ rows: ProductSurveyRow[];
+ majorityHits?: ProductSurveyRow[];
+ customerCount?: number;
+ majorityThreshold?: number;
+ }>('/customer/product-surveys'),
+ listMyProductSurveys: () =>
+ request<{ rows: ProductSurveyRow[] }>('/customer/me/product-surveys'),
+ createProductSurvey: (body: ProductSurveyInput) =>
+ request('/customer/me/product-surveys', {
+ method: 'POST',
+ body: JSON.stringify(body),
+ }),
+ voteProductSurvey: (id: string, value: 'LIKE' | 'DISLIKE') =>
+ request<{ surveyId: string; myVote: 'LIKE' | 'DISLIKE'; likes: number; dislikes: number }>(
+ `/customer/product-surveys/${encodeURIComponent(id)}/vote`,
+ { method: 'PUT', body: JSON.stringify({ value }) },
+ ),
adjustExpertChildOrder: (orderId: string, items: { id: string; rash_weight: number }[], finalSubmit: boolean) =>
request<{ id: string; status: string }>(`/expert/child-orders/${encodeURIComponent(orderId)}`, {
method: 'PUT',
@@ -739,4 +797,63 @@ export const api = {
request<{ result: ReportDetailRow[]; page: number; limit: number }>(
`/customer/me/report/${encodeURIComponent(id)}`,
),
+
+ // ── cart sync / daily expiry ─────────────────────────────────────────
+ getCartPolicy: () =>
+ request<{
+ enabled: boolean;
+ clearHour: number;
+ clearMinute: number;
+ sameDayDeferFromHour: number;
+ sameDayDeferFromMinute: number;
+ reminderEnabled: boolean;
+ reminderHoursBefore: number;
+ todayClearAt: string;
+ nextClearAt: string;
+ nextReminderAt: string;
+ }>('/cart/policy'),
+
+ getCart: () =>
+ request<{
+ items: Array<{
+ pid: string;
+ vid: string;
+ qty: number;
+ note?: string | null;
+ }>;
+ updatedAt: string | null;
+ clearedAt: string | null;
+ }>('/cart'),
+
+ putCart: (items: Array<{ pid: string; vid: string; qty: number; note?: string | null }>) =>
+ request<{
+ items: unknown[];
+ updatedAt: string;
+ clearedAt: string | null;
+ }>('/cart', {
+ method: 'PUT',
+ body: JSON.stringify({ items }),
+ }),
+
+ getBuyLater: () =>
+ request<{
+ items: Array<{
+ pid: string;
+ vid: string;
+ qty: number;
+ note?: string | null;
+ }>;
+ updatedAt: string | null;
+ }>('/cart/buy-later'),
+
+ putBuyLater: (
+ items: Array<{ pid: string; vid: string; qty: number; note?: string | null }>,
+ ) =>
+ request<{
+ items: unknown[];
+ updatedAt: string;
+ }>('/cart/buy-later', {
+ method: 'PUT',
+ body: JSON.stringify({ items }),
+ }),
};
diff --git a/apps/user-app-v2/src/components/expert/ActingAsBar.vue b/apps/user-app-v2/src/components/expert/ActingAsBar.vue
index 5b29e83..0cfc80d 100644
--- a/apps/user-app-v2/src/components/expert/ActingAsBar.vue
+++ b/apps/user-app-v2/src/components/expert/ActingAsBar.vue
@@ -29,10 +29,12 @@ import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useSessionStore } from '../../stores/session'
import { useCartStore } from '../../stores/cartStore'
+import { useBuyLaterStore } from '../../stores/buyLaterStore'
import { toast } from '../../composables/useToast'
const session = useSessionStore()
const cart = useCartStore()
+const buyLater = useBuyLaterStore()
const router = useRouter()
const leaving = ref(false)
@@ -45,6 +47,7 @@ async function leave() {
leaving.value = true
try {
cart.clear()
+ buyLater.clear()
session.setActingAs(null)
await router.push({ name: 'home' })
toast.info('به حساب خودتان بازگشتید')
diff --git a/apps/user-app-v2/src/components/home/ProductPanel.vue b/apps/user-app-v2/src/components/home/ProductPanel.vue
index 3f787c0..55496c5 100644
--- a/apps/user-app-v2/src/components/home/ProductPanel.vue
+++ b/apps/user-app-v2/src/components/home/ProductPanel.vue
@@ -14,6 +14,7 @@
+
{{ title }}
@@ -30,6 +31,7 @@
+
+
diff --git a/apps/user-app-v2/src/components/product/SingleProduct.vue b/apps/user-app-v2/src/components/product/SingleProduct.vue
index 1aba3d7..3787153 100644
--- a/apps/user-app-v2/src/components/product/SingleProduct.vue
+++ b/apps/user-app-v2/src/components/product/SingleProduct.vue
@@ -334,7 +334,7 @@ import { useFavoritesStore } from '../../stores/favoritesStore'
import { useFiltersStore } from '../../stores/filtersStore'
import { useCartStore, type CartItem } from '../../stores/cartStore'
import { isUserLoggedIn } from '../../utils/checkAccess'
-import type { ProductSummary } from '../../api/client'
+import { api, type ProductSummary } from '../../api/client'
// `initial`: the card's own ProductSummary when opened from a list (see
// ProductMorph.vue) — lets the page render whole on its first frame.
@@ -530,6 +530,7 @@ const goCategory = () => {
}
onBeforeMount(async () => {
+ api.recordProductView(props.id)
await productDetail.load()
})
diff --git a/apps/user-app-v2/src/composables/useProductMorph.ts b/apps/user-app-v2/src/composables/useProductMorph.ts
index 6f3f0b6..9913601 100644
--- a/apps/user-app-v2/src/composables/useProductMorph.ts
+++ b/apps/user-app-v2/src/composables/useProductMorph.ts
@@ -12,6 +12,10 @@
// to thread an element through. A caller that does hold the element can pass
// it; with neither (keyboard activation), the overlay falls back to a plain
// rise-and-fade instead of a morph.
+//
+// History: opening pushes a same-URL history entry so the phone/browser back
+// button closes the overlay first (like a modal), instead of leaving the list
+// page underneath. Closing via the in-overlay back control pops that entry.
import { shallowRef } from 'vue'
import type { ProductSummary } from '../api/client'
@@ -22,6 +26,12 @@ export interface ProductMorphRequest {
export const productMorph = shallowRef(null)
+/** Marker on `history.state` while the product overlay owns the top entry. */
+export const PRODUCT_MORPH_HISTORY_KEY = '__productMorph'
+
+/** True when the current close was triggered by popstate (phone/browser back). */
+let closingFromPopstate = false
+
let lastPointerTarget: Element | null = null
if (typeof document !== 'undefined') {
document.addEventListener(
@@ -31,11 +41,74 @@ if (typeof document !== 'undefined') {
},
{ capture: true, passive: true },
)
+
+ window.addEventListener('popstate', () => {
+ if (!productMorph.value) return
+ closingFromPopstate = true
+ productMorph.value = null
+ })
+}
+
+type RouterHistoryState = Record & {
+ position?: number
+ current?: string
+ back?: string | null
+ forward?: string | null
+}
+
+function pushMorphHistory() {
+ if (typeof window === 'undefined') return
+ const st = (window.history.state ?? {}) as RouterHistoryState
+ if (st[PRODUCT_MORPH_HISTORY_KEY]) return
+ const current = typeof st.current === 'string' ? st.current : window.location.pathname
+ const position = typeof st.position === 'number' ? st.position + 1 : 1
+ window.history.pushState(
+ {
+ ...st,
+ [PRODUCT_MORPH_HISTORY_KEY]: true,
+ position,
+ back: current,
+ current,
+ forward: null,
+ replaced: false,
+ },
+ '',
+ window.location.href,
+ )
+}
+
+/**
+ * After the overlay finishes closing via the UI (not phone back), drop the
+ * extra history entry so the next back leaves the underlying page.
+ */
+export function releaseMorphHistory() {
+ if (typeof window === 'undefined') return
+ if (closingFromPopstate) {
+ closingFromPopstate = false
+ return
+ }
+ const st = (window.history.state ?? {}) as RouterHistoryState
+ if (!st[PRODUCT_MORPH_HISTORY_KEY]) return
+ window.history.back()
+}
+
+/** Strip the morph marker without navigating — used when the route itself changes. */
+export function discardMorphHistory() {
+ if (typeof window === 'undefined') return
+ closingFromPopstate = false
+ const st = (window.history.state ?? {}) as RouterHistoryState
+ if (!st[PRODUCT_MORPH_HISTORY_KEY]) return
+ const next = { ...st }
+ delete next[PRODUCT_MORPH_HISTORY_KEY]
+ window.history.replaceState(next, '', window.location.href)
}
export function openProduct(product: ProductSummary, origin?: HTMLElement | null) {
const fromTap = lastPointerTarget?.closest('[data-product-origin]') ?? null
+ const alreadyOpen = Boolean(productMorph.value)
productMorph.value = { product, origin: origin ?? fromTap }
+ // Switching product while open keeps the same history entry.
+ if (!alreadyOpen) pushMorphHistory()
}
export function closeProduct() {
diff --git a/apps/user-app-v2/src/router/index.ts b/apps/user-app-v2/src/router/index.ts
index d7dae43..78fdca6 100644
--- a/apps/user-app-v2/src/router/index.ts
+++ b/apps/user-app-v2/src/router/index.ts
@@ -44,6 +44,8 @@ export const router = createRouter({
// Reference paths and names kept (henza_pwa router/index.ts:113-128).
{ path: '/makeOrder', name: 'makeOrderList', component: () => import('../views/user/MakeOrderView.vue'), meta: { requiresAuth: true } },
{ path: '/makeOrder_status', name: 'makeOrderStatus', component: () => import('../views/user/MakeOrderStatusView.vue'), meta: { requiresAuth: true } },
+ { path: '/surveys', name: 'surveyFeed', component: () => import('../views/user/SurveyFeedView.vue'), meta: { requiresAuth: true } },
+ { path: '/surveys/mine', name: 'surveyMine', component: () => import('../views/user/SurveyMineView.vue'), meta: { requiresAuth: true } },
// Theme + reduced motion. Device preferences, so no requiresAuth.
{ path: '/settings/appearance', name: 'appearanceSettings', component: () => import('../views/user/AppearanceView.vue') },
// D21 — the sales expert's two screens. Reference paths and names kept
diff --git a/apps/user-app-v2/src/stores/buyLaterStore.ts b/apps/user-app-v2/src/stores/buyLaterStore.ts
new file mode 100644
index 0000000..2185b60
--- /dev/null
+++ b/apps/user-app-v2/src/stores/buyLaterStore.ts
@@ -0,0 +1,214 @@
+/**
+ * «سبد خرید بعدی» — deferred purchase list.
+ *
+ * Scenarios covered:
+ * - Move from cart → here: removes from active cart; stock is untouched
+ * (cart never reserved stock; this list also never reserves).
+ * - Move here → cart: uses cart.addToCart stock guards; on failure the line
+ * stays here so an out-of-stock deferral is not lost.
+ * - Qty/note editable here without stock ceiling (desired qty for later).
+ * - Survives daily cart expiry and successful order (active cart only clears).
+ * - Guest: localStorage; logged-in: debounced PUT /cart/buy-later.
+ * - Acting-as / logout: list is cleared in memory like the active cart.
+ */
+import { defineStore } from 'pinia'
+import { api, type ProductSummary } from '../api/client'
+import { useSessionStore } from './session'
+import { useCartStore, type CartItem } from './cartStore'
+
+const STORAGE_KEY = 'appBuyLater'
+const SYNC_DEBOUNCE_MS = 600
+
+type Stored = { items: CartItem[] }
+
+function loadStored(): CartItem[] {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY)
+ if (!raw) return []
+ const parsed = JSON.parse(raw)
+ if (Array.isArray(parsed)) return parsed
+ return Array.isArray(parsed?.items) ? parsed.items : []
+ } catch {
+ return []
+ }
+}
+
+function persist(items: CartItem[]) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ items } satisfies Stored))
+}
+
+function toServerItems(items: CartItem[]) {
+ return items.map((i) => ({
+ pid: i.pid,
+ vid: i.vid,
+ qty: i.qty,
+ note: i.note ?? null,
+ }))
+}
+
+export const useBuyLaterStore = defineStore('buyLater', {
+ state: () => ({
+ items: loadStored() as CartItem[],
+ _syncTimer: null as ReturnType | null,
+ _hydrating: false,
+ }),
+ actions: {
+ async init() {
+ await this.syncFromServer()
+ if (this.items.length === 0) return
+ try {
+ const ids = [...new Set(this.items.map((i) => i.pid))]
+ const products = await api.getProductsByIds(ids)
+ const byId = new Map(products.map((p) => [p.id, p]))
+ const refreshed: CartItem[] = []
+ for (const item of this.items) {
+ const product = byId.get(item.pid)
+ const variant = product?.variants.find((v) => v.id === item.vid)
+ // Keep OOS / missing-from-storefront lines with a stale product
+ // snapshot so the person can still see what they deferred.
+ if (product && variant) refreshed.push({ ...item, product })
+ else refreshed.push(item)
+ }
+ this.items = refreshed
+ this.writeLocal()
+ } catch (e) {
+ console.log(e)
+ }
+ },
+
+ async syncFromServer() {
+ const session = useSessionStore()
+ if (!session.isLoggedIn) return
+ try {
+ this._hydrating = true
+ const remote = await api.getBuyLater()
+ const remoteItems = Array.isArray(remote.items) ? remote.items : []
+ if (remoteItems.length > 0) {
+ await this.hydrateFromServer(remoteItems)
+ } else if (this.items.length > 0) {
+ await api.putBuyLater(toServerItems(this.items))
+ }
+ } catch (e) {
+ console.log(e)
+ } finally {
+ this._hydrating = false
+ }
+ },
+
+ async hydrateFromServer(
+ lines: Array<{ pid: string; vid: string; qty: number; note?: string | null }>,
+ ) {
+ const ids = [...new Set(lines.map((l) => l.pid))]
+ let byId = new Map()
+ try {
+ const products = await api.getProductsByIds(ids)
+ byId = new Map(products.map((p) => [p.id, p]))
+ } catch {
+ /* keep whatever local product blobs we can */
+ }
+ const next: CartItem[] = []
+ for (const line of lines) {
+ const product = byId.get(line.pid)
+ const local = this.items.find((i) => i.pid === line.pid && i.vid === line.vid)
+ const blob = product ?? local?.product
+ if (!blob) continue
+ next.push({
+ pid: line.pid,
+ vid: line.vid,
+ qty: Math.max(1, Math.floor(line.qty) || 1),
+ note: line.note ?? null,
+ product: blob,
+ })
+ }
+ this.items = next
+ this.writeLocal()
+ },
+
+ writeLocal() {
+ persist(this.items)
+ if (!this._hydrating) this.scheduleServerSync()
+ },
+
+ scheduleServerSync() {
+ const session = useSessionStore()
+ if (!session.isLoggedIn) return
+ if (this._syncTimer) clearTimeout(this._syncTimer)
+ this._syncTimer = setTimeout(() => {
+ this._syncTimer = null
+ void api.putBuyLater(toServerItems(this.items)).catch((e) => console.log(e))
+ }, SYNC_DEBOUNCE_MS)
+ },
+
+ find_index(item: Pick) {
+ return this.items.findIndex((i) => i.pid === item.pid && i.vid === item.vid)
+ },
+
+ /** Upsert a line without any stock check. */
+ upsert(item: CartItem) {
+ const qty = Math.max(1, Math.floor(item.qty) || 1)
+ const i = this.find_index(item)
+ if (i === -1) {
+ this.items.push({ ...item, qty })
+ } else {
+ this.items[i] = {
+ ...this.items[i],
+ product: item.product,
+ qty,
+ note: item.note ?? this.items[i].note,
+ }
+ }
+ this.writeLocal()
+ },
+
+ remove(item: Pick) {
+ const i = this.find_index(item)
+ if (i === -1) return
+ this.items.splice(i, 1)
+ this.writeLocal()
+ },
+
+ updateQty(item: Pick, qty: number) {
+ const i = this.find_index(item)
+ if (i === -1) return
+ this.items[i].qty = Math.max(1, Math.floor(qty) || 1)
+ this.writeLocal()
+ },
+
+ updateNote(item: Pick, note: string | null | undefined) {
+ const i = this.find_index(item)
+ if (i === -1) return
+ this.items[i].note = note ?? null
+ this.writeLocal()
+ },
+
+ /**
+ * Cart → buy later. Stock unchanged. Removes from active cart.
+ */
+ moveFromCart(item: CartItem) {
+ const cart = useCartStore()
+ const live = cart.items[cart.find_index(item)]
+ const snapshot = live ? { ...live } : { ...item }
+ this.upsert(snapshot)
+ cart.remove(snapshot)
+ },
+
+ /**
+ * Buy later → cart. Stock guards apply only now.
+ * @returns false when cart refused the add (e.g. out of stock).
+ */
+ moveToCart(item: CartItem): boolean {
+ const cart = useCartStore()
+ const live = this.items[this.find_index(item)]
+ const snapshot = live ? { ...live } : { ...item }
+ const ok = cart.addToCart(snapshot)
+ if (ok) this.remove(snapshot)
+ return ok
+ },
+
+ clear() {
+ this.items = []
+ persist(this.items)
+ if (!this._hydrating) this.scheduleServerSync()
+ },
+ },
+})
diff --git a/apps/user-app-v2/src/stores/cartStore.ts b/apps/user-app-v2/src/stores/cartStore.ts
index db65961..de32a01 100644
--- a/apps/user-app-v2/src/stores/cartStore.ts
+++ b/apps/user-app-v2/src/stores/cartStore.ts
@@ -1,8 +1,13 @@
// Ported from legacy henza_pwa/src/store/cartStore.ts, adapted to our string
// ids. Persisted the same way (a single localStorage key), just via Pinia's own
// state instead of @vueuse's useStorage wrapping a hand-rolled class.
+//
+// When logged in, lines are also mirrored to PUT /cart so the API can SMS a
+// reminder and clear the basket at the configured Tehran time.
import { defineStore } from 'pinia'
import { api, type ProductSummary } from '../api/client'
+import { clearDeadlineForTouchedAt } from '../utils/cartExpiry'
+import { useSessionStore } from './session'
export interface CartItem {
pid: string
@@ -12,41 +17,77 @@ export interface CartItem {
note?: string | null
}
-const STORAGE_KEY = 'appCart'
+type StoredCart = {
+ items: CartItem[]
+ /** Last local mutation — used for guest daily-expiry. */
+ touchedAt: string | null
+}
-function loadFromStorage(): CartItem[] {
+const STORAGE_KEY = 'appCart'
+const SYNC_DEBOUNCE_MS = 600
+
+function loadStored(): StoredCart {
try {
const raw = localStorage.getItem(STORAGE_KEY)
- return raw ? JSON.parse(raw) : []
+ if (!raw) return { items: [], touchedAt: null }
+ const parsed = JSON.parse(raw)
+ // Legacy shape was a bare array.
+ if (Array.isArray(parsed)) {
+ return { items: parsed, touchedAt: null }
+ }
+ return {
+ items: Array.isArray(parsed?.items) ? parsed.items : [],
+ touchedAt: typeof parsed?.touchedAt === 'string' ? parsed.touchedAt : null,
+ }
} catch {
- return []
+ return { items: [], touchedAt: null }
}
}
-function persist(items: CartItem[]) {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(items))
+function persist(items: CartItem[], touchedAt: string | null) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ items, touchedAt }))
+}
+
+function toServerItems(items: CartItem[]) {
+ return items.map((i) => ({
+ pid: i.pid,
+ vid: i.vid,
+ qty: i.qty,
+ note: i.note ?? null,
+ }))
}
export const useCartStore = defineStore('cart', {
- state: () => ({
- items: loadFromStorage() as CartItem[],
- }),
+ state: () => {
+ const stored = loadStored()
+ return {
+ items: stored.items as CartItem[],
+ touchedAt: stored.touchedAt as string | null,
+ policy: null as null | {
+ enabled: boolean
+ clearHour: number
+ clearMinute: number
+ sameDayDeferFromHour: number
+ sameDayDeferFromMinute: number
+ todayClearAt: string
+ nextClearAt: string
+ },
+ _syncTimer: null as ReturnType | null,
+ _hydrating: false,
+ }
+ },
actions: {
/**
* Re-syncs cart entries against fresh product data (e.g. price/weight
* changes since the item was added), dropping entries whose
* product/variant no longer exists — same intent as legacy's init().
*
- * Asks for exactly the products in the cart. It used to load the entire
- * catalogue to look up two or three of them, which with ~16k products meant
- * downloading all of them every time the app opened.
- *
- * A failed request leaves the cart alone: it is the person's own data, held
- * in localStorage, and dropping items because the network blinked would be
- * worse than showing a slightly stale price.
+ * Also loads expiry policy + server cart (when logged in) so a clear that
+ * happened while the app was closed is reflected locally.
*/
async init(force = false) {
- void force // kept for callers; every call now goes to the server anyway
+ void force
+ await this.applyExpiryAndSync()
if (this.items.length === 0) return
try {
const ids = [...new Set(this.items.map((i) => i.pid))]
@@ -56,19 +97,107 @@ export const useCartStore = defineStore('cart', {
for (const item of this.items) {
const product = byId.get(item.pid)
const variant = product?.variants.find((v) => v.id === item.vid)
- // A product the server no longer lists (deactivated, or its photo was
- // removed) comes back missing, which is the same signal the catalogue
- // lookup gave before: drop the line.
if (product && variant) {
refreshed.push({ ...item, product })
}
}
this.items = refreshed
- persist(this.items)
+ this.writeLocal(false)
} catch (e) {
console.log(e)
}
},
+
+ async applyExpiryAndSync() {
+ try {
+ const policy = await api.getCartPolicy()
+ this.policy = {
+ enabled: policy.enabled,
+ clearHour: policy.clearHour,
+ clearMinute: policy.clearMinute,
+ sameDayDeferFromHour: policy.sameDayDeferFromHour ?? 8,
+ sameDayDeferFromMinute: policy.sameDayDeferFromMinute ?? 0,
+ todayClearAt: policy.todayClearAt,
+ nextClearAt: policy.nextClearAt,
+ }
+ if (policy.enabled && this.touchedAt) {
+ const touched = new Date(this.touchedAt)
+ if (!Number.isNaN(touched.getTime())) {
+ const deadline = clearDeadlineForTouchedAt(touched, {
+ clearHour: this.policy.clearHour,
+ clearMinute: this.policy.clearMinute,
+ sameDayDeferFromHour: this.policy.sameDayDeferFromHour,
+ sameDayDeferFromMinute: this.policy.sameDayDeferFromMinute,
+ })
+ if (Date.now() >= deadline.getTime()) this.clear(false)
+ }
+ }
+ } catch (e) {
+ console.log(e)
+ }
+
+ const session = useSessionStore()
+ if (!session.isLoggedIn) return
+
+ try {
+ this._hydrating = true
+ const remote = await api.getCart()
+ const remoteItems = Array.isArray(remote.items) ? remote.items : []
+ if (remoteItems.length > 0) {
+ await this.hydrateFromServer(remoteItems)
+ } else if (remote.clearedAt) {
+ this.clear(false)
+ } else if (this.items.length > 0) {
+ // First sync: push local guest/offline lines up.
+ await api.putCart(toServerItems(this.items))
+ }
+ } catch (e) {
+ console.log(e)
+ } finally {
+ this._hydrating = false
+ }
+ },
+
+ async hydrateFromServer(
+ lines: Array<{ pid: string; vid: string; qty: number; note?: string | null }>,
+ ) {
+ const ids = [...new Set(lines.map((l) => l.pid))]
+ const products = await api.getProductsByIds(ids)
+ const byId = new Map(products.map((p) => [p.id, p]))
+ const next: CartItem[] = []
+ for (const line of lines) {
+ const product = byId.get(line.pid)
+ const variant = product?.variants.find((v) => v.id === line.vid)
+ if (!product || !variant) continue
+ next.push({
+ pid: line.pid,
+ vid: line.vid,
+ qty: line.qty,
+ note: line.note ?? null,
+ product,
+ })
+ }
+ this.items = next
+ this.touchedAt = new Date().toISOString()
+ this.writeLocal(false)
+ },
+
+ writeLocal(touch: boolean) {
+ if (touch) this.touchedAt = new Date().toISOString()
+ persist(this.items, this.touchedAt)
+ if (!this._hydrating) this.scheduleServerSync()
+ },
+
+ scheduleServerSync() {
+ const session = useSessionStore()
+ if (!session.isLoggedIn) return
+ if (this._syncTimer) clearTimeout(this._syncTimer)
+ this._syncTimer = setTimeout(() => {
+ this._syncTimer = null
+ void api.putCart(toServerItems(this.items)).catch((e) => console.log(e))
+ }, SYNC_DEBOUNCE_MS)
+ },
+
addToCart(data: CartItem) {
// Refuse a variant that cannot be ordered — out of stock and not flagged
// pre-order. The same guard the admin cart applies (stores/cart.ts), kept
@@ -83,7 +212,7 @@ export const useCartStore = defineStore('cart', {
this.items[index].product = data.product
this.items[index].qty = this.capQty(data.qty, target)
}
- persist(this.items)
+ this.writeLocal(true)
return true
},
@@ -110,7 +239,7 @@ export const useCartStore = defineStore('cart', {
remove(item: CartItem) {
const index = this.find_index(item)
if (index > -1) this.items.splice(index, 1)
- persist(this.items)
+ this.writeLocal(true)
},
// Legacy's exact name (typo'd "Feild"), kept for consistency with the rest
// of this faithfully-ported store's method names.
@@ -125,7 +254,7 @@ export const useCartStore = defineStore('cart', {
value = v ? this.capQty(Number(value), v) : Math.max(1, Number(value) || 1)
}
;(this.items[i] as any)[field] = value
- persist(this.items)
+ this.writeLocal(true)
return true
},
find_index(item: Pick) {
@@ -157,9 +286,15 @@ export const useCartStore = defineStore('cart', {
return sum + (v?.rial_pay ? Number(v.rial_pay) * item.qty : 0)
}, 0)
},
- clear() {
+ /**
+ * @param syncServer when false, only wipe local (used while applying a
+ * server-side clear so we do not bounce an empty PUT that races).
+ */
+ clear(syncServer = true) {
this.items = []
- persist(this.items)
+ this.touchedAt = new Date().toISOString()
+ persist(this.items, this.touchedAt)
+ if (syncServer && !this._hydrating) this.scheduleServerSync()
},
},
})
diff --git a/apps/user-app-v2/src/utils/cartExpiry.ts b/apps/user-app-v2/src/utils/cartExpiry.ts
new file mode 100644
index 0000000..efcbc37
--- /dev/null
+++ b/apps/user-app-v2/src/utils/cartExpiry.ts
@@ -0,0 +1,92 @@
+/**
+ * Mirrors apps/api cart-schedule clearDeadlineForTouchedAt for guest/local expiry.
+ * Keep in step with the server rule (same-day defer → tomorrow's clear).
+ */
+
+type Parts = { year: number; month: number; day: number; hour: number; minute: number; second: number }
+
+function tehranParts(date: Date): Parts {
+ const fmt = new Intl.DateTimeFormat('en-US', {
+ timeZone: 'Asia/Tehran',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hourCycle: 'h23',
+ })
+ const bag: Record = {}
+ for (const p of fmt.formatToParts(date)) {
+ if (p.type !== 'literal') bag[p.type] = p.value
+ }
+ return {
+ year: Number(bag.year),
+ month: Number(bag.month),
+ day: Number(bag.day),
+ hour: Number(bag.hour),
+ minute: Number(bag.minute),
+ second: Number(bag.second),
+ }
+}
+
+function tehranWallToUtc(
+ year: number,
+ month: number,
+ day: number,
+ hour: number,
+ minute: number,
+ second = 0,
+): Date {
+ const utcGuess = new Date(Date.UTC(year, month - 1, day, hour, minute, second))
+ const shown = tehranParts(utcGuess)
+ const wantedMs = Date.UTC(year, month - 1, day, hour, minute, second)
+ const shownMs = Date.UTC(
+ shown.year,
+ shown.month - 1,
+ shown.day,
+ shown.hour,
+ shown.minute,
+ shown.second,
+ )
+ return new Date(utcGuess.getTime() + (wantedMs - shownMs))
+}
+
+function addTehranDays(year: number, month: number, day: number, delta: number) {
+ const noon = tehranWallToUtc(year, month, day, 12, 0, 0)
+ const shifted = new Date(noon.getTime() + delta * 24 * 60 * 60 * 1000)
+ const p = tehranParts(shifted)
+ return { year: p.year, month: p.month, day: p.day }
+}
+
+export type CartExpiryPolicyFields = {
+ clearHour: number
+ clearMinute: number
+ sameDayDeferFromHour: number
+ sameDayDeferFromMinute: number
+}
+
+/** Deadline when a cart last touched at `touchedAt` should be emptied. */
+export function clearDeadlineForTouchedAt(
+ touchedAt: Date,
+ cfg: CartExpiryPolicyFields,
+): Date {
+ const p = tehranParts(touchedAt)
+ const dayClear = tehranWallToUtc(p.year, p.month, p.day, cfg.clearHour, cfg.clearMinute, 0)
+ const deferFrom = tehranWallToUtc(
+ p.year,
+ p.month,
+ p.day,
+ cfg.sameDayDeferFromHour,
+ cfg.sameDayDeferFromMinute,
+ 0,
+ )
+ const tomorrowClear = () => {
+ const tmr = addTehranDays(p.year, p.month, p.day, 1)
+ return tehranWallToUtc(tmr.year, tmr.month, tmr.day, cfg.clearHour, cfg.clearMinute, 0)
+ }
+
+ if (touchedAt.getTime() >= dayClear.getTime()) return tomorrowClear()
+ if (touchedAt.getTime() >= deferFrom.getTime()) return tomorrowClear()
+ return dayClear
+}
diff --git a/apps/user-app-v2/src/views/CartView.vue b/apps/user-app-v2/src/views/CartView.vue
index 14dbd42..34a07bb 100644
--- a/apps/user-app-v2/src/views/CartView.vue
+++ b/apps/user-app-v2/src/views/CartView.vue
@@ -27,37 +27,122 @@
-->
-
+
+
+
+
+ سبد خرید
+ {{ cart.items.length }}
+
+
+ خرید بعدی
+ {{ buyLater.items.length }}
+
+
+
+
-
-
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+ سبد خرید خالی است.
+
+ {{ buyLater.items.length }} مورد در «خرید بعدی» دارید.
+
+
+ رفتن به خرید بعدی
+
+
+ دیدن محصولات
+
+
-
-
- سبد خرید شما خالی است.
-
- دیدن محصولات
-
-
+
+
+
+ از موجودی کم نمیشود؛ هر وقت آماده بودید به سبد برگردانید.
+
+
+
+
+
+
+
+
+ خرید بعدی خالی است.
+ از سبد خرید، «خرید بعدی» را بزنید تا اینجا نگه داشته شود.
+
+ بازگشت به سبد
+
+
+ دیدن محصولات
+
+
+
@@ -76,8 +161,8 @@
-
-
+
+
@@ -145,6 +230,8 @@
+
+
diff --git a/apps/user-app-v2/src/views/user/SurveyMineView.vue b/apps/user-app-v2/src/views/user/SurveyMineView.vue
new file mode 100644
index 0000000..d39681e
--- /dev/null
+++ b/apps/user-app-v2/src/views/user/SurveyMineView.vue
@@ -0,0 +1,211 @@
+
+
+
+
+
+
+
+
+
+ ارسالهای قبلی
+
+
+
+ {{ item.title || 'بدون عنوان' }}
+ {{ item.suggestedWeightGrams }} گرم
+ {{ item.statusLabel }}
+
+ ▲ {{ item.likes }} · ▼ {{ item.dislikes }}
+
+
+
+ هنوز طرحی نفرستادهاید.
+ بارگذاری لیست ناموفق بود.
+
+
+
+
+
+
+
+
+
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a875c2b..5d2cab9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -35,6 +35,9 @@ importers:
axios:
specifier: ^1.7.7
version: 1.20.0
+ dayjs:
+ specifier: ^1.11.23
+ version: 1.11.23
lodash:
specifier: ^4.17.21
version: 4.18.1