Skip to content

Commit d04097e

Browse files
authored
Merge pull request #69 from UrumiAI/feat/product-lifecycle-surfacing
[Domain] Product lifecycle surfacing
2 parents 18c7fb4 + a163e03 commit d04097e

11 files changed

Lines changed: 412 additions & 31 deletions
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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.

packages/domain/src/ports/product-commerce-store.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,23 @@ export interface ProductListFilter {
3232
* `sku` (see the filter doc for why the two axes diverge). A row with a
3333
* null `title`/`sku` simply cannot match that half — never a throw. */
3434
search?: string;
35+
/**
36+
* The tombstone axis (admin-UX Increment 2, "product lifecycle surfacing"
37+
* — the archive-view follow-up to the always-excludes-deleted default).
38+
* Deliberately NOT an OR-able array like `OrderListFilter.states`: a row is
39+
* either live or soft-deleted, a strict two-value axis, so a single
40+
* equality filter is the honest, minimal mirror of `active`.
41+
* - omitted or `false` ⇒ the ORIGINAL default: only LIVE rows list
42+
* (`deleted_at IS NULL`) — unchanged behavior, so every existing caller
43+
* that never set this field keeps seeing exactly what it saw before.
44+
* - `true` ⇒ the archive view: ONLY soft-deleted rows list
45+
* (`deleted_at IS NOT NULL`). There is no "both" value — mixing live and
46+
* tombstoned rows into one page would force every consumer to re-derive
47+
* "is this archived" from `deletedAt` instead of the deliberate,
48+
* filterable either/or a merchant actually wants (browse the catalog,
49+
* or browse the archive — never both at once).
50+
*/
51+
deleted?: boolean;
3552
}
3653

3754
/** A keyset cursor POSITION — the `(createdAt, productId)` of the last row of
@@ -68,6 +85,17 @@ export interface ProductSummary {
6885
price: Money | null;
6986
productKind: ProductKind;
7087
active: boolean;
88+
/**
89+
* The soft-delete tombstone timestamp (admin-UX Increment 2, "product
90+
* lifecycle surfacing"), ISO-8601 text like `createdAt` (never a `Date` —
91+
* this is a wire-adjacent projection, not the full `ProductCommerce` row).
92+
* Null for every LIVE row; non-null ONLY when `ProductListFilter.deleted:
93+
* true` requested the archive view (the default list never returns a
94+
* tombstoned row, so this field is null on every row of a default page —
95+
* present unconditionally, not "sometimes on the wire", so a consumer never
96+
* has to guess whether the field exists before reading it).
97+
*/
98+
deletedAt: string | null;
7199
createdAt: string;
72100
}
73101

@@ -432,10 +460,14 @@ export interface ProductCommerceStore {
432460
* `ProductSummary` PROJECTIONS (never the full `ProductCommerce`, and never
433461
* joined with `inventory` — the list must not N+1 into stock per row; a
434462
* per-row stock signal is deferred to the detail leaf's single-sku
435-
* `InventoryStore.getOnHand` read). Always excludes soft-deleted rows
436-
* (`deleted_at IS NULL`) — mirrors `listCommerceByIds`'s tombstone
437-
* discipline; there is no admin surface for browsing/restoring a
438-
* soft-deleted product yet.
463+
* `InventoryStore.getOnHand` read). Excludes soft-deleted rows
464+
* (`deleted_at IS NULL`) by DEFAULT — mirrors `listCommerceByIds`'s
465+
* tombstone discipline — UNLESS `filter.deleted: true` requests the archive
466+
* view (`deleted_at IS NOT NULL` instead), the "product lifecycle
467+
* surfacing" slice's one new axis: browse what was soft-deleted (there is
468+
* still no RESTORE — a soft delete's `active`/`deletedAt` are flipped only
469+
* by the CMS-sync/lifecycle paths, `softDelete`/`activate`/`deactivate`;
470+
* this list is read-only visibility, not a new mutation).
439471
*
440472
* Ordered `created_at DESC, product_id DESC` (newest-first, `product_id`
441473
* the stable tie-break — the primary key, exactly like `OrderSummary.id`).

