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
@@ -47,7 +47,11 @@ export class CreateOrderDto {
/**
* Which sales channel placed the order. Legacy had no such field because the
* two channels lived in separate tables (orders vs orders_orders); the one
* Order model carries the distinction here. Defaults to RETAIL.
* Order model carries the distinction here.
*
* On the customer (app) path the server forces WHOLESALE and ignores this
* field. Operators may set it; admin MakeOrder always sends WHOLESALE.
* Default when an operator omits it remains RETAIL (retail list builder).
*/
@IsOptional()
@IsEnum(OrderChannel)
@@ -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('معلق');
});
+34 -5
View File
@@ -135,13 +135,29 @@ export class OrdersService {
.gt(customer.goldCreditLimitGrams);
const initialStatus = isSuspendedOverCreditLimit ? OrderStatus.SUSPENDED : OrderStatus.PLACED;
// App (B2B) orders are always WHOLESALE. Legacy wrote them to `orders`,
// not `orders_orders` (RETAIL). The DTO default was RETAIL and the apps
// never sent `channel`, so every app order landed on the retail list.
// Operators still pick the channel (admin MakeOrder forces WHOLESALE;
// the retail list builder can pass RETAIL). A client-supplied channel on
// the customer path is ignored — same posture as MON-08 pricing.
const channel =
opts.placedBy === 'customer'
? OrderChannel.WHOLESALE
: (dto.channel ?? OrderChannel.RETAIL);
if (opts.placedBy === 'customer' && dto.channel && dto.channel !== OrderChannel.WHOLESALE) {
this.logger.warn(
`Order body carried channel=${dto.channel} from the app; forcing WHOLESALE.`,
);
}
const placed = await this.prisma.$transaction(async (tx) => {
const order = await tx.order.create({
data: {
orderNumber: this.generateOrderNumber(),
orderNumber: await this.nextOrderNumber(tx),
customerId,
branchId: dto.branchId,
channel: dto.channel ?? OrderChannel.RETAIL,
channel,
status: initialStatus,
totalGoldWeightGrams,
wageWeightGrams,
@@ -360,8 +376,21 @@ export class OrdersService {
return order;
}
private generateOrderNumber(): string {
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
return `ORD-${Date.now()}-${random}`;
/**
* Short global sequential number for the customer, SMS and search
* (`100`, `101`, …). Allocated from Postgres `order_number_seq` inside the
* placement transaction so concurrent creates cannot collide. Legacy used
* the auto-increment order id the same way; the old `ORD-{ts}-{rand}` form
* was too long for SMS and the list UI.
*/
private async nextOrderNumber(tx: Prisma.TransactionClient): Promise<string> {
const rows = await tx.$queryRaw<Array<{ n: bigint | number | string }>>`
SELECT nextval('order_number_seq') AS n
`;
const n = rows[0]?.n;
if (n === undefined || n === null) {
throw new Error('order_number_seq returned no value');
}
return String(n);
}
}