Skip to Content

← All archived runs

Run: product-service-links

run.md

Run: product-service-links

  • branch: claude/pipeline-product-service-links-rpd0uc
  • pr: #592

00_intake/stub.md

Stub: Product ↔ service links (tier-2 relationship)

  • feature-slug: product-service-links
  • 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: none
  • sequence: 1 of 5

Problem

Products and services are separate flat taxonomies with no relationship — a lead's product and service are chosen independently, so nothing knows which services belong to a product. The tiered catalogue needs services to derive from products before any consumer (admin, lead cascade) can filter by that link.

Proposed change

Introduce a many-to-many relationship between product and service (services are generic and shared across many products — e.g. "onboarding" attaches to many products). Expose service-layer queries in both directions: "services available for product X" and "products a service belongs to". Purely additive — existing flat product/service fields and every current consumer keep working unchanged; this only adds the link and the queries that later stubs read.

Acceptance criteria (rough)

  • A product can be associated with many services and a service with many products (M2M), tenant-scoped.
  • productService (or serviceService) can list the services linked to a given product, and the products linked to a given service.
  • Adding/removing a link is idempotent and cannot create duplicate pairs.
  • No change to existing lead / product / service behaviour; the build is green and existing queries are unaffected.

Out of scope (this feature)

  • Any admin UI to manage the links (that is product-services-admin, stub 3).
  • The skill↔service link and skill-category removal (stub 2).
  • Lead capture cascade (stub 4) and demo reseed (stub 5).

Notes for Define

  • Keep platform → product untouched — this sits beneath product.
  • M2M storage shape (array on one side vs a join collection) is a Build decision; whichever is chosen must stay tenant-scoped and index the reverse lookup cleanly. Follow the mongodb-schema-design skill; watch the shared taxonomy plugins (schemaPlugin/softDeletePlugin/tenantPlugin).
  • touches: packages/services/src/db/models/{product,service}.ts, packages/services/src/db/services/{product,service}/.

01_define/output/spec.md

Spec: Product ↔ service links (tier-2 relationship)

  • slug: product-service-links
  • personas: Admin
  • touches: packages/services/src/db/models/{product,service}.ts, packages/services/src/db/services/{product,service}/
  • complexity: standard

Problem

product and service are separate flat taxonomies (packages/services/src/db/models/{product,service}.ts) with no relationship between them — a lead's product and service are chosen independently today, so nothing knows which services belong to a product. Objective 2 of the okrs/2026-Q2 plan (Build Repeatable Lead Generation Pipeline, KR: lead-to-vendor match accuracy) depends on a tiered catalogue (platform → product → service → skill) so the lead capture cascade can filter services by the chosen product. This is stub 1 of 5 in the catalogue-hierarchy epic (pipeline/intake/catalogue-hierarchy/breakdown.md) and the foundation the later admin-UI and lead-cascade stubs build on.

Proposed change

Introduce a tenant-scoped, many-to-many relationship between product and service (services are generic and shared across many products — e.g. "onboarding" attaches to many products), plus service-layer read/write operations to manage and query it:

  • Link/unlink a (product, service) pair for a tenant.
  • List the services linked to a given product.
  • List the products linked to a given service.

This is purely additive: the existing flat product/service fields on lead and expert, and every current consumer of ProductService/ServiceService, keep working unchanged. No admin UI, no lead form changes — this stub only adds the link and the queries later stubs (product-services-admin, lead-capture-cascade) will read.

Acceptance criteria

  • A product can be linked to many services and a service to many products, scoped per tenant (M2M).
  • Linking the same (tenantId, product, service) pair twice does not create a duplicate — the operation is idempotent.
  • Unlinking a pair that isn't linked does not error.
  • A service-layer query returns the list of services linked to a given product (tenant-scoped).
  • A service-layer query returns the list of products linked to a given service (tenant-scoped).
  • Deleting/archiving a product or service does not silently orphan links: attempting to archive a product or service that still has active links raises the same kind of reference-guard error ProductService/ServiceService already raise for lead/expert references (consistent with guardReferences in packages/services/src/db/services/product/index.ts).
  • Existing lead/product/service/expert behaviour and existing queries are unaffected — the build, lint, and typecheck stay green with no changes required to unrelated call sites.
  • platform → product is untouched — this link sits beneath product, not above it.

