|
| 1 | +--- |
| 2 | +"@urumi/domain": minor |
| 3 | +"@urumi/store-postgres": minor |
| 4 | +"@urumi/service": minor |
| 5 | +"@urumi/plugin": minor |
| 6 | +--- |
| 7 | + |
| 8 | +Product lifecycle surfacing (admin-UX Increment 2, slice 4): make a product's |
| 9 | +active/inactive/deleted state honest and browsable on the admin Products console, |
| 10 | +without adding a new mutating command. |
| 11 | + |
| 12 | +Ownership discovery (decisive for scope): activate/deactivate are ALREADY |
| 13 | +CMS-owned and already wired (`content:afterPublish`/`afterUnpublish` → |
| 14 | +`ProductCommerceStore.activate`/`deactivate`, landed alongside the sync hooks) and |
| 15 | +soft-delete is ALREADY CMS-owned and already wired (`content:afterDelete` → |
| 16 | +`softDeleteProductCommerce`, on both trash and permanent delete). There is no |
| 17 | +undiscovered domain-owned lifecycle command left to build — the gap was purely on |
| 18 | +the READ side, called out verbatim in the existing code: "there is no admin surface |
| 19 | +for browsing/restoring a soft-deleted product yet." This slice closes exactly that |
| 20 | +gap; it adds no new writer of `active`/`deletedAt`. |
| 21 | + |
| 22 | +- **Domain** — `ProductListFilter` gains `deleted?: boolean` (the tombstone axis, |
| 23 | + a strict two-value equality filter mirroring `active`): omitted/`false` is the |
| 24 | + ORIGINAL default (`deleted_at IS NULL`, unchanged for every existing caller); |
| 25 | + `true` is the new archive view (`deleted_at IS NOT NULL`, mutually exclusive |
| 26 | + with the live view — never both on one page). `ProductSummary` gains |
| 27 | + `deletedAt: string | null`, present on every row (null on a live row, set only |
| 28 | + in the archive view) so a consumer never has to guess whether the field exists. |
| 29 | +- **Adapters** — the fake and the Kysely store (sqlite + Postgres) flip the same |
| 30 | + base `deleted_at` predicate the filter now parameterizes, contract-pinned |
| 31 | + (`listProducts filter.deleted:true is the archive view`, `...composes with |
| 32 | + active/productKind/search like every other axis`). |
| 33 | +- **Service** — `GET /admin/products?deleted=true` is the archive-view query |
| 34 | + param; `GET /admin/products/:id` no longer collapses a soft-deleted row into |
| 35 | + the SAME 404 an unknown id gets — it now returns 200 with `deletedAt` set (the |
| 36 | + honest read-only tombstone), while the WRITE routes (`PATCH`, `restock`, |
| 37 | + `remove-stock`) remain 404 for a deleted row via their own pre-existing |
| 38 | + not_found guards — this is visibility only, never a path back to editability. |
| 39 | +- **Plugin** — the Products console's "Status" filter gets a 4th, mutually |
| 40 | + exclusive option, "Archived (deleted)", so a merchant can never combine it with |
| 41 | + Active/Inactive into a filter contradiction. A `deletedAt`-outranks-`active` |
| 42 | + status label ("deleted" over "inactive") is shared by the list table, the "Open |
| 43 | + product" picker, and the detail fields. Opening a soft-deleted product renders |
| 44 | + a read-only tombstone banner (deletion timestamp + a note that existing orders |
| 45 | + are unaffected, since an order snapshots price/title at purchase time) with NO |
| 46 | + edit form and NO stock forms — editing or restocking a deleted product is |
| 47 | + meaningless, and the write routes would 404 it anyway. |
| 48 | + |
| 49 | +Known, deliberately out-of-scope gap this slice surfaces but does not fix: restoring |
| 50 | +a CMS document from the trash does NOT undo a soft delete — `upsert` (the |
| 51 | +`content:afterSave` handler) never touches `deletedAt`/`active` by design, so a |
| 52 | +restored CMS document stays commerce-tombstoned with no self-heal. That is a new |
| 53 | +domain-owned RESTORE command, a separate, larger change (its own idempotency / |
| 54 | +ordering-watermark story), not a read-surfacing slice; flagged here for a follow-up |
| 55 | +decision, not built. |
| 56 | + |
| 57 | +Verification: the full `productCommerceStoreContract` (130 tests, sqlite + Postgres |
| 58 | +dialects), `admin-products-http.test.ts` against a live Postgres-backed server (incl. |
| 59 | +the new archive-filter and tombstone-detail cases, and the write-route |
| 60 | +still-blocked-for-deleted regression), and the plugin's workerd-on-Node sandbox |
| 61 | +(`products-page.sandbox.test.ts`, incl. the archived-filter query and the |
| 62 | +no-edit/no-stock-forms tombstone render) all pass. No new mutating command exists to |
| 63 | +race checkout, so no new Postgres concurrency test was needed; `listCommerceByIds` |
| 64 | +already omits soft-deleted rows (pre-existing, unchanged) so a deleted product was |
| 65 | +already unpurchasable before this change. |
0 commit comments