Add a Pending status so items are staged, not published the moment they are created #90

Closed
opened 2026-08-21 11:33:01 -05:00 by bermudalamb · 1 comment
Owner

Today an item is live on the storefront the instant it is created. There is no way to add something, look at it, fix the photos, and then decide it is ready. A Pending status should hold an item back until an admin explicitly Publishes it.

Decisions

Question Decision
What status do new items get Pending. Nothing publishes itself.
Is publishing reversible Yes, but only from Available.
Unpublishing a Reserved or Sold item Refused with a clear message

Reversible only from Available because the other two are not drafts: a Reserved item is being held in a customer's cart right now, and hiding it would strand them; a Sold item is history, and un-publishing it would quietly rewrite what happened.

New items default to Pending by a migration changing the column default. Existing rows keep whatever status they already have — the migration does not backfill.

Three ways a Pending item leaks onto the storefront, none of them obvious

This is the part worth reading, because the storefront's item routes apply no status filter at all today. Sold items are listed and rendered with a Sold badge, which is deliberate. So Pending cannot be expressed as "one more status filter" — every public read needs an explicit exclusion it does not currently have:

  1. GET /api/items — lists everything. Needs i.status <> 'pending' unconditionally, not through the optional status filter, or a Pending item appears in the catalogue.
  2. GET /api/items/:id — no filter either, so a Pending item stays fetchable by direct id even once it is hidden from the list.
  3. GET /api/filters — aggregates over every row in items. A Pending item inflates its tags' item_count and stretches the price slider's MIN/MAX. A customer would see a tag reading "Rare (1)", filter by it, and be told nothing matches. That is precisely the plausible-looking lie this codebase keeps designing against.

There is a fourth, subtler one. parseItemFilters is shared by the storefront and admin routes, so adding pending to ITEM_STATUSES makes ?status=pending parse on the public route too. With the exclusion above it would return an empty list — which looks like an answer rather than a refusal. The public route rejects it explicitly with a 400, the way the admin route already rejects favorites.

Transitions

Publish reuses the existing POST /items/:id/mark-available. It already sets status='available' and clears sold_at, reserved_until and paypal_order_id, all of which are no-ops on a Pending item. A second endpoint doing the same UPDATE would be duplication; the admin UI simply labels the button "Publish" when the item is Pending.

Unpublish is new, and guarded: it moves Available to Pending and refuses anything else with a message naming why.

Admin UI

A grey Pending tag in the Status column, Publish or Unpublish in the Actions column according to state, and Pending added to the inventory status filter so staged items can be found.

Verification

Integration tests for each leak path, because each is a separate query and fixing one proves nothing about the others: a Pending item absent from GET /api/items, a 404 from GET /api/items/:id, absent from the tag counts and the price bounds in GET /api/filters, and ?status=pending rejected on the public route while still accepted on the admin one. Plus both unpublish refusals, and that a newly created item arrives Pending.

End to end: publish an item and watch it appear on the storefront.

Severity

Medium. No defect today, but the absence of this is why anything added to the catalogue is immediately public whether or not it is finished.

Sibling of the preview-panel issue — previewing before publishing is the natural pairing — but independent and separately shippable.