Out of scope

  • Any admin UI to manage the links (that's product-services-admin, stub 3 of the epic).
  • The skill↔service link and skillCategory retirement (stub 2, skill-service-retier).
  • Lead capture cascade / lead form changes (stub 4, lead-capture-cascade).
  • Demo-data reseed (stub 5, catalogue-demo-reseed).
  • Migrating or backfilling existing tenant catalogue data, or reconciling historical leads.

Open questions

  • none — storage shape (embedded array vs. a join collection) is a Build-time schema-design decision per the mongodb-schema-design skill; either satisfies the acceptance criteria above as long as it's tenant-scoped and indexes the reverse lookup.

02_build/output/notes.md

Build notes: product-service-links

  • commits: feat: product-service-links — add product↔service M2M link + queries

What changed

  • packages/services/src/db/models/product-service-link.ts: new thin join model — {tenantId, product, service}, unique per pair, reverse index on {tenantId, service}. No softDeletePlugin (a link either exists or doesn't; unlink is a hard delete, which is what keeps unlink-a-non-existent-pair a no-op).
  • packages/services/src/db/services/product-service-link/{index,instance}.ts: ProductServiceLinkServicelink/unlink (both idempotent), getServicesForProduct, getProductsForService, countByProduct, countByService.
  • packages/services/src/db/services/product/index.ts: ProductService.guardReferences now also blocks archiving a product with active service links (same pattern as the existing lead/expert checks).
  • packages/services/src/db/services/service/index.ts: added ServiceService.guardReferences (previously used the base no-op) to block archiving a service with active product links.
  • packages/services/src/db/models/index.ts and packages/services/src/db/services/index.ts: barrel exports for the new model/service/instance, matching the existing product/service pattern.

platform → product is untouched; the new link sits beneath product, alongside service.

Acceptance criteria status

  • A product can be linked to many services and a service to many products, scoped per tenant (M2M) — ProductServiceLinkSchema has no cardinality constraint either direction; unique index is on the triple (tenantId, product, service) only.
  • Linking the same (tenantId, product, service) pair twice does not create a duplicate — link() upserts and swallows the duplicate-key race.
  • Unlinking a pair that isn't linked does not error — unlink() is a plain deleteOne, which succeeds (0 modified) regardless.
  • A service-layer query returns the list of services linked to a given product — getServicesForProduct.
  • A service-layer query returns the list of products linked to a given service — getProductsForService.
  • Archiving a product/service with active links raises a reference-guard error — ProductService/ServiceService.guardReferences now check countByProduct/countByService.
  • Existing lead/product/service/expert behaviour and queries are unaffected — purely additive; packages/services typechecks clean.
  • platform → product is untouched.

Verify result

  • pnpm --filter @sustentus/services typecheck run locally (obvious check on a new/changed file, not a full-repo sweep) — clean. Format/lint/build are CI + Vercel preview's job per the Build contract.

Notes for review

  • M2M storage is a join collection (not an embedded array on either side) — keeps both directions equally indexed and avoids unbounded-array growth on product/service docs, per mongodb-schema-design.
  • No admin UI or lead-form wiring in this stub — that's product-services-admin and lead-capture-cascade (stubs 3 and 4 of the catalogue-hierarchy epic).

03_release/output/investor-update.md

Foundation laid for smarter lead-to-vendor matching

Who it's for: Admin, the foundation for accurate vendor matching later. What shipped: Products and services can now be linked many-to-many in the catalogue's data model. Why it matters: Advances Build the Bridge's Objective 2 — Build Repeatable Lead Generation Pipeline — toward better lead-to-vendor match accuracy.

Existing product, service, and lead behaviour is fully unchanged.

Dig deeper: https://github.com/sustentus/sustentus/pull/592

03_release/output/release.md

Release: product-service-links

  • pr: #592 · merged: yes — 2026-07-06, squash commit caceec2
  • CI: green — Formatting/Linting/Typechecking (Quality Project), Audit database, Migrate preview database all passed; Migrate production database skipped (branch protection runs it post-merge only)
  • technical docs: no technical docs impact — purely additive backend change; no new app, package, route, env var, or build/CI step. apps/docs/app/technical/packages/services/page.mdx doesn't enumerate individual db models, so nothing there needs updating.
  • business docs: no business docs impact — no user-facing behaviour changed yet (no admin UI, no lead-form cascade; those are stubs 3/4 of the catalogue-hierarchy epic). feature-role-matrix/service-journey/platform-overview still describe current product behaviour accurately.
  • release notes: investor-only — internal/infra change with no visible end-user capability yet, framed as delivery velocity toward Objective 2. No changelog entry (nothing an end user can do differently today).
  • deploy: web production deploy of caceec2 reached READY. help's deployment attempt for this sha reported CANCELED (https://vercel.com/sustentus/help-centre/3p2EZfwn8DXfXiN8CqhwYZTAcdTL) — inspected via the Vercel MCP: errorLink points at the "Ignored Build Step" doc, consistent with this PR touching nothing under apps/help (a build skip, not a failure). help.sustentus.com's live deployment (from PR #573) was independently confirmed READY and unaffected. poll-deploy.sh treats any CANCELED as a hard stop with no exception for this case, so the automated gate held; the user reviewed the diagnosis and authorised proceeding manually.
  • sent: investor update sent to 2 recipients on 2026-07-06, after confirming web READY and help.sustentus.com's live deployment READY.

Review summary

Ran /code-review medium (complexity: standard) via three parallel agents (correctness, cleanup/efficiency, altitude/conventions) plus this stage's own read. Findings and disposition:

  • Missing migration for the new unique indexproduct-service-link.ts's {tenantId, product, service} unique index (and the {tenantId, service} reverse index) were declared only via Schema.index(). Per this repo's db-migration convention, autoIndex only builds indexes in dev; production needs a migration. Resolved on branch: added packages/services/src/db/migrations/1782800000000-product-service-link-indexes.ts and cross-referenced it in the schema comment (matching the invoice.ts pattern).
  • link() didn't verify the product/service belong to the calling tenant — flagged independently by two review angles: a caller bug or stale ID could create a cross-tenant link, leaking another tenant's product/service name into getServicesForProduct/getProductsForService, and blocking that tenant's own archive via phantom link counts. Resolved on branch: link() now looks up both documents tenant-scoped first and throws if either isn't found, before creating the join row.
  • ProductService.guardReferences ran three reference checks as sequential awaits — minor latency regression from adding a third serial round-trip. Resolved on branch: parallelized with Promise.all, preserving the existing lead → expert → link error-message priority.
  • isDuplicateKeyError duplicated instead of reusing taxonomy/base.ts's copy — accepted as-is: it's already duplicated 7+ times across existing service files in this codebase (a pre-existing pattern, not one this PR introduces); consolidating it into a shared util is a separate, codebase-wide cleanup out of scope for this stub.
  • TaxonomyService.update() can set isActive: false on a linked product/service without running guardReferences — accepted as a pre-existing gap: the same hole already exists for the Lead/Expert checks that predate this PR. Logged here as a known follow-up, not introduced by this change.
  • getServicesForProduct/getProductsForService do two round-trips instead of one $lookup aggregation — accepted: tenant-scoped taxonomy tables are expected to stay small, so the extra round-trip is negligible; two-query mirror methods keep both directions equally easy to read.

Acceptance check (vs spec)

  • A product can be linked to many services and a service to many products, scoped per tenant (M2M) — no cardinality constraint either direction; unique index (with a migration) enforces one row per pair.
  • Linking the same pair twice does not create a duplicate — upsert + duplicate-key swallow.
  • Unlinking a pair that isn't linked does not error — plain deleteOne.
  • Services-for-product and products-for-service queries exist and are tenant-scoped.
  • Archiving a linked product/service raises a reference-guard error — guardReferences on both ProductService and ServiceService.
  • Existing lead/product/service/expert behaviour and queries unaffected — purely additive; typechecks clean.
  • platform → product untouched.