product-service-linksrun.md00_intake/stub.mdProducts 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.
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.
productService (or serviceService) can list the services linked to a given product, and the products linked to a given service.product-services-admin, stub 3).platform → product untouched — this sits beneath product.mongodb-schema-design
skill; watch the shared taxonomy plugins (schemaPlugin/softDeletePlugin/tenantPlugin).packages/services/src/db/models/{product,service}.ts,
packages/services/src/db/services/{product,service}/.01_define/output/spec.mdproduct 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.
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:
(product, service) pair for a tenant.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.
(tenantId, product, service) pair twice does not create a duplicate — the operation is idempotent.ProductService/ServiceService already raise for lead/expert references (consistent with guardReferences in packages/services/src/db/services/product/index.ts).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.product-services-admin, stub 3 of the epic).skillCategory retirement (stub 2, skill-service-retier).lead-capture-cascade).catalogue-demo-reseed).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.mdpackages/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: ProductServiceLinkService — link/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.
ProductServiceLinkSchema has no cardinality constraint either direction; unique index is on the triple (tenantId, product, service) only.(tenantId, product, service) pair twice does not create a duplicate — link() upserts and swallows the duplicate-key race.unlink() is a plain deleteOne, which succeeds (0 modified) regardless.getServicesForProduct.getProductsForService.ProductService/ServiceService.guardReferences now check countByProduct/countByService.lead/product/service/expert behaviour and queries are unaffected — purely additive; packages/services typechecks clean.platform → product is untouched.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.product/service docs, per mongodb-schema-design.product-services-admin and lead-capture-cascade (stubs 3 and 4 of the catalogue-hierarchy epic).03_release/output/investor-update.mdWho 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.mdcaceec2Quality Project), Audit database, Migrate preview database all passed; Migrate production database skipped (branch protection runs it post-merge only)apps/docs/app/technical/packages/services/page.mdx doesn't enumerate individual db models, so nothing there needs updating.catalogue-hierarchy epic). feature-role-matrix/service-journey/platform-overview still describe current product behaviour accurately.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.web READY and help.sustentus.com's live deployment READY.Ran /code-review medium (complexity: standard) via three parallel agents (correctness, cleanup/efficiency, altitude/conventions) plus this stage's own read. Findings and disposition:
product-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.deleteOne.guardReferences on both ProductService and ServiceService.platform → product untouched.