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:
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user