packages/domain/src/testing/in-memory-product-commerce-store.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -370,9 +370,11 @@ export class InMemoryProductCommerceStore implements ProductCommerceStore {
370370

371371
/** The ONE `ProductListFilter` predicate (mirrors `OrderStore`'s
372372
* `#matchesFilter` / the Kysely adapter's shared predicate builder) —
373-
* always excludes soft-deleted rows first. */
373+
* excludes soft-deleted rows UNLESS `filter.deleted: true` requests the
374+
* archive view (product lifecycle surfacing — see the port doc). */
374375
#matchesFilter(row: ProductCommerce, filter: ProductListFilter): boolean {
375-
if (row.deletedAt !== null) return false; // never list a tombstone.
376+
const wantDeleted = filter.deleted === true;
377+
if (wantDeleted !== (row.deletedAt !== null)) return false;
376378
if (filter.active !== undefined && row.active !== filter.active) return false;
377379
if (filter.productKind !== undefined && row.productKind !== filter.productKind) return false;
378380
if (filter.search !== undefined) {
@@ -427,6 +429,7 @@ export class InMemoryProductCommerceStore implements ProductCommerceStore {
427429
price: row.price,
428430
productKind: row.productKind,
429431
active: row.active,
432+
deletedAt: row.deletedAt === null ? null : row.deletedAt.toISOString(),
430433
createdAt: row.createdAt.toISOString(),
431434
};
432435
}

packages/domain/src/testing/product-commerce-store-contract.ts

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,7 @@ export function productCommerceStoreContract(
11021102
expect(p.price).toEqual({ amount: 4200, currency: "EUR" });
11031103
expect(p.productKind).toBe("digital");
11041104
expect(p.active).toBe(true);
1105+
expect(p.deletedAt).toBeNull();
11051106
expect(p.createdAt).toBe("2026-07-10T01:00:00.000Z");
11061107
});
11071108

@@ -1119,7 +1120,7 @@ export function productCommerceStoreContract(
11191120
expect(products[0]).toMatchObject({ sku: null, title: null, price: null });
11201121
});
11211122

