Happy House - Ecommerce Docs
Developer ResourcesWishlist

Wishlist Backend Documentation

Backend architecture, data model, services, cache, and operational behavior for the Wishlist module.

Wishlist - Backend Documentation

1. Documentation Evidence

AreaFiles InspectedVerified Details
Module wiringapps/api/src/modules/wishlist/ module filesLeaf composition, mobile registration
Controllerscustomer/wishlist-customer.controller.tsRoutes, guards, status codes
Servicescustomer/wishlist-customer.service.ts, wishlist-response.builder.tsON CONFLICT add, reason precedence, sort tiebreakers
DTOscustomer/dto/*.tsSort/filter enums, pagination
Schemapackages/db/src/schema/wishlist/wishlist-item.tsUnique constraint, cascades, hard delete, cap
Cache@happy-shop/redisCACHE_TTL.VOLATILE (30s)
Error registryapps/api/src/common/types/error-codes.ts (// WISHLIST)WISHLIST_* codes

2. Backend Scope and Boundaries

Owns

  • The wishlist_item table 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.getForProducts through ProductCardAssembler — 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 duplication CLAUDE.md forbids.
  • 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

ConcernSource of TruthNotes
What is savedwishlist_item rows (hard-deleted on remove)
Product factsLive product rows — never snapshotted
AvailabilityInventoryAvailabilityService (products' seam)
Membershipwishlist_item projection, 30s cache

3. Module Composition

ModuleTypePathControllersProvidersExportsResponsibility
WishlistCustomerModuleLeafcustomer/WishlistCustomerControllerServiceThe four routes
Mobile compositionmobile.module.tsMounted 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.sql

Key files:

FilePurposeKey ExportsNotes
customer/wishlist-customer.service.tsThe four operationsWishlistCustomerServiceON CONFLICT add, cap, cache
customer/wishlist-response.builder.tsResponse shape + sort builderbuildWishlistOrderByTiebreaker lives here
schema/wishlist/wishlist-item.tsThe tablewishlistItems, WISHLIST_MAX_ITEMSUnique constraint is the idempotency

5. Data Model

5.1 Schema Source

packages/db/src/schema/wishlist/wishlist-item.ts

5.2 Tables

wishlist_item

ColumnTypeNullableIndex/ConstraintRelationNotes
id / public_idserial / uuid v7NoPK / UNIQUE
customer_iduuidNoFK CASCADEcustomers.idErasing a customer erases what they saved
product_idintegerNoFK CASCADE + indexproducts.idHard-delete only; soft-deleted products stay listed with a reason
created_attimestamptzNo(customer_id, created_at desc) indexExposed 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_at would 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

MethodCalled ByReadsWritesSide EffectsErrors
list()GET /wishlistwishlist rows, products, inventory
getProductIds()GET /product-idswishlist rows30s cache
add()PUT /products/:productIdproduct visibility, countwishlist rowcache clearWISHLIST_PRODUCT_NOT_FOUND, WISHLIST_LIMIT_REACHED
remove()DELETE /products/:productIdrow deletedcache clear

Key behaviours:

  • Add is one INSERT … ON CONFLICT DO NOTHING after 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 reads removed, the more final fact and the one the customer can act on. Unlisted counts as available, following the products module's purchasableOf.
  • Remove never 404s — including for a product id that names nothing. Telling "hidden" apart from "nonexistent" would let anyone enumerate unpublished product ids.
  • summary.totalItems is the whole wishlist, not the page and not the filtered set; lastUpdated is null for an empty wishlist. Paginated by default; pagination=false returns everything, bounded at 200.

6.2 WishlistResponseBuilder

MethodCalled ByReadsWritesSide EffectsErrors
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

CacheTTLKeyNotes
Membership id-set (product-ids)CACHE_TTL.VOLATILE = 30sper-customerThe 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/DELETE clears 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.id in the service's WHERE clauses.
  • Rate limits, keyed on the account (keyStrategy: "user"): CUSTOMER_READ 60/min (list, product-ids), CUSTOMER_WRITE 20/min (save, remove).
  • Anti-enumeration: PUT returns WISHLIST_PRODUCT_NOT_FOUND (404) for a hidden product and a nonexistent product alike; DELETE never 404s at all. There is deliberately no WISHLIST_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.