feat: paginate customer orders and polish the dashboard hero

Add paged orders API and infinite scroll, short order numbers, and align the guest invite card with the logged-in hero shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
s.esmaeili
2026-10-17 13:11:42 +03:30
co-authored by Cursor
parent f550172567
commit afb068c643
89 changed files with 4518 additions and 1325 deletions
@@ -5,6 +5,9 @@ import { OrdersService } from './orders.service';
/** Order SMS is fire-and-forget (D20); these suites only assert order data. */
const smsStub = { send: jest.fn(async () => ({ ok: true })) };
/** Stands in for Postgres `nextval('order_number_seq')` inside the create tx. */
const nextOrderNumberRaw = () => jest.fn(async () => [{ n: 100n }]);
/**
* Owner ruling 2026-10-08: an order is credited to the customer's expert AT
* THE MOMENT IT IS PLACED (legacy `orders.reagent_id`), and a later change of
@@ -20,6 +23,7 @@ describe('OrdersService.createOrder — sales expert credit', () => {
return { id: 'o1', ...data };
}),
},
$queryRaw: nextOrderNumberRaw(),
};
const prisma = {
customer: {
@@ -48,6 +52,12 @@ describe('OrdersService.createOrder — sales expert credit', () => {
expect(created[0].salesAgentId).toBe('agent-A');
});
it('assigns a short numeric orderNumber from the sequence', async () => {
const { service, created } = build(null);
await service.createOrder(dto, 'c1');
expect(created[0].orderNumber).toBe('100');
});
it('records no expert for a customer without one', async () => {
const { service, created } = build(null);
await service.createOrder(dto, 'c1');
@@ -63,7 +73,10 @@ const wageStub = {
/** MON-05 (owner ruling 2026-10-08): «عدم سفارش» stops app orders only. */
describe('OrdersService.createOrder — order ban', () => {
function build(canPlaceOrders: boolean) {
const tx = { order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) } };
const tx = {
order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) },
$queryRaw: nextOrderNumberRaw(),
};
const prisma = {
customer: {
findUnique: jest.fn(async () => ({
@@ -98,7 +111,15 @@ describe('OrdersService.createOrder — order ban', () => {
describe('OrdersService.createOrder — server-side making charge', () => {
function build() {
const created: Record<string, any>[] = [];
const tx = { order: { create: jest.fn(async ({ data }: { data: Record<string, any> }) => { created.push(data); return { id: 'o1', ...data }; }) } };
const tx = {
order: {
create: jest.fn(async ({ data }: { data: Record<string, any> }) => {
created.push(data);
return { id: 'o1', ...data };
}),
},
$queryRaw: nextOrderNumberRaw(),
};
const prisma = {
customer: { findUnique: jest.fn(async () => ({ id: 'c1', canPlaceOrders: true, referredByAgentId: null, goldDebtGrams: new Prisma.Decimal(0), goldCreditLimitGrams: new Prisma.Decimal(1000) })) },
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
@@ -200,6 +221,75 @@ describe('OrdersService.createOrder — server-side making charge', () => {
});
});
/** App orders are B2B wholesale; RETAIL is only for the admin retail list. */
describe('OrdersService.createOrder — channel', () => {
function build() {
const created: Record<string, any>[] = [];
const tx = {
order: {
create: jest.fn(async ({ data }: { data: Record<string, any> }) => {
created.push(data);
return { id: 'o1', ...data };
}),
},
$queryRaw: nextOrderNumberRaw(),
};
const prisma = {
customer: {
findUnique: jest.fn(async () => ({
id: 'c1',
canPlaceOrders: true,
referredByAgentId: null,
goldDebtGrams: new Prisma.Decimal(0),
goldCreditLimitGrams: new Prisma.Decimal(1000),
})),
},
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
};
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
const service = new OrdersService(
prisma as never,
catalog as never,
{} as never,
{ orderStatusReached: jest.fn() } as never,
wageStub as never,
smsStub as never,
);
return { service, created };
}
const items = [{ productVariantId: 'v1', quantity: 1, wagePercent: 10 }];
it('stores an app order as WHOLESALE even when the body omits channel', async () => {
const { service, created } = build();
await service.createOrder({ items } as never, 'c1', { placedBy: 'customer' });
expect(created[0].channel).toBe('WHOLESALE');
});
it('ignores a RETAIL channel sent by the app', async () => {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
try {
const { service, created } = build();
await service.createOrder({ items, channel: 'RETAIL' } as never, 'c1', { placedBy: 'customer' });
expect(created[0].channel).toBe('WHOLESALE');
expect(warn.mock.calls.map((c) => String(c[0])).join('\n')).toContain('forcing WHOLESALE');
} finally {
warn.mockRestore();
}
});
it('keeps an operator-supplied RETAIL channel', async () => {
const { service, created } = build();
await service.createOrder({ items, channel: 'RETAIL' } as never, 'c1', { placedBy: 'operator' });
expect(created[0].channel).toBe('RETAIL');
});
it('defaults an operator order without channel to RETAIL', async () => {
const { service, created } = build();
await service.createOrder({ items } as never, 'c1', { placedBy: 'operator' });
expect(created[0].channel).toBe('RETAIL');
});
});
/**
* Legacy ApiMobileController::setOrder:733-766 texted three audiences on every
* placed order. None of it was wired in the target until now.
@@ -207,7 +297,10 @@ describe('OrdersService.createOrder — server-side making charge', () => {
describe('OrdersService.createOrder — order SMS', () => {
function build(opts: { overLimit?: boolean; agentId?: string | null; agentMobile?: string | null } = {}) {
const sent: { to: string; body: string }[] = [];
const tx = { order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) } };
const tx = {
order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) },
$queryRaw: nextOrderNumberRaw(),
};
const prisma = {
customer: {
findUnique: jest.fn(async () => ({
@@ -241,7 +334,7 @@ describe('OrdersService.createOrder — order SMS', () => {
await service.createOrder(dto, 'c1', { placedBy: 'customer' });
await settle();
const toCustomer = sent.find((s) => s.to === '09120000001');
expect(toCustomer?.body).toContain('به ثبت رسید. زرتاج');
expect(toCustomer?.body).toContain('به ثبت رسید. تهران تاج');
expect(toCustomer?.body).not.toContain('معلق');
});