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
@@ -1,8 +1,23 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { AccountingSyncService } from '../accounting-sync/accounting-sync.service';
/** Default page size for GET /customer/me/orders (mobile infinite scroll). */
const ORDERS_PAGE_LIMIT = 20;
const ORDERS_PAGE_LIMIT_MAX = 50;
const ORDER_STATUSES = new Set<string>(Object.values(OrderStatus));
export interface GetOrdersQuery {
page?: number;
limit?: number;
/** One OrderStatus value, or omit / ALL for every status. */
status?: string;
/** Substring match on `orderNumber`. */
q?: string;
}
// passwordHash must never leave the API — every customer read goes through this.
const PUBLIC_CUSTOMER_SELECT = {
id: true,
@@ -36,6 +51,8 @@ const PUBLIC_CUSTOMER_SELECT = {
*/
@Injectable()
export class CustomersService {
private readonly logger = new Logger(CustomersService.name);
constructor(
private readonly prisma: PrismaService,
private readonly accounting: AccountingSyncService,
@@ -51,52 +68,105 @@ export class CustomersService {
// D27 — with the accounting integration on, the customer sees the balance
// the accounting software holds. The apps only display these two fields;
// the credit-limit check runs server-side on the internal ledger, untouched.
const accounting = await this.accounting.customerBalance(customerId);
if (accounting) {
return {
...customer,
goldDebtGrams: accounting.gold,
fiatDebtRial: accounting.rial,
balanceSource: 'accounting' as const,
balanceAt: accounting.at,
};
//
// Accounting must never take down /customer/me: the dashboard treats a
// failed me() as "no customer" and shows the guest invite while the token
// (and logout) still work — orders keep loading because they skip this path.
try {
const accounting = await this.accounting.customerBalance(customerId);
if (accounting) {
return {
...customer,
goldDebtGrams: accounting.gold,
fiatDebtRial: accounting.rial,
balanceSource: 'accounting' as const,
balanceAt: accounting.at,
};
}
} catch (err) {
this.logger.warn(
`Balance overlay for ${customerId} failed; serving app ledger: ${(err as Error).message}`,
);
}
return { ...customer, balanceSource: 'app' as const, balanceAt: null };
}
getOrders(customerId: string) {
return this.prisma.order.findMany({
where: { customerId },
include: {
items: {
// Each item carried only `productVariantId`, so the apps looked the
// title up by loading the ENTIRE catalogue and joining in the browser
// (composables/useOrderItemProduct.ts). With ~16k products that is
// the wrong trade for a few row labels, and it also failed for an
// order whose product has since left the storefront — the catalogue
// hides it, so the row rendered blank.
//
// A nested read here is one join and always answers, including for
// products that are no longer on sale.
include: {
productVariant: {
select: {
id: true,
title: true,
product: {
select: {
id: true,
title: true,
images: { orderBy: { sortOrder: 'asc' }, take: 1, select: { url: true } },
},
/**
* Paginated order history for the signed-in customer.
*
* Shape matches the report endpoints (`result` / `count` / `page` / `limit`)
* so the apps can infinite-scroll the same way. Status and search are applied
* server-side — client-side filtering after a page slice would miss matches
* that sit on later pages.
*/
async getOrders(customerId: string, query: GetOrdersQuery = {}) {
const page = Math.max(1, Math.floor(query.page ?? 1) || 1);
const limit = Math.min(
ORDERS_PAGE_LIMIT_MAX,
Math.max(1, Math.floor(query.limit ?? ORDERS_PAGE_LIMIT) || ORDERS_PAGE_LIMIT),
);
const where: Prisma.OrderWhereInput = { customerId };
if (query.status && query.status !== 'ALL' && ORDER_STATUSES.has(query.status)) {
where.status = query.status as OrderStatus;
}
const q = query.q?.trim();
if (q) {
where.orderNumber = { contains: q, mode: 'insensitive' };
}
const include = {
items: {
// Each item carried only `productVariantId`, so the apps looked the
// title up by loading the ENTIRE catalogue and joining in the browser
// (composables/useOrderItemProduct.ts). With ~16k products that is
// the wrong trade for a few row labels, and it also failed for an
// order whose product has since left the storefront — the catalogue
// hides it, so the row rendered blank.
//
// A nested read here is one join and always answers, including for
// products that are no longer on sale.
include: {
productVariant: {
select: {
id: true,
title: true,
product: {
select: {
id: true,
title: true,
images: { orderBy: { sortOrder: 'asc' as const }, take: 1, select: { url: true } },
},
},
},
},
},
},
orderBy: { createdAt: 'desc' },
});
// Chronological status changes for the order-detail timeline in the app.
statusEvents: {
orderBy: { createdAt: 'asc' as const },
select: {
id: true,
fromStatus: true,
toStatus: true,
note: true,
createdAt: true,
},
},
};
const [result, count] = await Promise.all([
this.prisma.order.findMany({
where,
include,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.order.count({ where }),
]);
return { result, count, page, limit };
}
getLedgerEntries(customerId: string) {
@@ -0,0 +1,71 @@
import { NotFoundException } from '@nestjs/common';
import { CustomersService } from './customers.service';
describe('CustomersService.getOrThrow', () => {
const base = {
id: 'c1',
fullName: 'Honar',
mobile: '09382507041',
nationalCode: null,
cityId: null,
referredByAgentId: null,
goldCreditLimitGrams: '100',
goldDebtGrams: '1.5',
fiatDebtRial: '2000',
verificationStep: 'VERIFIED',
nationalIdImageUrl: null,
businessLicenseUrl: null,
shopImageUrl: null,
isActive: true,
isArchived: false,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
};
it('falls back to the app ledger when accounting throws', async () => {
const prisma = {
customer: { findUnique: jest.fn().mockResolvedValue(base) },
};
const accounting = {
customerBalance: jest.fn().mockRejectedValue(new Error('tahesab down')),
};
const service = new CustomersService(prisma as never, accounting as never);
await expect(service.getOrThrow('c1')).resolves.toMatchObject({
id: 'c1',
goldDebtGrams: '1.5',
fiatDebtRial: '2000',
balanceSource: 'app',
balanceAt: null,
});
});
it('overlays the accounting balance when available', async () => {
const prisma = {
customer: { findUnique: jest.fn().mockResolvedValue(base) },
};
const accounting = {
customerBalance: jest.fn().mockResolvedValue({
gold: '3.25',
rial: '9000',
at: new Date('2026-10-17'),
provider: 'ته‌حساب',
}),
};
const service = new CustomersService(prisma as never, accounting as never);
await expect(service.getOrThrow('c1')).resolves.toMatchObject({
goldDebtGrams: '3.25',
fiatDebtRial: '9000',
balanceSource: 'accounting',
});
});
it('404s when the customer is missing', async () => {
const prisma = {
customer: { findUnique: jest.fn().mockResolvedValue(null) },
};
const service = new CustomersService(prisma as never, { customerBalance: jest.fn() } as never);
await expect(service.getOrThrow('missing')).rejects.toBeInstanceOf(NotFoundException);
});
});
@@ -16,10 +16,11 @@ import { CustomersService } from './customers.service';
describe('CustomersService.getOrders — items carry their product', () => {
function setup() {
const findMany = jest.fn().mockResolvedValue([]);
const prisma = { order: { findMany } };
const count = jest.fn().mockResolvedValue(0);
const prisma = { order: { findMany, count } };
// getOrders touches neither the accounting sync nor anything on it.
const service = new CustomersService(prisma as never, {} as never);
return { service, findMany };
return { service, findMany, count };
}
it('includes the variant, its product and the first image', async () => {
@@ -39,6 +40,22 @@ describe('CustomersService.getOrders — items carry their product', () => {
});
});
it('includes status events oldest-first for the detail timeline', async () => {
const { service, findMany } = setup();
await service.getOrders('c1');
expect(findMany.mock.calls[0][0].include.statusEvents).toMatchObject({
orderBy: { createdAt: 'asc' },
select: {
id: true,
fromStatus: true,
toStatus: true,
note: true,
createdAt: true,
},
});
});
it('is scoped to the caller and newest first', async () => {
// Guards the two properties that were already here: one customer must never
// receive another's orders, and the list is reverse-chronological.
@@ -59,4 +76,34 @@ describe('CustomersService.getOrders — items carry their product', () => {
const product = findMany.mock.calls[0][0].include.items.include.productVariant.select.product;
expect(product.where).toBeUndefined();
});
it('pages with skip/take and returns count', async () => {
const { service, findMany, count } = setup();
findMany.mockResolvedValue([{ id: 'o1' }]);
count.mockResolvedValue(41);
await expect(service.getOrders('c1', { page: 2, limit: 20 })).resolves.toEqual({
result: [{ id: 'o1' }],
count: 41,
page: 2,
limit: 20,
});
expect(findMany.mock.calls[0][0]).toMatchObject({ skip: 20, take: 20 });
});
it('filters by status and orderNumber on the server', async () => {
const { service, findMany } = setup();
await service.getOrders('c1', { status: 'PLACED', q: '12' });
expect(findMany.mock.calls[0][0].where).toEqual({
customerId: 'c1',
status: 'PLACED',
orderNumber: { contains: '12', mode: 'insensitive' },
});
});
it('ignores an unknown status instead of throwing', async () => {
const { service, findMany } = setup();
await service.getOrders('c1', { status: 'NOT_A_STATUS' });
expect(findMany.mock.calls[0][0].where).toEqual({ customerId: 'c1' });
});
});