Today an item is live on the storefront the instant it is created. There is no way to add something, look at it, fix the photos, and then decide it is ready. A **Pending** status should hold an item back until an admin explicitly **Publishes** it. ## Decisions | Question | Decision | | --- | --- | | What status do new items get | **Pending.** Nothing publishes itself. | | Is publishing reversible | **Yes, but only from Available.** | | Unpublishing a Reserved or Sold item | Refused with a clear message | Reversible only from Available because the other two are not drafts: a **Reserved** item is being held in a customer's cart right now, and hiding it would strand them; a **Sold** item is history, and un-publishing it would quietly rewrite what happened. New items default to Pending by a migration changing the column default. Existing rows keep whatever status they already have — the migration does not backfill. ## Three ways a Pending item leaks onto the storefront, none of them obvious This is the part worth reading, because the storefront's item routes apply **no status filter at all** today. Sold items are listed and rendered with a Sold badge, which is deliberate. So Pending cannot be expressed as "one more status filter" — every public read needs an explicit exclusion it does not currently have: 1. **`GET /api/items`** — lists everything. Needs `i.status <> 'pending'` unconditionally, not through the optional `status` filter, or a Pending item appears in the catalogue. 2. **`GET /api/items/:id`** — no filter either, so a Pending item stays fetchable by direct id even once it is hidden from the list. 3. **`GET /api/filters`** — aggregates over every row in `items`. A Pending item inflates its tags' `item_count` and stretches the price slider's `MIN`/`MAX`. A customer would see a tag reading "Rare (1)", filter by it, and be told nothing matches. That is precisely the plausible-looking lie this codebase keeps designing against. There is a fourth, subtler one. `parseItemFilters` is **shared** by the storefront and admin routes, so adding `pending` to `ITEM_STATUSES` makes `?status=pending` parse on the public route too. With the exclusion above it would return an empty list — which looks like an answer rather than a refusal. The public route rejects it explicitly with a 400, the way the admin route already rejects `favorites`. ## Transitions **Publish reuses the existing `POST /items/:id/mark-available`.** It already sets `status='available'` and clears `sold_at`, `reserved_until` and `paypal_order_id`, all of which are no-ops on a Pending item. A second endpoint doing the same UPDATE would be duplication; the admin UI simply labels the button "Publish" when the item is Pending. **Unpublish is new**, and guarded: it moves Available to Pending and refuses anything else with a message naming why. ## Admin UI A grey Pending tag in the Status column, Publish or Unpublish in the Actions column according to state, and Pending added to the inventory status filter so staged items can be found. ## Verification Integration tests for each leak path, because each is a separate query and fixing one proves nothing about the others: a Pending item absent from `GET /api/items`, a 404 from `GET /api/items/:id`, absent from the tag counts and the price bounds in `GET /api/filters`, and `?status=pending` rejected on the public route while still accepted on the admin one. Plus both unpublish refusals, and that a newly created item arrives Pending. End to end: publish an item and watch it appear on the storefront. ## Severity Medium. No defect today, but the absence of this is why anything added to the catalogue is immediately public whether or not it is finished. Sibling of the preview-panel issue — previewing before publishing is the natural pairing — but independent and separately shippable.
bermudalamb self-assigned this 2026-08-21 11:42:53 -05:00
bermudalamb added this to the Code Quality and Hardening project 2026-08-21 11:42:57 -05:00
bermudalamb moved this to In Progress in Code Quality and Hardening on 2026-08-21 12:13:17 -05:00
Author
Owner

Implemented on feature/90-pending-status — one commit, not pushed

Branched on top of #89 rather than main, because the two meet: a pending item is previewed as it will look once published. A customer never sees the pending state, so rendering it would answer a question nobody is asking. That mapping lives at the preview call site, and #89 has since merged, so the history is clean.

The four leak paths, all closed

The part worth recording is that this needed four separate changes, not one. The storefront's item routes had no status filter at all — sold items are listed with a Sold badge on purpose — so pending could not be expressed as one more optional filter.

Route What was needed
GET /api/items An exclusion the caller cannot opt out of
GET /api/items/:id The same — hiding it from the list while serving it by id leaves it reachable to anyone with an old link
GET /api/filters Excluded from both aggregates: tag counts and price bounds
?status=pending Refused with a 400 on the public route, still valid on the admin route

The filters one is the least obvious and the most user-visible: a pending item would have inflated its tags' counts, so a customer would read "Rare (1)", filter by it, and be told nothing matches — and its price would have stretched the slider to a range no visible item occupies.

A bug the tests caught

My first version of the tag count excluded pending items with a WHERE. That drops the row entirely for a tag whose only item is pending, so the tag would have vanished from the filter drawer instead of showing zero. Counting over the joined items fixes it, and there is now a test named for exactly that case.

Changing a column default has reach

Eight test fixtures silently depended on new items being available. Each is now explicit about the status it wants rather than inheriting one — better practice regardless, and immune to the next default change.

