diff --git a/apps/api/src/modules/customers/customers.service.ts b/apps/api/src/modules/customers/customers.service.ts index 7ab8040..95e3e74 100644 --- a/apps/api/src/modules/customers/customers.service.ts +++ b/apps/api/src/modules/customers/customers.service.ts @@ -67,7 +67,34 @@ export class CustomersService { getOrders(customerId: string) { return this.prisma.order.findMany({ where: { customerId }, - include: { items: true }, + 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 } }, + }, + }, + }, + }, + }, + }, + }, orderBy: { createdAt: 'desc' }, }); } diff --git a/apps/api/src/modules/customers/order-items-include.spec.ts b/apps/api/src/modules/customers/order-items-include.spec.ts new file mode 100644 index 0000000..a26bb86 --- /dev/null +++ b/apps/api/src/modules/customers/order-items-include.spec.ts @@ -0,0 +1,62 @@ +import { CustomersService } from './customers.service'; + +/** + * `GET customer/me/orders` must carry each item's product and variant. + * + * The apps used to label order rows by downloading the ENTIRE catalogue and + * joining it in the browser (composables/useOrderItemProduct.ts). With the real + * catalogue at ~16k products that is the wrong trade for a few row labels, and + * it was also broken for the case order history exists for: a product that has + * since left the storefront is absent from the catalogue, so the row rendered + * blank. + * + * Both apps now read these fields straight off the item, so the include is part + * of the contract — hence a test, which this method did not have. + */ +describe('CustomersService.getOrders — items carry their product', () => { + function setup() { + const findMany = jest.fn().mockResolvedValue([]); + const prisma = { order: { findMany } }; + // getOrders touches neither the accounting sync nor anything on it. + const service = new CustomersService(prisma as never, {} as never); + return { service, findMany }; + } + + it('includes the variant, its product and the first image', async () => { + const { service, findMany } = setup(); + await service.getOrders('c1'); + + const include = findMany.mock.calls[0][0].include; + const variant = include.items.include.productVariant; + + expect(variant.select.title).toBe(true); + expect(variant.select.product.select.title).toBe(true); + // images[0] is the product's main photo, so it must be ordered and limited + // to one — the apps read `images[0].url` and nothing else. + expect(variant.select.product.select.images).toMatchObject({ + orderBy: { sortOrder: 'asc' }, + take: 1, + }); + }); + + 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. + const { service, findMany } = setup(); + await service.getOrders('c1'); + expect(findMany.mock.calls[0][0]).toMatchObject({ + where: { customerId: 'c1' }, + orderBy: { createdAt: 'desc' }, + }); + }); + + it('does not filter the product by storefront visibility', async () => { + // Deliberate: an order for a product that has since been deactivated, or + // had its photo removed, must still show what was bought. The storefront + // rules apply to the catalogue, not to history. + const { service, findMany } = setup(); + await service.getOrders('c1'); + const product = findMany.mock.calls[0][0].include.items.include.productVariant.select.product; + expect(product.where).toBeUndefined(); + }); +}); diff --git a/apps/user-app-v2/src/api/client.ts b/apps/user-app-v2/src/api/client.ts index b2eb631..17c6b2a 100644 --- a/apps/user-app-v2/src/api/client.ts +++ b/apps/user-app-v2/src/api/client.ts @@ -4,6 +4,8 @@ const BASE = '/api'; const TOKEN_KEY = 'gold-bank:token'; /** Must match stores/session.ts — the act-as target, if any. */ const ACTING_AS_KEY = 'gold-bank:actingAs'; +/** Must match MAX_IDS on the server's ListProductsDto. */ +const ID_CHUNK = 100; /** * Called when the API rejects our token. Wired up in main.ts rather than @@ -130,6 +132,54 @@ export interface ProductSummary { collections: Array<{ collectionId: string; collection: CollectionSummary }>; } +/** Query for the paged storefront list; every field is optional. */ +export interface ProductQuery { + page?: number; + limit?: number; + search?: string; + categoryId?: string; + brandId?: string; + collectionId?: string; + minGram?: number; + maxGram?: number; + sort?: 'newest' | 'oldest' | 'title'; +} + +/** + * One page of products. `total` is the size of the FILTERED set, not of the + * page, so it both drives the "N محصول" count and tells the list when to stop + * asking for more. + */ +export interface ProductPage { + rows: ProductSummary[]; + total: number; + page: number; + limit: number; +} + +/** Filter options, built from the catalogue server-side. */ +export interface CatalogFacets { + categories: CategorySummary[]; + brands: BrandSummary[]; + collections: CollectionSummary[]; + /** Decimal strings, like every other weight this API returns. */ + gramRange: { min: string; max: string }; +} + +/** + * Builds `?a=1&b=2`, dropping anything unset so the server sees a bare request + * rather than `?search=undefined` — which would be a filter for the literal + * text "undefined". + */ +function toQueryString(query: object): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== '') params.set(key, String(value)); + } + const s = params.toString(); + return s ? `?${s}` : ''; +} + export interface LedgerEntrySummary { id: string; currency: string; @@ -158,6 +208,22 @@ export interface OrderItemDetail { discountPercentSnapshot: string; computedWeightGrams: string; flatRialCharge: string; + /** + * The product and variant this line is for, included by the server. + * + * Optional because an order fetched by an older client — or a cached response + * from before the server included it — has no such field. The apps fall back + * to a plain label rather than showing nothing. + * + * It used to be looked up in the browser against the whole catalogue, which + * both downloaded ~16k products for a few row labels and rendered blank for a + * product that had since left the storefront. + */ + productVariant?: { + id: string; + title: string; + product: { id: string; title: string; images: Array<{ url: string }> }; + } | null; } export interface OrderDetail { @@ -331,7 +397,53 @@ function iamAppUrl(path: string) { } export const api = { + /** + * The WHOLE active catalogue in one response. + * + * The real catalogue reaches ~16k products, so prefer the paged/targeted + * calls below; this stays for the screens that genuinely work over the full + * list and is what the route answers when asked for nothing in particular. + */ listProducts: () => request('/catalog/products'), + + /** One page of the storefront, filtered and counted server-side. */ + listProductsPaged: (query: ProductQuery = {}) => + request(`/catalog/products${toQueryString(query)}`), + + /** + * One product, instead of downloading the catalogue to `find()` it. + * 404s for a product that is not on the storefront (inactive, or no photo). + */ + getProduct: (id: string) => request(`/catalog/products/${encodeURIComponent(id)}`), + + /** + * Specific products by id — the cart, favourites, order items. + * + * Chunked because the route caps `ids` at 100 and drops the rest: a caller + * that asked for more would silently get a partial answer, and callers treat + * "missing from the answer" as "no longer on the storefront". Favourites + * already run to the high hundreds in the imported data (874 rows, FAV-01), + * so this is a live case, not a theoretical one. + */ + getProductsByIds: async (ids: string[]): Promise => { + const unique = [...new Set(ids)].filter(Boolean); + if (unique.length === 0) return []; + const chunks = Array.from({ length: Math.ceil(unique.length / ID_CHUNK) }, (_, i) => + unique.slice(i * ID_CHUNK, (i + 1) * ID_CHUNK), + ); + const pages = await Promise.all( + chunks.map((chunk) => + request(`/catalog/products?ids=${chunk.map(encodeURIComponent).join(',')}`), + ), + ); + return pages.flatMap((p) => p.rows); + }, + + /** + * The filter sheet's options (categories, brands, collections, gram range), + * which used to be derived by scanning the whole catalogue in the browser. + */ + getCatalogFacets: () => request('/catalog/products/facets'), /** * Step 1 of login — ports henza_server ApiMobileController::userAuth. * Always resolves `{ sent: true }`, even for an unregistered number, so the diff --git a/apps/user-app-v2/src/components/product/OrderItemCard.vue b/apps/user-app-v2/src/components/product/OrderItemCard.vue index f1f37eb..8cfe750 100644 --- a/apps/user-app-v2/src/components/product/OrderItemCard.vue +++ b/apps/user-app-v2/src/components/product/OrderItemCard.vue @@ -3,9 +3,10 @@ language (see components/home/* and HomeView). Ported originally from legacy henza_pwa/src/components/product/orderItem.vue. - Adapted: product/variant title comes from a client-side lookup (see - useOrderItemProduct.ts) since the real order-items response only carries - productVariantId. + The product/variant title and photo come WITH the order (the server includes + each item's variant and product). They used to be looked up in the browser + against the whole catalogue, which downloaded ~16k products to label a few + rows and left the row blank for a product that had since left the storefront. DROPPED the "توضیحات: -" row the previous version rendered: our OrderItem schema has no note field (legacy's per-item note was a real column there; @@ -46,18 +47,17 @@