feat(apps): read products by id instead of loading the whole catalogue
Step 3 of docs/CATALOG-PAGINATION-PLAN.md. Step 2 added the targeted routes; these are the five callers that were fetching all ~16k products to find a handful of rows: product screen whole catalogue, then find(id) -> GET /products/:id cart re-sync whole catalogue for 2-3 lines -> ?ids= favourites whole catalogue, filtered here -> ?ids= home categories categories derived from it -> /products/facets order row labels joined on variantId -> included with the order Three of these were not merely slow, they were going to be wrong. Favourites filtered the loaded catalogue, so once the list pages a favourite outside the loaded slice silently disappears. Home categories were derived the same way, so a category whose products all sit further down would vanish from the home screen. Order rows looked their title up in the catalogue, which ALREADY failed for a product that had since left the storefront — exactly the old orders history exists for — and rendered a blank row. Order labels went to the server rather than to another id lookup: the item carries productVariantId, not a product id, so ?ids= could not answer it. One nested include in getOrders returns the variant, its product and the product's first image. Deliberately NOT filtered by storefront visibility, so a de-listed product still shows what was bought; asserted in the new spec. OrderItemCard is synchronous now — no fetch, no onMounted, the row renders complete on its first frame. variantLookup is deleted from catalogStore rather than left behind: it existed only to label order rows, and keeping it would have kept a reason to load the full catalogue. getProductsByIds chunks at 100 because the route caps ids there and drops the rest. A partial answer reads to callers as "these products are no longer on the storefront" — the cart would silently drop valid lines. Favourites already run to 874 rows in the imported data (FAV-01). A failed cart re-sync now leaves the cart alone: it is the person's own data in localStorage, and dropping lines because the network blinked is worse than a slightly stale price. The product screen tells a real 404 from a failed request, so only the former renders the not-found page. One bug introduced and caught while doing this: moving the home categories to /facets meant memory.init() no longer loaded the catalogue as a side effect, and the home rails read catalog.products — the home screen would have rendered empty rails. HomeView now loads it explicitly. Found by grepping every caller of configStore.init, which is also how I know HomeView is the only one. Still on the unpaged list by design: the home rails and the search/newest lists. Moving those onto paged reads is step 4, kept separate because it is the risky part — debounce, loading state, and telling "no results" from "still loading". api 334/334 across 30 suites; both apps vue-tsc clean. getOrders has a spec now (3 cases) because its include is a contract both apps depend on and the method had no test at all. Nothing click-tested — the cart re-sync and the favourites screen are the two worth a manual pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cbb93dec5d
commit
9fc0886a57
@@ -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' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user