Skip to Content

← All archived runs

Run: product-services-admin

run.md

Run: product-services-admin

  • branch: claude/pipeline-product-services-admin-vpelfr
  • pr: #594

00_intake/stub.md

Stub: Manage services on a product (admin)

  • feature-slug: product-services-admin
  • epic: catalogue-hierarchy
  • personas: Admin
  • initiative: Build the bridge / objective: okrs/2026-Q2 Objective 2 — Build Repeatable Lead Generation Pipeline (lead-to-vendor match accuracy)
  • depends-on: product-service-links
  • sequence: 3 of 5

Problem

The product↔service links exist in the data layer (stub 1) but an admin has no way to curate them — they cannot say which generic services a given product offers. Without a management surface the tier cannot be populated or maintained.

Proposed change

Add admin UI to attach/detach services on a product: from the product detail/edit view, an admin selects from the shared service catalogue which services this product offers, and sees/removes the current set. Reuse the existing products admin surface (/products/**, /admin/settings/products) and the service-layer link queries from stub 1.

Acceptance criteria (rough)

  • From a product's admin view, an admin can see the services linked to it and add/remove links from the shared service catalogue.
  • Changes persist via the stub-1 link queries and are tenant-scoped; duplicates are prevented.
  • The products table/detail reflects the linked-service count or list.
  • Role-gated to catalogue managers (Admin; CSM/SDM view per the products feature-role-matrix).

Out of scope (this feature)

  • Managing skill↔service links (handled from the skill side in stub 2).
  • Lead capture cascade (stub 4) and demo reseed (stub 5).
  • Creating/editing the service catalogue entries themselves (existing services admin already does that).

Notes for Define

  • This is UI over stub 1's queries — no new schema. Follow CONVENTIONS.md ShadCN/component rules.
  • Manage the M2M from the product side; the service side stays the plain services list.
  • touches: apps/web/app/(app)/products/**, apps/web/components/products/**, apps/web/app/(app)/admin/settings/products/.

01_define/output/spec.md

Spec: Manage services on a product (admin)

  • slug: product-services-admin
  • personas: Admin
  • touches: apps/web/app/(app)/products/[id]/, apps/web/app/(app)/products/actions.ts, apps/web/components/products/, packages/services/src/db/services/product-service-link/ (consumed, no change)
  • complexity: standard

Problem

Stub 1 of the catalogue-hierarchy epic (product-service-links, merged in #592) added the tenant-scoped product↔service many-to-many link and its service-layer queries (productServiceLinkService.link / .unlink / .getServicesForProduct / .countByProduct), but there is no admin surface to curate them — an admin cannot say which generic services a given product offers. Without a management UI the tier-2 relationship cannot be populated or maintained, so the later lead-capture cascade (stub 4) has nothing to filter on. This advances okrs/2026-Q2 Objective 2 (Build Repeatable Lead Generation Pipeline, KR: lead-to-vendor match accuracy) by making the tiered catalogue (platform → product → service → skill) manageable. This is stub 3 of 5 in the epic (pipeline/intake/catalogue-hierarchy/breakdown.md); it is UI only, reusing stub 1's queries — no new schema.

Proposed change

Add an admin management surface on the product side to attach and detach services on a product, reusing the existing products admin surface (apps/web/app/(app)/products/**) and stub 1's link queries:

  • On a product's admin detail view (/products/[id]), show a "Services offered" section listing the services currently linked to that product (via getServicesForProduct), each with a remove control.
  • Provide an add control that lets the admin pick one or more services from the shared service catalogue (the existing services list) that are not already linked, and attach them.
  • Each add/remove persists immediately as its own tenant-scoped action calling productServiceLinkService.link / .unlink — the M2M is managed independently of the product's own fields (it is not part of the product-form save). Adding an already-linked service is idempotent (no duplicate); removing an unlinked one does not error, matching the stub-1 operations.
  • Surface the linked-service count on the products list (components/products/products-list) so the table reflects how many services each product offers (via countByProduct).
  • Gate writes to Admin (catalogue managers); the section stays consistent with the products feature-role-matrix (CSM/SDM may view where they can already see products, but only Admin can add/remove).

Manage the relationship only from the product side; the service side stays the plain services list. This does not create or edit service-catalogue entries themselves — the existing services admin already does that.

Acceptance criteria

  • From a product's admin detail view, an admin sees the list of services currently linked to that product, tenant-scoped.
  • An admin can add one or more services to a product by selecting from the shared service catalogue; only services not already linked are offered, and the new links appear in the list without a full-page reload.
  • An admin can remove a linked service from a product; the link is deleted and the service disappears from the list.
  • Add is idempotent (linking an already-linked service creates no duplicate) and remove of an unlinked service does not error — the UI delegates to the stub-1 link / unlink operations.
  • All reads and writes are tenant-scoped and go through productServiceLinkService; no product↔service link logic is re-implemented in the app layer.
  • The products list reflects each product's linked-service count.
  • Adding/removing services is available to Admin only; a non-Admin viewing the product does not get add/remove controls, consistent with the products feature-role-matrix.
  • Existing products/services admin behaviour is unaffected; build, lint, and typecheck stay green.

Out of scope

  • Managing skill↔service links (handled from the skill side in stub 2, skill-service-retier).
  • The lead capture cascade / lead form changes (stub 4, lead-capture-cascade).
  • Demo-data reseed (stub 5, catalogue-demo-reseed).
  • Creating, editing, or deleting the service-catalogue entries themselves (the existing services admin already does that).
  • Any change to the product↔service schema or service-layer queries — stub 1 owns those; this stub only consumes them.
  • Managing the relationship from the service side (the service list stays plain).

Open questions

  • none — the surface (product detail view), the persistence model (immediate per add/remove via the stub-1 link/unlink operations), and role-gating (Admin writes) are settled above. Exact component composition (dialog vs inline combobox for the add control) is a Build-time UI decision per CONVENTIONS.md ShadCN rules and does not affect the observable acceptance criteria.

02_build/output/notes.md

Build notes: product-services-admin

  • commits: feat: product-services-admin — manage services on a product (admin)

What changed

  • apps/web/lib/taxonomy-action-roles.ts: added PRODUCT_SERVICE_LINK_ROLES = ["admin"] — managing a product's services is admin-only per the approved spec.
  • apps/web/app/(app)/products/actions.ts: added linkProductService / unlinkProductService server actions. Same explicit Zod → resolveActionContext (admin-gated) → runActionBody pipeline as the existing product actions; both delegate to productServiceLinkService.link / .unlink (idempotent in the service layer) and revalidatePath the products list + the product detail page.
  • apps/web/components/products/product-services-manager/product-services-manager.tsx: new client component. Shows the linked services as removable badges and an "Add services" dialog (a searchable Command list of unlinked services). Each add/remove calls the action and router.refresh()es, so the list updates without a full-page reload. Add/remove controls render only when canManage.
  • apps/web/app/(app)/products/[id]/page.tsx: fetches linked services (getServicesForProduct), the service catalogue (serviceService.findAll) and the viewer role in parallel; computes the unlinked, active services for the picker; renders <ProductServicesManager> gated to role === "admin".
  • apps/web/components/products/products-list/products-list.tsx: added a "Services" column showing each product's linked-service count via countByProduct (batched with Promise.all over the page).

Acceptance criteria status

  • From a product's admin detail view, an admin sees the list of linked services, tenant-scoped — getServicesForProduct(tenantId, id) on the detail page.
  • An admin can add one or more services from the shared catalogue; only unlinked (active) services are offered, and new links appear without a full-page reload — the add dialog lists availableServices and router.refresh()es after each link.
  • An admin can remove a linked service — the badge's remove button calls unlinkProductService.
  • Add is idempotent / remove of an unlinked service does not error — the UI delegates to the stub-1 link / unlink operations, which own that behaviour.
  • All reads/writes are tenant-scoped and go through productServiceLinkService — no link logic re-implemented in the app layer.
  • The products list reflects each product's linked-service count — new "Services" column via countByProduct.
  • Add/remove available to Admin only — actions gated by PRODUCT_SERVICE_LINK_ROLES; controls render only when role === "admin" (non-admins still see the read-only list).
  • Existing products/services admin behaviour is unaffected — additive changes only; no existing action or query modified.

Verify result

  • mechanical checks (format · lint · typecheck · build) run in CI + the Vercel preview, not here. No check is expected to fail.

Notes for review

  • Write-gating is admin-only per the approved spec, which is stricter than product create/edit/delete (TAXONOMY_EDITOR_ROLES = admin/csm/sdm). CSM/SDM can still view the linked-service list on the product detail page but get no add/remove controls.
  • The picker offers only isActive services not already linked; linked services always render regardless of active state.
  • countByProduct is issued once per row on the products page (≤ limit rows, batched) — the sanctioned per-product query from stub 1; no batch-count method exists in the service layer.

03_release/output/changelog.md

Choose which services a product offers

You can now manage the services attached to each product, so your catalogue shows exactly what each product delivers.

  • On a product's page, see the services it currently offers and add or remove them from your shared service catalogue.
  • Add several at once from a searchable list — only services that aren't already linked are shown.
  • The products list shows how many services each product offers at a glance.

Personas: admin · Live entry: apps/help/app/changelog/2026-07-06-product-services-admin/page.mdx

03_release/output/investor-update.md

Admins can now curate which services each product offers

Who it's for: Admins (catalogue managers) What shipped: A management view to attach and detach services on a product, drawn from the shared service catalogue. Why it matters: Populating the tiered catalogue is groundwork for the Build Repeatable Lead Generation Pipeline objective — accurate product-to-service links feed lead-to-vendor match accuracy.

Every change is tenant-scoped and role-gated to admins.

Dig deeper: https://github.com/sustentus/sustentus/pull/594 · https://help.sustentus.com/changelog/2026-07-06-product-services-admin

03_release/output/release.md

Release: product-services-admin

  • pr: #594 (https://github.com/sustentus/sustentus/pull/594) · merged: yes — squash 46af5f7 on 2026-07-06
  • CI: green — Quality Project ✓, Migrate preview database ✓, Vercel Preview Comments ✓ (Migrate production database skipped, expected on a PR)
  • technical docs: no technical docs impact — additive UI over the existing /products surface; no new app, package, route, env var, or architecture change
  • business docs: apps/docs/app/business/feature-role-matrix/products/page.mdx — added "Manage services on a product" (Admin) row
  • release notes: both — changelog entry (apps/help/app/changelog/2026-07-06-product-services-admin/page.mdx) + investor draft in this PR
  • deploy: READY — web (web) ✓, help (help-centre) ✓ @ sha 46af5f7
  • sent: investor update sent to 2 recipients on 2026-07-06, after green deploy

Review summary

Ran /code-review medium (standard complexity) over the diff — 2 finder passes (correctness; cleanup/conventions), verified.

  • Stale error after dialog close (product-services-manager.tsx) — a failed "add" left its error rendered in the card after the dialog was dismissed (only cleared on the next link/unlink). Fixed on branch: onOpenChange now clears the error on open/close.
  • serviceService.findAll({ limit: 1000 }) in the add picker (product detail page) — silently caps the offered catalogue at 1000 active services. Accepted: services are a small generic catalogue (onboarding/training/etc., dozens per tenant), so 1000 is effectively "all"; per-tenant service counts are nowhere near the cap. Logged as a known bound — if the catalogue ever grows large, the picker should paginate/typeahead-query server-side.
  • N+1 countByProduct on the products list (products-list.tsx) — one count query per row (≤ page limit, batched with Promise.all). Accepted: the sanctioned stub-1 per-product query; a bulk count would mean re-implementing link-model logic in the app layer, which the spec forbids ("no product↔service link logic re-implemented in the app layer"). Follow-up: a countByProducts(ids) bulk method belongs in productServiceLinkService (service layer), not this PR.
  • Raw <button> for the badge remove control (product-services-manager.tsx) — reuse nit. Accepted: matches existing precedent for compact inline affordances (e.g. option-picker-popover); a full Button is oversized inside a Badge.
  • Dialog/Typography nesting in DialogTitle/DescriptionAccepted: mirrors the sibling product-delete-button.tsx house pattern in apps/web/components/products.
  • Add dialog stays open after linking the last available serviceAccepted: the "No services left to add." empty state is informative confirmation, not a defect.

No correctness bug survived that blocks release.

Acceptance check (vs spec)

  • Admin sees the linked services on the product detail view (tenant-scoped) — getServicesForProduct.
  • Admin can add one or more unlinked services from the shared catalogue; list updates without full reload — add dialog + router.refresh().
  • Admin can remove a linked service — badge remove button → unlinkProductService.
  • Add idempotent / remove of unlinked no-ops — delegated to stub-1 link/unlink.
  • All reads/writes tenant-scoped via productServiceLinkService; no link logic in the app layer.
  • Products list shows each product's linked-service count — new "Services" column.
  • Add/remove admin-only; non-admins see the read-only list — PRODUCT_SERVICE_LINK_ROLES + canManage gate.
  • Existing products/services admin behaviour unaffected — additive only.