Wishlist Backend Documentation
Backend architecture, data model, services, cache, and operational behavior for the Wishlist module.
Wishlist - Backend Documentation
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/wishlist/ module files | Leaf composition, mobile registration |
| Controllers | customer/wishlist-customer.controller.ts | Routes, guards, status codes |
| Services | customer/wishlist-customer.service.ts, wishlist-response.builder.ts | ON CONFLICT add, reason precedence, sort tiebreakers |
| DTOs | customer/dto/*.ts | Sort/filter enums, pagination |
| Schema | packages/db/src/schema/wishlist/wishlist-item.ts | Unique constraint, cascades, hard delete, cap |
| Cache | @happy-shop/redis | CACHE_TTL.VOLATILE (30s) |
| Error registry | apps/api/src/common/types/error-codes.ts (// WISHLIST) | WISHLIST_* codes |
2. Backend Scope and Boundaries
Owns
- The
wishlist_itemtable and the four customer routes. - The "saved while visible, stays saved after" availability semantics with per-item reasons.
- The 30-second membership id-set cache.
Does Not Own
- Stock logic. Wishlist builds none of its own. It consumes
InventoryAvailabilityService.getForProductsthroughProductCardAssembler— the same batched seam cart uses. The module is small because that seam already existed; building a second stock-check helper would have been the duplicationCLAUDE.mdforbids. - Carts, checkout, orders, payments, promotions. It stores a preference and resolves live product data when asked — it reserves no inventory and snapshots no price.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| What is saved | wishlist_item rows (hard-deleted on remove) | |
| Product facts | Live product rows — never snapshotted | |
| Availability | InventoryAvailabilityService (products' seam) | |
| Membership | wishlist_item projection, 30s cache |
3. Module Composition
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
WishlistCustomerModule | Leaf | customer/ | WishlistCustomerController | Service | — | The four routes |
| Mobile composition | — | mobile.module.ts | — | — | — | Mounted under /api/mobile/wishlist |
4. File and Directory Map
apps/api/src/modules/wishlist/
customer/
wishlist-customer.controller.ts
wishlist-customer.service.ts
wishlist-response.builder.ts # shape + buildWishlistOrderBy
dto/
packages/db/src/schema/wishlist/
wishlist-item.ts
packages/db/src/migrations/0007_wishlist.sqlKey files:
| File | Purpose | Key Exports | Notes |
|---|---|---|---|
customer/wishlist-customer.service.ts | The four operations | WishlistCustomerService | ON CONFLICT add, cap, cache |
customer/wishlist-response.builder.ts | Response shape + sort builder | buildWishlistOrderBy | Tiebreaker lives here |
schema/wishlist/wishlist-item.ts | The table | wishlistItems, WISHLIST_MAX_ITEMS | Unique constraint is the idempotency |
5. Data Model
5.1 Schema Source
packages/db/src/schema/wishlist/wishlist-item.ts5.2 Tables
wishlist_item
| Column | Type | Nullable | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|
id / public_id | serial / uuid v7 | No | PK / UNIQUE | — | |
customer_id | uuid | No | FK CASCADE | customers.id | Erasing a customer erases what they saved |
product_id | integer | No | FK CASCADE + index | products.id | Hard-delete only; soft-deleted products stay listed with a reason |
created_at | timestamptz | No | (customer_id, created_at desc) index | — | Exposed as savedAt; no updated_at — an entry is created and deleted, never edited |
| — | — | — | UNIQUE (customer_id, product_id) | — | The idempotency guarantee |
Design decisions:
- The unique constraint IS the idempotency guarantee. Save is one
INSERT … ON CONFLICT DO NOTHING; two concurrent taps cannot produce two rows, and there is no service-level duplicate check to forget. - No soft delete, deliberately. A
deleted_atwould force the unique constraint to become partial, and re-adding a removed product would then need a read-modify-write with a race in the middle. Hard delete keeps the guarantee in the database. - Product FK CASCADE fires only on hard delete. Products are soft-deleted in normal operation and the module keeps showing them with a reason — the customer must learn why they cannot buy a saved item.
WISHLIST_MAX_ITEMS = 200— an abuse bound, not a business requirement. Far above what a real shopper accumulates, far below what makes the list read expensive (every item resolves live product data).
5.3 Relationship Diagram
6. Services and Responsibilities
6.1 WishlistCustomerService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
list() | GET /wishlist | wishlist rows, products, inventory | — | — | — |
getProductIds() | GET /product-ids | wishlist rows | — | 30s cache | — |
add() | PUT /products/:productId | product visibility, count | wishlist row | cache clear | WISHLIST_PRODUCT_NOT_FOUND, WISHLIST_LIMIT_REACHED |
remove() | DELETE /products/:productId | — | row deleted | cache clear | — |
Key behaviours:
- Add is one
INSERT … ON CONFLICT DO NOTHINGafter a saveability check (product must be currently published or unlisted). Only a product that is currently published or unlisted can be saved; one already saved stays saved after it stops being either. Re-saving at the 200 cap succeeds — the call adds nothing, so rejecting it would fail a no-op. - Reason precedence (bottom-up):
removed>archived>draft>out_of_stock>null. A product both archived and deleted readsremoved, the more final fact and the one the customer can act on. Unlisted counts as available, following the products module'spurchasableOf. - Remove never 404s — including for a product id that names nothing. Telling "hidden" apart from "nonexistent" would let anyone enumerate unpublished product ids.
summary.totalItemsis the whole wishlist, not the page and not the filtered set;lastUpdatedis null for an empty wishlist. Paginated by default;pagination=falsereturns everything, bounded at 200.
6.2 WishlistResponseBuilder
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
buildWishlistOrderBy() | list | — | — | — | — |
Every sort carries a unique tiebreaker ((sortKey, id)-style). Without one, offset paging over a low-cardinality sort (e.g. availability) silently duplicates and omits items while the total insists nothing is missing. It looks removable — it is not. The availability sort is a SQL sort on the product's stored stock status, not a post-query shuffle.
7. Runtime Flows
7.1 Save
7.2 List with live data
8. Cache
| Cache | TTL | Key | Notes |
|---|---|---|---|
Membership id-set (product-ids) | CACHE_TTL.VOLATILE = 30s | per-customer | The only cached read |
- The list is never cached — caching an assembled list would contradict the rule that a wishlist always shows live product data.
- The TTL is 30s, not 300s, because of a fill race: a miss fills the cache from the database, and a long TTL would keep serving a stale membership set after a write elsewhere. 30s bounds the staleness while keeping the miss rate trivial. A
PUT/DELETEclears the key immediately, so the customer's own writes are reflected at once.
9. Jobs and Workers
None. The wishlist is synchronous — four reads/writes, no queue involvement, no outbox use.
10. Security and Authorization
- All routes:
JwtAuthGuard— no permission, no@Public(), no admin surface. - Every query is scoped to
req.user.idin the service's WHERE clauses. - Rate limits, keyed on the account (
keyStrategy: "user"):CUSTOMER_READ60/min (list, product-ids),CUSTOMER_WRITE20/min (save, remove). - Anti-enumeration:
PUTreturnsWISHLIST_PRODUCT_NOT_FOUND(404) for a hidden product and a nonexistent product alike;DELETEnever 404s at all. There is deliberately noWISHLIST_ITEM_NOT_FOUND.
11. Contract Note — ProductCustomerStatusDto.lifecycle
As part of this work, ProductCustomerStatusDto.lifecycle was widened from "published" | "unlisted" to the full "draft" | "published" | "unlisted" | "archived" union. The reason is wishlist: it is the first storefront surface that can legitimately show an archived product, and the narrow union made that unrepresentable.
This is a runtime no-op for every existing surface — all product discovery routes filter to published and still emit only published. What changed is what the contract admits: a generated client with an exhaustive switch over status.lifecycle must now handle draft and archived to compile.