product-services-adminrun.md00_intake/stub.mdThe 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.
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.
CONVENTIONS.md ShadCN/component rules.apps/web/app/(app)/products/**, apps/web/components/products/**,
apps/web/app/(app)/admin/settings/products/.01_define/output/spec.mdStub 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.
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:
/products/[id]), show a "Services offered" section listing the services currently linked to that product (via getServicesForProduct), each with a remove control.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.components/products/products-list) so the table reflects how many services each product offers (via countByProduct).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.
link / unlink operations.productServiceLinkService; no product↔service link logic is re-implemented in the app layer.skill-service-retier).lead-capture-cascade).catalogue-demo-reseed).CONVENTIONS.md ShadCN rules and does not affect the observable acceptance criteria.02_build/output/notes.mdapps/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).getServicesForProduct(tenantId, id) on the detail page.availableServices and router.refresh()es after each link.unlinkProductService.link / unlink operations, which own that behaviour.productServiceLinkService — no link logic re-implemented in the app layer.countByProduct.PRODUCT_SERVICE_LINK_ROLES; controls render only when role === "admin" (non-admins still see the read-only list).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.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.mdYou can now manage the services attached to each product, so your catalogue shows exactly what each product delivers.
Personas: admin · Live entry: apps/help/app/changelog/2026-07-06-product-services-admin/page.mdx
03_release/output/investor-update.mdWho 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/products surface; no new app, package, route, env var, or architecture changeapps/docs/app/business/feature-role-matrix/products/page.mdx — added "Manage services on a product" (Admin) rowapps/help/app/changelog/2026-07-06-product-services-admin/page.mdx) + investor draft in this PRRan /code-review medium (standard complexity) over the diff — 2 finder passes (correctness; cleanup/conventions), verified.
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.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.<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.product-delete-button.tsx house pattern in apps/web/components/products.No correctness bug survived that blocks release.
getServicesForProduct.router.refresh().unlinkProductService.link/unlink.productServiceLinkService; no link logic in the app layer.PRODUCT_SERVICE_LINK_ROLES + canManage gate.