feat: product design surveys, cart expiry/buy-later, and catalog polish

Add like/dislike surveys with majority alerts for manufacturing decisions, clearer accounting mapping copy, and related cart/catalog admin-app improvements.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mahmood
2026-11-12 17:55:52 +03:30
co-authored by Cursor
parent 032fa800d9
commit 7dd74ff4f7
74 changed files with 6351 additions and 893 deletions
@@ -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;
@@ -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");
@@ -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;
@@ -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;
@@ -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;
+121
View File
@@ -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
+4
View File
@@ -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,
@@ -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<string, unknown> = {}) {
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<string, unknown> {
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 } : {}),
};
}
@@ -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) {
@@ -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,
@@ -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;
}
@@ -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<string, unknown>) => {
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.
@@ -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')
@@ -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
@@ -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)
@@ -20,6 +20,9 @@ const SHOW_TYPE_LABELS: Record<number, string> = {
3: 'سایت و گوشی',
};
/** Setting key of the «ثابت‌ها → برچسب‌ها» list. */
export const TAG_LIST_KEY = '__list__tags';
export const VARIANT_COLORS: Record<number, string> = {
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<string[]> {
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<string[]> {
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);
@@ -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',
+1
View File
@@ -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 {
@@ -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<string, unknown>);
}
}
@@ -0,0 +1,124 @@
import { CartExpiryWorkerService } from './cart-expiry.worker';
import { tehranWallToUtc } from './cart-schedule';
describe('CartExpiryWorkerService', () => {
function build(opts: {
cfg?: Record<string, unknown>;
carts?: Array<Record<string, unknown>>;
}) {
const carts = opts.carts ?? [];
const updates: Array<{ customerId: string; data: Record<string, unknown> }> = [];
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<string, unknown> }) => {
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');
});
});
@@ -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<void> {
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<void> {
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<void> {
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<boolean> {
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;
}
}
@@ -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');
});
});
+238
View File
@@ -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<string, unknown>) : {};
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<string, string> = {};
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;
}
@@ -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);
}
}
+70
View File
@@ -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';
}
+14
View File
@@ -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 {}
@@ -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<string, Record<string, unknown>>();
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<string, unknown>;
update: Record<string, unknown>;
}) => {
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<string, Record<string, unknown>>();
(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<string, unknown>;
update: Record<string, unknown>;
}) => {
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);
});
});
+180
View File
@@ -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<CartExpiryConfig> {
const row = await this.prisma.setting.findUnique({
where: { key: CART_EXPIRY_SETTING_KEY },
});
return parseCartExpiryConfig(row?.value ?? {});
}
async setConfig(raw: Record<string, unknown>): Promise<CartExpiryConfig> {
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<void> {
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;
}
}
@@ -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);
}
}
@@ -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).
@@ -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
+2 -1
View File
@@ -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],
@@ -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 };
}
@@ -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;
}
@@ -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 ?? {});
}
}
@@ -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 ?? {});
}
}
@@ -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 {}
@@ -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<string, unknown> | 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<string, unknown> }) => {
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<string, unknown> }) => {
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,
);
});
});
@@ -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<ProductSurveyStatus, string> = {
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<string> {
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<string, { likes: number; dislikes: number }>();
const rows = await this.prisma.productSurveyVote.groupBy({
by: ['surveyId', 'value'],
where: { surveyId: { in: surveyIds } },
_count: { _all: true },
});
const map = new Map<string, { likes: number; dislikes: number }>();
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,
};
}
}