1122-
test("listProducts always excludes soft-deleted rows (no toggle exists yet)", async () => {
1123+
test("listProducts excludes soft-deleted rows by DEFAULT (filter.deleted omitted or false)", async () => {
11231124
const h = await makeStore();
11241125
await h.seedProduct(productRow({ id: "prod-live", createdAt: "2026-07-10T01:00:00.000Z" }));
11251126
await h.seedProduct(
@@ -1129,8 +1130,52 @@ export function productCommerceStoreContract(
11291130
deletedAt: "2026-07-10T03:00:00.000Z",
11301131
}),
11311132
);
1132-
const { products } = await h.store.listProducts({}, { limit: 25 });
1133-
expect(products.map((p) => p.productId)).toEqual(["prod-live"]);
1133+
const omitted = await h.store.listProducts({}, { limit: 25 });
1134+
expect(omitted.products.map((p) => p.productId)).toEqual(["prod-live"]);
1135+
const explicitFalse = await h.store.listProducts({ deleted: false }, { limit: 25 });
1136+
expect(explicitFalse.products.map((p) => p.productId)).toEqual(["prod-live"]);
1137+
});
1138+
1139+
test("listProducts filter.deleted:true is the archive view — ONLY soft-deleted rows list, projecting deletedAt", async () => {
1140+
const h = await makeStore();
1141+
await h.seedProduct(productRow({ id: "prod-live", createdAt: "2026-07-10T01:00:00.000Z" }));
1142+
await h.seedProduct(
1143+
productRow({
1144+
id: "prod-deleted",
1145+
createdAt: "2026-07-10T02:00:00.000Z",
1146+
deletedAt: "2026-07-10T03:00:00.000Z",
1147+
}),
1148+
);
1149+
const { products } = await h.store.listProducts({ deleted: true }, { limit: 25 });
1150+
expect(products.map((p) => p.productId)).toEqual(["prod-deleted"]);
1151+
expect(products[0]?.deletedAt).toBe("2026-07-10T03:00:00.000Z");
1152+
});
1153+
1154+
test("listProducts filter.deleted:true composes with active/productKind/search like every other axis", async () => {
1155+
const h = await makeStore();
1156+
await h.seedProduct(
1157+
productRow({
1158+
id: "deleted-digital",
1159+
productKind: "digital",
1160+
title: "Findable Deleted Ebook",
1161+
createdAt: "2026-07-10T01:00:00.000Z",
1162+
deletedAt: "2026-07-10T02:00:00.000Z",
1163+
}),
1164+
);
1165+
await h.seedProduct(
1166+
productRow({
1167+
id: "deleted-physical",
1168+
productKind: "physical",
1169+
title: "Findable Deleted Mug",
1170+
createdAt: "2026-07-10T01:30:00.000Z",
1171+
deletedAt: "2026-07-10T02:30:00.000Z",
1172+
}),
1173+
);
1174+
const { products } = await h.store.listProducts(
1175+
{ deleted: true, productKind: "digital", search: "findable" },
1176+
{ limit: 25 },
1177+
);
1178+
expect(products.map((p) => p.productId)).toEqual(["deleted-digital"]);
11341179
});
11351180

11361181
test("listProducts filters by active", async () => {

packages/plugin/src/admin/admin-products-client.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export interface ProductSummaryWire {
2626
currency: string | null;
2727
productKind: string;
2828
active: boolean;
29+
/** Soft-delete tombstone (product lifecycle surfacing). Null on every row
30+
* of a default (live) page; non-null only in the archive view
31+
* (`ProductsListFilter.deleted: true`). */
32+
deletedAt: string | null;
2933
createdAt: string;
3034
}
3135

@@ -44,16 +48,25 @@ export interface ProductDetailWire {
4448
heightMm: number | null;
4549
productKind: string;
4650
active: boolean;
51+
/** Soft-delete tombstone (product lifecycle surfacing). Non-null ⇒ this IS
52+
* the read-only archive view — the detail leaf renders it instead of the
53+
* edit/stock forms (see `products-page.ts`'s `detailBlocks`). A 404 (never
54+
* existed) is still `getProduct` returning `null`; a deleted row is a 200
55+
* with this field set. */
56+
deletedAt: string | null;
4757
onHand: number;
4858
createdAt: string;
4959
updatedAt: string;
5060
}
5161

5262
/** The list filter the console builds from its filter form. `active` is a
5363
* tri-state string ("" ⇒ both) so the wire query mirrors the service's
54-
* `active=true|false` param exactly. */
64+
* `active=true|false` param exactly. `deleted` is the archive-view toggle
65+
* (product lifecycle surfacing): omitted/false ⇒ the original default (live
66+
* rows only); true ⇒ ONLY soft-deleted rows. */
5567
export interface ProductsListFilter {
5668
active?: boolean;
69+
deleted?: boolean;
5770
productKind?: string;
5871
search?: string;
5972
}
@@ -275,6 +288,7 @@ export class AdminProductsClient {
275288
q.set("cursor", opts.cursor);
276289
} else {
277290
if (filter.active !== undefined) q.set("active", filter.active ? "true" : "false");
291+
if (filter.deleted !== undefined) q.set("deleted", filter.deleted ? "true" : "false");
278292
if (filter.productKind !== undefined && filter.productKind.length > 0) {
279293
q.set("productKind", filter.productKind);
280294
}

0 commit comments

Comments
 (0)