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:
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user