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