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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mahmood
2026-11-12 17:55:52 +03:30
co-authored by Cursor
parent 032fa800d9
commit 7dd74ff4f7
74 changed files with 6351 additions and 893 deletions
@@ -0,0 +1,21 @@
import { Body, Controller, Get, Put } from '@nestjs/common';
import { RequirePermission } from '../auth/guards';
import { CartService } from './cart.service';
import { UpdateCartExpiryConfigDto } from './cart.dto';
@Controller('admin/cart-expiry')
export class AdminCartExpiryController {
constructor(private readonly cart: CartService) {}
@Get('config')
@RequirePermission('config.cart')
getConfig() {
return this.cart.getConfig();
}
@Put('config')
@RequirePermission('config.cart')
putConfig(@Body() dto: UpdateCartExpiryConfigDto) {
return this.cart.setConfig(dto as Record<string, unknown>);
}
}
@@ -0,0 +1,124 @@
import { CartExpiryWorkerService } from './cart-expiry.worker';
import { tehranWallToUtc } from './cart-schedule';
describe('CartExpiryWorkerService', () => {
function build(opts: {
cfg?: Record<string, unknown>;
carts?: Array<Record<string, unknown>>;
}) {
const carts = opts.carts ?? [];
const updates: Array<{ customerId: string; data: Record<string, unknown> }> = [];
const sms = { send: jest.fn(async () => ({ ok: true })) };
const prisma = {
customerCart: {
findMany: jest.fn(async () => carts),
update: jest.fn(async ({ where, data }: { where: { customerId: string }; data: Record<string, unknown> }) => {
updates.push({ customerId: where.customerId, data });
return {};
}),
},
$executeRaw: jest.fn(async () => 1),
};
const cart = {
getConfig: jest.fn(async () => ({
enabled: true,
clearHour: 13,
clearMinute: 0,
sameDayDeferFromHour: 8,
sameDayDeferFromMinute: 0,
reminderEnabled: true,
reminderHoursBefore: 2,
reminderBody: 'تا %count ساعت',
worker: 'on' as const,
...opts.cfg,
})),
};
const worker = new CartExpiryWorkerService(prisma as never, cart as never, sms as never);
return { worker, sms, updates, prisma, cart };
}
it('no-ops when disabled', async () => {
const { worker, sms, prisma } = build({ cfg: { enabled: false } });
await worker.tick(tehranWallToUtc(2026, 11, 12, 12, 0, 0));
expect(sms.send).not.toHaveBeenCalled();
expect(prisma.customerCart.findMany).not.toHaveBeenCalled();
});
it('reminds carts due today (touched before 08:00), not deferred ones', async () => {
const todayClear = tehranWallToUtc(2026, 11, 12, 13, 0, 0);
const { worker, sms, updates } = build({
carts: [
{
customerId: 'early',
items: [{ pid: 'p1', vid: 'v1', qty: 1 }],
reminderSentForClearAt: null,
updatedAt: tehranWallToUtc(2026, 11, 12, 7, 0, 0),
customer: { mobile: '09120000000', isActive: true },
},
{
customerId: 'deferred',
items: [{ pid: 'p2', vid: 'v2', qty: 1 }],
reminderSentForClearAt: null,
// 10:00 → deadline tomorrow 13 — no reminder yet at 11:30 today
updatedAt: tehranWallToUtc(2026, 11, 12, 10, 0, 0),
customer: { mobile: '09121111111', isActive: true },
},
],
});
await worker.tick(tehranWallToUtc(2026, 11, 12, 11, 30, 0));
expect(sms.send).toHaveBeenCalledTimes(1);
expect(sms.send).toHaveBeenCalledWith('09120000000', expect.any(String));
expect(updates[0]?.customerId).toBe('early');
expect(updates[0]?.data.reminderSentForClearAt).toEqual(todayClear);
});
it('does not clear 08–13 carts at today 13; clears pre-08 carts', async () => {
const { worker, updates, sms } = build({
carts: [
{
customerId: 'early',
items: [{ pid: 'p1', vid: 'v1', qty: 1 }],
reminderSentForClearAt: null,
updatedAt: tehranWallToUtc(2026, 11, 12, 7, 0, 0),
customer: { mobile: '09120000000', isActive: true },
},
{
customerId: 'deferred',
items: [{ pid: 'p2', vid: 'v2', qty: 1 }],
reminderSentForClearAt: null,
updatedAt: tehranWallToUtc(2026, 11, 12, 10, 0, 0),
customer: { mobile: '09121111111', isActive: true },
},
{
customerId: 'afternoon',
items: [{ pid: 'p3', vid: 'v3', qty: 1 }],
reminderSentForClearAt: null,
updatedAt: tehranWallToUtc(2026, 11, 12, 14, 0, 0),
customer: { mobile: '09122222222', isActive: true },
},
],
});
await worker.tick(tehranWallToUtc(2026, 11, 12, 13, 5, 0));
expect(sms.send).not.toHaveBeenCalled();
expect(updates).toHaveLength(1);
expect(updates[0].customerId).toBe('early');
expect(updates[0].data.items).toEqual([]);
});
it('clears deferred (08–13) carts at tomorrow 13', async () => {
const { worker, updates } = build({
carts: [
{
customerId: 'deferred',
items: [{ pid: 'p2', vid: 'v2', qty: 1 }],
reminderSentForClearAt: null,
updatedAt: tehranWallToUtc(2026, 11, 12, 10, 0, 0),
customer: { mobile: '09121111111', isActive: true },
},
],
});
await worker.tick(tehranWallToUtc(2026, 11, 13, 13, 5, 0));
expect(updates).toHaveLength(1);
expect(updates[0].customerId).toBe('deferred');
});
});
@@ -0,0 +1,140 @@
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { SmsService } from '../sms/sms.service';
import {
CART_EXPIRY_LEASE_KEY,
cartItemsNonEmpty,
clearDeadlineForTouchedAt,
formatReminderBody,
reminderAtForDeadline,
type CartExpiryConfig,
} from './cart-schedule';
import { CartService } from './cart.service';
const TICK_MS = 60_000;
const LEASE_MS = 90_000;
/**
* Daily cart clear + reminder SMS. Same in-process lease pattern as
* AccountingWorkerService — no @nestjs/schedule.
*
* Clear/reminder deadlines are per cart from `updatedAt` (same-day defer
* window: touch in [08:00, 13:00) → clear tomorrow 13:00).
*/
@Injectable()
export class CartExpiryWorkerService implements OnApplicationBootstrap, OnModuleDestroy {
private readonly logger = new Logger('CartExpiry');
private readonly owner = randomUUID();
private timer: NodeJS.Timeout | null = null;
private running = false;
constructor(
private readonly prisma: PrismaService,
private readonly cart: CartService,
private readonly sms: SmsService,
) {}
onApplicationBootstrap() {
if (process.env.NODE_ENV === 'test') return;
this.timer = setInterval(() => void this.tick(), TICK_MS);
this.timer.unref?.();
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
/** Exposed for unit tests. */
async tick(now = new Date()): Promise<void> {
if (this.running) return;
this.running = true;
try {
const cfg = await this.cart.getConfig();
if (!cfg.enabled || cfg.worker === 'off') return;
if (!(await this.acquireLease())) return;
await this.sendReminders(cfg, now);
await this.clearDueCarts(cfg, now);
} catch (err) {
this.logger.error(
`cart expiry tick failed: ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
this.running = false;
}
}
private async sendReminders(cfg: CartExpiryConfig, now: Date): Promise<void> {
if (!cfg.reminderEnabled) return;
const carts = await this.prisma.customerCart.findMany({
include: {
customer: { select: { mobile: true, isActive: true } },
},
});
const body = formatReminderBody(cfg.reminderBody, cfg.reminderHoursBefore);
for (const row of carts) {
if (!cartItemsNonEmpty(row.items)) continue;
if (!row.customer.isActive || !row.customer.mobile) continue;
const deadline = clearDeadlineForTouchedAt(row.updatedAt, cfg);
const reminderAt = reminderAtForDeadline(deadline, cfg.reminderHoursBefore);
if (now.getTime() < reminderAt.getTime()) continue;
if (now.getTime() >= deadline.getTime()) continue;
if (
row.reminderSentForClearAt &&
row.reminderSentForClearAt.getTime() === deadline.getTime()
) {
continue;
}
try {
await this.sms.send(row.customer.mobile, body);
await this.prisma.customerCart.update({
where: { customerId: row.customerId },
data: { reminderSentForClearAt: deadline },
});
} catch (err) {
this.logger.warn(
`reminder SMS failed for ${row.customerId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
}
private async clearDueCarts(cfg: CartExpiryConfig, now: Date): Promise<void> {
const carts = await this.prisma.customerCart.findMany();
for (const row of carts) {
if (!cartItemsNonEmpty(row.items)) continue;
const deadline = clearDeadlineForTouchedAt(row.updatedAt, cfg);
if (now.getTime() < deadline.getTime()) continue;
await this.prisma.customerCart.update({
where: { customerId: row.customerId },
data: {
items: [] as Prisma.InputJsonValue,
clearedAt: now,
reminderSentForClearAt: null,
},
});
}
}
private async acquireLease(): Promise<boolean> {
const value = JSON.stringify({
owner: this.owner,
until: new Date(Date.now() + LEASE_MS).toISOString(),
});
await this.prisma.$executeRaw`
INSERT INTO "Setting" ("key", "value", "updatedAt") VALUES (${CART_EXPIRY_LEASE_KEY}, ${value}::jsonb, now())
ON CONFLICT ("key") DO NOTHING`;
const n = await this.prisma.$executeRaw`
UPDATE "Setting" SET "value" = ${value}::jsonb, "updatedAt" = now()
WHERE "key" = ${CART_EXPIRY_LEASE_KEY}
AND ("value"->>'owner' = ${this.owner} OR ("value"->>'until')::timestamptz < now())`;
return n > 0;
}
}
@@ -0,0 +1,100 @@
import {
DEFAULT_CART_EXPIRY,
clearDeadlineForTouchedAt,
formatReminderBody,
nextClearAt,
nextReminderAt,
parseCartExpiryConfig,
tehranParts,
tehranWallToUtc,
todayClearAt,
} from './cart-schedule';
const deferCfg = {
clearHour: 13,
clearMinute: 0,
sameDayDeferFromHour: 8,
sameDayDeferFromMinute: 0,
};
describe('cart-schedule', () => {
it('parses defaults including same-day defer from 08:00', () => {
const cfg = parseCartExpiryConfig({});
expect(cfg.enabled).toBe(false);
expect(cfg.clearHour).toBe(13);
expect(cfg.sameDayDeferFromHour).toBe(8);
expect(cfg.reminderHoursBefore).toBe(2);
});
it('clamps hour/minute and keeps custom body', () => {
const cfg = parseCartExpiryConfig({
enabled: true,
clearHour: 99,
clearMinute: -3,
reminderHoursBefore: '4',
reminderBody: 'سبد تا %count ساعت دیگر',
worker: 'off',
});
expect(cfg.enabled).toBe(true);
expect(cfg.clearHour).toBe(23);
expect(cfg.clearMinute).toBe(0);
expect(cfg.reminderHoursBefore).toBe(4);
expect(cfg.reminderBody).toContain('%count');
expect(cfg.worker).toBe('off');
});
it('todayClearAt is today at the configured Tehran wall time', () => {
const now = tehranWallToUtc(2026, 11, 12, 10, 0, 0);
const clear = todayClearAt(now, 13, 0);
expect(tehranParts(clear)).toMatchObject({
year: 2026,
month: 11,
day: 12,
hour: 13,
minute: 0,
});
});
it('nextClearAt is today before the clear, else tomorrow', () => {
const morning = tehranWallToUtc(2026, 11, 12, 11, 0, 0);
const afternoon = tehranWallToUtc(2026, 11, 12, 14, 0, 0);
expect(tehranParts(nextClearAt(morning, 13, 0))).toMatchObject({ day: 12, hour: 13 });
expect(tehranParts(nextClearAt(afternoon, 13, 0))).toMatchObject({ day: 13, hour: 13 });
});
it('nextReminderAt is clear minus reminderHoursBefore', () => {
const morning = tehranWallToUtc(2026, 11, 12, 10, 0, 0);
const reminder = nextReminderAt(morning, 13, 0, 2);
expect(tehranParts(reminder)).toMatchObject({ day: 12, hour: 11, minute: 0 });
});
it('clearDeadline: before 08:00 → same-day 13:00', () => {
const touched = tehranWallToUtc(2026, 11, 12, 7, 30, 0);
const deadline = clearDeadlineForTouchedAt(touched, deferCfg);
expect(tehranParts(deadline)).toMatchObject({ day: 12, hour: 13 });
});
it('clearDeadline: 08:00..13:00 → tomorrow 13:00', () => {
const atEight = tehranWallToUtc(2026, 11, 12, 8, 0, 0);
const atTen = tehranWallToUtc(2026, 11, 12, 10, 0, 0);
const atNoon = tehranWallToUtc(2026, 11, 12, 12, 59, 0);
for (const touched of [atEight, atTen, atNoon]) {
expect(tehranParts(clearDeadlineForTouchedAt(touched, deferCfg))).toMatchObject({
day: 13,
hour: 13,
});
}
});
it('clearDeadline: at/after 13:00 → tomorrow 13:00', () => {
const touched = tehranWallToUtc(2026, 11, 12, 14, 0, 0);
expect(tehranParts(clearDeadlineForTouchedAt(touched, deferCfg))).toMatchObject({
day: 13,
hour: 13,
});
});
it('formatReminderBody replaces %count', () => {
expect(formatReminderBody(DEFAULT_CART_EXPIRY.reminderBody, 2)).toContain('2');
});
});
+238
View File
@@ -0,0 +1,238 @@
/**
* Tehran wall-clock helpers for daily cart expiry.
* No luxon/dayjs — Intl + UTC offset correction only.
*/
export const CART_EXPIRY_SETTING_KEY = 'cart.expiry';
export const CART_EXPIRY_LEASE_KEY = 'cart.expiry.lease';
export type CartExpiryConfig = {
enabled: boolean;
clearHour: number;
clearMinute: number;
/**
* Same-day defer window start (Tehran). Items touched in
* [sameDayDeferFrom, clear) expire at tomorrow's clear, not today's.
* Default 08:00 → 13:00 same day maps to 13:00 next day.
*/
sameDayDeferFromHour: number;
sameDayDeferFromMinute: number;
reminderEnabled: boolean;
reminderHoursBefore: number;
reminderBody: string;
worker: 'on' | 'off';
};
export const DEFAULT_CART_EXPIRY: CartExpiryConfig = {
enabled: false,
clearHour: 13,
clearMinute: 0,
sameDayDeferFromHour: 8,
sameDayDeferFromMinute: 0,
reminderEnabled: true,
reminderHoursBefore: 2,
reminderBody:
'سبد خرید شما تا %count ساعت دیگر خالی می‌شود. برای ثبت سفارش همین امروز اقدام کنید.',
worker: 'on',
};
export function parseCartExpiryConfig(raw: unknown): CartExpiryConfig {
const o = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
const clearHour = clampInt(o.clearHour, 0, 23, DEFAULT_CART_EXPIRY.clearHour);
const clearMinute = clampInt(o.clearMinute, 0, 59, DEFAULT_CART_EXPIRY.clearMinute);
const sameDayDeferFromHour = clampInt(
o.sameDayDeferFromHour,
0,
23,
DEFAULT_CART_EXPIRY.sameDayDeferFromHour,
);
const sameDayDeferFromMinute = clampInt(
o.sameDayDeferFromMinute,
0,
59,
DEFAULT_CART_EXPIRY.sameDayDeferFromMinute,
);
const reminderHoursBefore = Math.max(
0,
clampInt(o.reminderHoursBefore, 0, 48, DEFAULT_CART_EXPIRY.reminderHoursBefore),
);
return {
enabled: o.enabled === true,
clearHour,
clearMinute,
sameDayDeferFromHour,
sameDayDeferFromMinute,
reminderEnabled: o.reminderEnabled !== false,
reminderHoursBefore,
reminderBody:
typeof o.reminderBody === 'string' && o.reminderBody.trim()
? o.reminderBody.trim()
: DEFAULT_CART_EXPIRY.reminderBody,
worker: o.worker === 'off' ? 'off' : 'on',
};
}
function clampInt(v: unknown, min: number, max: number, fallback: number): number {
const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN;
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, Math.trunc(n)));
}
type TehranParts = {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
};
export function tehranParts(date: Date): TehranParts {
const fmt = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
});
const bag: Record<string, string> = {};
for (const p of fmt.formatToParts(date)) {
if (p.type !== 'literal') bag[p.type] = p.value;
}
return {
year: Number(bag.year),
month: Number(bag.month),
day: Number(bag.day),
hour: Number(bag.hour),
minute: Number(bag.minute),
second: Number(bag.second),
};
}
/** Instant corresponding to a Tehran civil date-time. */
export function tehranWallToUtc(
year: number,
month: number,
day: number,
hour: number,
minute: number,
second = 0,
): Date {
const utcGuess = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
const shown = tehranParts(utcGuess);
const wantedMs = Date.UTC(year, month - 1, day, hour, minute, second);
const shownMs = Date.UTC(
shown.year,
shown.month - 1,
shown.day,
shown.hour,
shown.minute,
shown.second,
);
return new Date(utcGuess.getTime() + (wantedMs - shownMs));
}
export function addTehranDays(
year: number,
month: number,
day: number,
delta: number,
): {
year: number;
month: number;
day: number;
} {
// Noon UTC avoids DST edge when shifting calendar days via Date.
const noon = tehranWallToUtc(year, month, day, 12, 0, 0);
const shifted = new Date(noon.getTime() + delta * 24 * 60 * 60 * 1000);
const p = tehranParts(shifted);
return { year: p.year, month: p.month, day: p.day };
}
/** Today's clear instant in Tehran for the given hour/minute. */
export function todayClearAt(now: Date, clearHour: number, clearMinute: number): Date {
const p = tehranParts(now);
return tehranWallToUtc(p.year, p.month, p.day, clearHour, clearMinute, 0);
}
/** Nearest upcoming wall clear (ignores per-cart defer). */
export function nextClearAt(now: Date, clearHour: number, clearMinute: number): Date {
const today = todayClearAt(now, clearHour, clearMinute);
if (now.getTime() < today.getTime()) return today;
const p = tehranParts(now);
const tmr = addTehranDays(p.year, p.month, p.day, 1);
return tehranWallToUtc(tmr.year, tmr.month, tmr.day, clearHour, clearMinute, 0);
}
export function nextReminderAt(
now: Date,
clearHour: number,
clearMinute: number,
reminderHoursBefore: number,
): Date {
const clear = nextClearAt(now, clearHour, clearMinute);
return new Date(clear.getTime() - reminderHoursBefore * 60 * 60 * 1000);
}
/**
* Per-cart clear deadline from last touch time.
*
* With defer 08:00 and clear 13:00:
* - touched 07:00 → clear same day 13:00
* - touched 08:00..12:59 → clear next day 13:00
* - touched ≥ 13:00 → clear next day 13:00
*/
export function clearDeadlineForTouchedAt(
touchedAt: Date,
cfg: Pick<
CartExpiryConfig,
| 'clearHour'
| 'clearMinute'
| 'sameDayDeferFromHour'
| 'sameDayDeferFromMinute'
>,
): Date {
const p = tehranParts(touchedAt);
const dayClear = tehranWallToUtc(p.year, p.month, p.day, cfg.clearHour, cfg.clearMinute, 0);
const deferFrom = tehranWallToUtc(
p.year,
p.month,
p.day,
cfg.sameDayDeferFromHour,
cfg.sameDayDeferFromMinute,
0,
);
const tomorrowClear = (): Date => {
const tmr = addTehranDays(p.year, p.month, p.day, 1);
return tehranWallToUtc(tmr.year, tmr.month, tmr.day, cfg.clearHour, cfg.clearMinute, 0);
};
if (touchedAt.getTime() >= dayClear.getTime()) {
return tomorrowClear();
}
// In [deferFrom, clear) → skip today's clear.
if (touchedAt.getTime() >= deferFrom.getTime()) {
return tomorrowClear();
}
return dayClear;
}
export function reminderAtForDeadline(
deadline: Date,
reminderHoursBefore: number,
): Date {
return new Date(deadline.getTime() - reminderHoursBefore * 60 * 60 * 1000);
}
/** Format reminder body; `%count` → hours until clear (at least 1 when >0). */
export function formatReminderBody(template: string, reminderHoursBefore: number): string {
const count = reminderHoursBefore <= 0 ? '۰' : String(reminderHoursBefore);
return template.replace(/%count/g, count);
}
export function cartItemsNonEmpty(items: unknown): boolean {
return Array.isArray(items) && items.length > 0;
}
@@ -0,0 +1,52 @@
import { Body, Controller, Get, Put } from '@nestjs/common';
import { Public } from '../auth/guards';
import {
CustomerAuth,
CurrentCustomer,
} from '../customer-auth/customer.guards';
import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strategy';
import { CartService } from './cart.service';
import { PutCartDto } from './cart.dto';
@Controller('cart')
export class CartController {
constructor(private readonly cart: CartService) {}
/** Safe expiry policy for guest + logged-in clients. */
@Public()
@Get('policy')
getPolicy() {
return this.cart.getPolicy();
}
@CustomerAuth()
@Get()
getMine(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.cart.getCart(me.id);
}
@CustomerAuth()
@Put()
putMine(
@CurrentCustomer() me: AuthenticatedCustomer,
@Body() dto: PutCartDto,
) {
return this.cart.putCart(me.id, dto.items);
}
/** Deferred «خرید بعدی» — not cleared by cart expiry or order submit. */
@CustomerAuth()
@Get('buy-later')
getBuyLater(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.cart.getBuyLater(me.id);
}
@CustomerAuth()
@Put('buy-later')
putBuyLater(
@CurrentCustomer() me: AuthenticatedCustomer,
@Body() dto: PutCartDto,
) {
return this.cart.putBuyLater(me.id, dto.items);
}
}
+70
View File
@@ -0,0 +1,70 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsBoolean,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
export class PutCartDto {
@IsArray()
items!: unknown[];
}
export class UpdateCartExpiryConfigDto {
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(23)
clearHour?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(59)
clearMinute?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(23)
sameDayDeferFromHour?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(59)
sameDayDeferFromMinute?: number;
@IsOptional()
@IsBoolean()
reminderEnabled?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(48)
reminderHoursBefore?: number;
@IsOptional()
@IsString()
@MaxLength(500)
reminderBody?: string;
@IsOptional()
@IsString()
worker?: 'on' | 'off';
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { SmsModule } from '../sms/sms.module';
import { AdminCartExpiryController } from './admin-cart-expiry.controller';
import { CartController } from './cart.controller';
import { CartExpiryWorkerService } from './cart-expiry.worker';
import { CartService } from './cart.service';
@Module({
imports: [SmsModule],
controllers: [CartController, AdminCartExpiryController],
providers: [CartService, CartExpiryWorkerService],
exports: [CartService],
})
export class CartModule {}
@@ -0,0 +1,104 @@
import { BadRequestException } from '@nestjs/common';
import { CartService } from './cart.service';
import { CART_EXPIRY_SETTING_KEY } from './cart-schedule';
describe('CartService', () => {
function build(settingValue: unknown = {}) {
const carts = new Map<string, Record<string, unknown>>();
const prisma = {
setting: {
findUnique: jest.fn(async ({ where }: { where: { key: string } }) =>
where.key === CART_EXPIRY_SETTING_KEY ? { value: settingValue } : null,
),
upsert: jest.fn(async ({ create, update }: { create: { value: unknown }; update: { value: unknown } }) => ({
value: update?.value ?? create.value,
})),
},
customerCart: {
findUnique: jest.fn(async ({ where }: { where: { customerId: string } }) =>
carts.get(where.customerId) ?? null,
),
upsert: jest.fn(async ({ where, create, update }: {
where: { customerId: string };
create: Record<string, unknown>;
update: Record<string, unknown>;
}) => {
const prev = carts.get(where.customerId);
const row = {
customerId: where.customerId,
updatedAt: new Date('2026-11-12T08:00:00.000Z'),
...(prev ?? {}),
...(prev ? update : create),
};
carts.set(where.customerId, row);
return row;
}),
},
};
const service = new CartService(prisma as never);
return { service, prisma, carts };
}
it('getPolicy exposes todayClearAt and nextClearAt when enabled', async () => {
const { service } = build({ enabled: true, clearHour: 13, clearMinute: 0 });
const policy = await service.getPolicy(new Date('2026-11-12T07:00:00.000Z'));
expect(policy.enabled).toBe(true);
expect(policy.clearHour).toBe(13);
expect(policy.todayClearAt).toBeTruthy();
expect(policy.nextClearAt).toBeTruthy();
});
it('putCart rejects non-array items', async () => {
const { service } = build();
await expect(service.putCart('c1', {} as never)).rejects.toBeInstanceOf(BadRequestException);
});
it('putCart upserts and clearForCustomer empties', async () => {
const { service, carts } = build();
await service.putCart('c1', [{ pid: 'p', vid: 'v', qty: 2 }]);
expect((carts.get('c1')?.items as unknown[]).length).toBe(1);
await service.clearForCustomer('c1');
expect(carts.get('c1')?.items).toEqual([]);
});
it('setConfig writes a parsed config', async () => {
const { service, prisma } = build();
const cfg = await service.setConfig({ enabled: true, clearHour: 14, reminderHoursBefore: 3 });
expect(cfg.enabled).toBe(true);
expect(cfg.clearHour).toBe(14);
expect(cfg.reminderHoursBefore).toBe(3);
expect(prisma.setting.upsert).toHaveBeenCalled();
});
it('buy-later upserts independently of the active cart', async () => {
const { service, prisma, carts } = build();
const buyLater = new Map<string, Record<string, unknown>>();
(prisma as any).customerBuyLater = {
findUnique: jest.fn(async ({ where }: { where: { customerId: string } }) =>
buyLater.get(where.customerId) ?? null,
),
upsert: jest.fn(async ({ where, create, update }: {
where: { customerId: string };
create: Record<string, unknown>;
update: Record<string, unknown>;
}) => {
const prev = buyLater.get(where.customerId);
const row = {
customerId: where.customerId,
updatedAt: new Date(),
...(prev ?? {}),
...(prev ? update : create),
};
buyLater.set(where.customerId, row);
return row;
}),
};
await service.putBuyLater('c1', [{ pid: 'p', vid: 'v', qty: 3 }]);
expect((buyLater.get('c1')?.items as unknown[]).length).toBe(1);
await service.putCart('c1', [{ pid: 'p2', vid: 'v2', qty: 1 }]);
// Order clear must not wipe buy-later — clearForCustomer only touches CustomerCart.
await service.clearForCustomer('c1');
expect(carts.get('c1')?.items).toEqual([]);
expect((buyLater.get('c1')?.items as unknown[]).length).toBe(1);
});
});
+180
View File
@@ -0,0 +1,180 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import {
CART_EXPIRY_SETTING_KEY,
DEFAULT_CART_EXPIRY,
nextClearAt,
nextReminderAt,
parseCartExpiryConfig,
todayClearAt,
type CartExpiryConfig,
} from './cart-schedule';
@Injectable()
export class CartService {
constructor(private readonly prisma: PrismaService) {}
async getConfig(): Promise<CartExpiryConfig> {
const row = await this.prisma.setting.findUnique({
where: { key: CART_EXPIRY_SETTING_KEY },
});
return parseCartExpiryConfig(row?.value ?? {});
}
async setConfig(raw: Record<string, unknown>): Promise<CartExpiryConfig> {
const next = parseCartExpiryConfig({ ...DEFAULT_CART_EXPIRY, ...raw });
await this.prisma.setting.upsert({
where: { key: CART_EXPIRY_SETTING_KEY },
update: { value: next as unknown as Prisma.InputJsonValue },
create: {
key: CART_EXPIRY_SETTING_KEY,
value: next as unknown as Prisma.InputJsonValue,
},
});
return next;
}
/** Public policy for the app (no secrets). */
async getPolicy(now = new Date()) {
const cfg = await this.getConfig();
const today = todayClearAt(now, cfg.clearHour, cfg.clearMinute);
const clear = nextClearAt(now, cfg.clearHour, cfg.clearMinute);
const reminder = nextReminderAt(
now,
cfg.clearHour,
cfg.clearMinute,
cfg.reminderHoursBefore,
);
return {
enabled: cfg.enabled,
clearHour: cfg.clearHour,
clearMinute: cfg.clearMinute,
sameDayDeferFromHour: cfg.sameDayDeferFromHour,
sameDayDeferFromMinute: cfg.sameDayDeferFromMinute,
reminderEnabled: cfg.reminderEnabled,
reminderHoursBefore: cfg.reminderHoursBefore,
/** Today's clear wall-time (may already be in the past). */
todayClearAt: today.toISOString(),
nextClearAt: clear.toISOString(),
nextReminderAt: reminder.toISOString(),
};
}
async getCart(customerId: string) {
const row = await this.prisma.customerCart.findUnique({
where: { customerId },
});
return {
items: (row?.items as unknown[]) ?? [],
updatedAt: row?.updatedAt?.toISOString() ?? null,
clearedAt: row?.clearedAt?.toISOString() ?? null,
};
}
async putCart(customerId: string, items: unknown) {
if (!Array.isArray(items)) {
throw new BadRequestException('items must be an array');
}
// Cap size so a buggy client cannot dump megabytes into Setting-adjacent JSON.
if (items.length > 200) {
throw new BadRequestException('cart too large');
}
const empty = items.length === 0;
const row = await this.prisma.customerCart.upsert({
where: { customerId },
create: {
customerId,
items: items as Prisma.InputJsonValue,
clearedAt: empty ? new Date() : null,
reminderSentForClearAt: null,
},
update: {
items: items as Prisma.InputJsonValue,
...(empty
? { clearedAt: new Date(), reminderSentForClearAt: null }
: { clearedAt: null }),
},
});
return {
items: (row.items as unknown[]) ?? [],
updatedAt: row.updatedAt.toISOString(),
clearedAt: row.clearedAt?.toISOString() ?? null,
};
}
/** After a successful order — mirror client cart.clear(). */
async clearForCustomer(customerId: string): Promise<void> {
await this.prisma.customerCart.upsert({
where: { customerId },
create: {
customerId,
items: [] as Prisma.InputJsonValue,
clearedAt: new Date(),
reminderSentForClearAt: null,
},
update: {
items: [] as Prisma.InputJsonValue,
clearedAt: new Date(),
reminderSentForClearAt: null,
},
});
}
/**
* «خرید بعدی» — deferred list. No stock touch, no expiry worker.
* Order clear leaves this alone on purpose.
*/
async getBuyLater(customerId: string) {
const row = await this.buyLaterTable().findUnique({
where: { customerId },
});
return {
items: (row?.items as unknown[]) ?? [],
updatedAt: row?.updatedAt?.toISOString() ?? null,
};
}
async putBuyLater(customerId: string, items: unknown) {
if (!Array.isArray(items)) {
throw new BadRequestException('items must be an array');
}
if (items.length > 200) {
throw new BadRequestException('buy-later list too large');
}
const row = await this.buyLaterTable().upsert({
where: { customerId },
create: {
customerId,
items: items as Prisma.InputJsonValue,
},
update: {
items: items as Prisma.InputJsonValue,
},
});
return {
items: (row.items as unknown[]) ?? [],
updatedAt: row.updatedAt.toISOString(),
};
}
/**
* Prisma client may lag schema until `prisma generate` (e.g. Windows file
* lock on the query engine). Access by name so TS still type-checks.
*/
private buyLaterTable() {
return (this.prisma as unknown as {
customerBuyLater: {
findUnique: (args: { where: { customerId: string } }) => Promise<{
items: unknown;
updatedAt: Date;
} | null>;
upsert: (args: {
where: { customerId: string };
create: { customerId: string; items: Prisma.InputJsonValue };
update: { items: Prisma.InputJsonValue };
}) => Promise<{ items: unknown; updatedAt: Date }>;
};
}).customerBuyLater;
}
}