Two tests also used 'pending' as their example of an unknown status, one unit and one integration. Both would have quietly become tautologies — still passing, no longer testing anything. They now use a status that is genuinely unknown, with a comment saying why the value changed.

Transitions

Publish is the existing mark-available: same transition, same UPDATE, so the UI labels it "Publish" when the item is pending rather than adding a second endpoint doing the same thing. Unpublish is new and deliberately not symmetrical — refused for a reserved item, which someone is holding in their cart right now, and for a sold one, which is a record rather than a draft. Both refusals name their reason, and the buttons are hidden in those states so a refusal is not how you discover the rule.

Verification

98 unit, 160 integration (16 new, one per leak path and one per refusal), 94 end-to-end (3 new), the last on a freshly created container. Lint unchanged at 0 errors in both workspaces.

One earlier full run showed a single failure in favorites.spec.ts. It passes in isolation and on a clean container, and is the cross-spec interference already recorded against this suite — not from this change. That is now the third time it has cost time; it deserves its own issue.

Not pushed, per the usual arrangement.

## Implemented on `feature/90-pending-status` — one commit, not pushed Branched on top of #89 rather than `main`, because the two meet: a pending item is previewed as it will look **once published**. A customer never sees the pending state, so rendering it would answer a question nobody is asking. That mapping lives at the preview call site, and #89 has since merged, so the history is clean. ## The four leak paths, all closed The part worth recording is that this needed four separate changes, not one. The storefront's item routes had **no status filter at all** — sold items are listed with a Sold badge on purpose — so pending could not be expressed as one more optional filter. | Route | What was needed | | --- | --- | | `GET /api/items` | An exclusion the caller cannot opt out of | | `GET /api/items/:id` | The same — hiding it from the list while serving it by id leaves it reachable to anyone with an old link | | `GET /api/filters` | Excluded from **both** aggregates: tag counts and price bounds | | `?status=pending` | Refused with a 400 on the public route, still valid on the admin route | The filters one is the least obvious and the most user-visible: a pending item would have inflated its tags' counts, so a customer would read "Rare (1)", filter by it, and be told nothing matches — and its price would have stretched the slider to a range no visible item occupies. ## A bug the tests caught My first version of the tag count excluded pending items with a `WHERE`. That drops the row entirely for a tag whose **only** item is pending, so the tag would have vanished from the filter drawer instead of showing zero. Counting over the joined items fixes it, and there is now a test named for exactly that case. ## Changing a column default has reach Eight test fixtures silently depended on new items being available. Each is now explicit about the status it wants rather than inheriting one — better practice regardless, and immune to the next default change. Two tests also used `'pending'` as their example of an *unknown* status, one unit and one integration. Both would have quietly become tautologies — still passing, no longer testing anything. They now use a status that is genuinely unknown, with a comment saying why the value changed. ## Transitions Publish is the existing `mark-available`: same transition, same UPDATE, so the UI labels it "Publish" when the item is pending rather than adding a second endpoint doing the same thing. Unpublish is new and deliberately not symmetrical — refused for a **reserved** item, which someone is holding in their cart right now, and for a **sold** one, which is a record rather than a draft. Both refusals name their reason, and the buttons are hidden in those states so a refusal is not how you discover the rule. ## Verification **98 unit**, **160 integration** (16 new, one per leak path and one per refusal), **94 end-to-end** (3 new), the last on a freshly created container. Lint unchanged at 0 errors in both workspaces. One earlier full run showed a single failure in `favorites.spec.ts`. It passes in isolation and on a clean container, and is the cross-spec interference already recorded against this suite — not from this change. That is now the third time it has cost time; it deserves its own issue. Not pushed, per the usual arrangement.
bermudalamb added reference feature/90-pending-status 2026-08-21 12:46:13 -05:00
bermudalamb moved this to Review in Code Quality and Hardening on 2026-08-21 12:47:05 -05:00
bermudalamb moved this to Ready for Release in Code Quality and Hardening on 2026-08-21 13:54:45 -05:00
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bermudalamb/redefined-designs#90