Catalog and enablement
The platform has a small set of named Products (the Scheduler today) and a set of named Integrations (ServiceTitan today). The catalog is the seed-driven inventory of what exists in the world. Enablement is the per-Brand state describing which products are turned on, which integrations are connected, and which vendor credentials back those connections.
This page covers the four catalog and enablement entities, the strategy pattern for vendor-specific credential handling, the product-industry config that bridges product availability to industry context, and the activation rules.
Two catalogs + two enablement tables
| Entity | Layer | Lifecycle | Source |
|---|---|---|---|
ProductDefinition | Catalog | Seed-driven; replaced via migration | libs/shared/types-data/src/lib/product/product.entity.ts |
IntegrationDefinition | Catalog | Seed-driven; replaced via migration | libs/shared/types-data/src/lib/integrations/integration.entity.ts |
OrgProduct | Enablement | Per-org row; CRUD via admin endpoints | libs/shared/types-data/src/lib/product/org-product.entity.ts |
IntegrationInstance | Enablement | Per-org row; created on connect | libs/shared/types-data/src/lib/integrations/integration-instance.entity.ts |
VendorCredential | Enablement | Per-instance row; encrypted vendor secrets | libs/shared/types-data/src/lib/integrations/vendor-credentials/vendor-credential.entity.ts |
ProductDefinition + IntegrationDefinition (catalog)
The catalog tables are deliberately read-only at runtime. They are populated by data-migration seed scripts and snapshot the inventory of products and integrations the platform supports. Adding a new product or integration today means writing a migration — see Data model and migrations.
ProductDefinition.keyis aProductKeyenum value:libs/shared/types-data/src/lib/product/product.enum.ts:1(enum ProductKey).IntegrationDefinition.keyis an integration-identifying enum value:libs/shared/types-data/src/lib/integrations/integration.enum.ts.
The catalog and the enablement tables are joined at query time via the key fields; there is no foreign-key constraint in MongoDB.
OpenSpec contract: openspec/specs/product-enablement/spec.md, openspec/specs/integration-enablement/spec.md.
In-memory cache and post-migration rehydration
The catalog is hot-path data: every OrgProduct / IntegrationInstance response is composed with its definition before serialization (OrgProductService.findAllForOrg → productDefinitionService.findByKeyOrThrow). To avoid a Mongo round-trip per row, each catalog service holds a process-local CacheService<TKey, TDefinition> populated at boot.
The cache is filled by two non-overlapping triggers:
| Trigger | When | Role |
|---|---|---|
OnModuleInit | During Nest module initialization | Non-discretionary boot load — guarantees the cache is populated against whatever DB state exists |
MigrationsAppliedListener.onMigrationsApplied | After MigrationBootCoordinator.runAsBootstrap reaches CONVERGED | Post-migration refresh — picks up any new rows a seed migration wrote during the bootstrap phase |
Both triggers call the same loadCatalogIntoCache(fetchAll, cache, label, logger) helper at libs/api/services-api/src/lib/catalog/shared/load-catalog-into-cache.ts. The listener contract lives at libs/api/services-api/src/lib/data-migration/lifecycle/migrations-applied-listener.types.ts; the catalog services (ProductDefinitionService, IntegrationDefinitionService) implement it and are injected directly into the coordinator.
The need for the listener is a consequence of NestJS lifecycle ordering: OnModuleInit fires strictly before OnApplicationBootstrap (which is where MigrationBootCoordinator runs). On a fresh deployment where seed migrations 028/029 first populate the catalog, the OnModuleInit snapshot is taken against an empty collection. The listener invocation closes that gap by re-reading after the coordinator converges.
Cross-pod coordination is implicit through Mongo durability, not through any cache-level message bus:
- Winner pod returns from
walkRegistryonly after every migration's writes are committed. - Loser pod returns from
waitForRegistryConvergenceonly after observingterminalCount === registryNames.lengthin Mongo — i.e. the winner's writes are durably visible. - Both paths flow through
notifyOrAbortBootstrap, so by the time either pod's listeners fire, theirrepository.find({}).lean<T[]>()reads see post-migration state. - Listener failures on the bootstrap path throw and abort the Nest bootstrap → ECS replaces the pod. The admin-trigger path returns the report in the HTTP response without throwing.
Adding a new in-memory catalog cache (RBAC roles, regional config, etc.) means (1) implementing MigrationsAppliedListener on the service, (2) appending it as a constructor parameter on MigrationBootCoordinator, and (3) including it in the private readonly listeners array assigned in the coordinator constructor — DI alone is silent; the constructor wiring is what makes notifyMigrationsApplied see the new listener. There's no module-wiring boilerplate beyond a normal class-based provider entry.
OrgProduct (per-org product state)
OrgProduct is the per-Brand enablement record. It is a discriminated entity keyed on product_key:
// libs/shared/types-data/src/lib/product/org-product.entity.ts
export type SchedulerOrgProduct = OrgProductBase & { /* scheduler config */ };
export type OrgProduct = SchedulerOrgProduct;
Today's only variant is Scheduler. The discriminated-union shape is the seam for adding a second product without retrofitting the type.
The lifecycle is driven by OrgProductStatus (libs/shared/types-data/src/lib/product/product.enum.ts:12):
| State | Whose call | Meaning |
|---|---|---|
PENDING_SETUP | system | Created, not yet ready for activation |
ACTIVE | admin | Turned on; surfaced in UI gating |
DISABLED | operator | Manual deactivation; product hidden from customer-facing flows but enablement state preserved for re-enable |
SUSPENDED | billing | Billing-driven placeholder; not currently writable through the activation DTO |
UI route gating reads OrgProductStatus per request; only ACTIVE exposes the product to customer-facing flows. The billing-driven SUSPENDED path is reserved for the future subscription layer — see Future state → Catalog and enablement.
IntegrationInstance + VendorCredential
An IntegrationInstance represents a per-Brand connection to a specific IntegrationDefinition. It is also a discriminated entity:
// libs/shared/types-data/src/lib/integrations/integration-instance.entity.ts
export type ServiceTitanIntegrationInstance = IntegrationInstanceBase & { /* … */ };
export type IntegrationInstance = ServiceTitanIntegrationInstance;
Each instance carries an IntegrationInstanceStatus (libs/shared/types-data/src/lib/integrations/integration.enum.ts). Today the platform ships atomic connect-time activation (PENDING → CONNECTED is collapsed). The PENDING state and orphan-cleanup cron are deferred (see Future state → Catalog and enablement).
VendorCredential carries the encrypted secrets backing the instance. Credentials are keyed by vendor and stored separately from the instance document — this keeps the credential lifecycle distinct from the instance lifecycle and lets the encryption boundary stay narrow.
Strategy pattern for vendor credentials
Each integration has vendor-specific credential semantics (auth shape, refresh policy, scope-availability check). The platform encapsulates these in a strategy interface so the credential service stays vendor-agnostic.
- Interface:
libs/api/services-api/src/lib/integrations/vendor-credentials/strategies/vendor-credential-strategy.interface.ts - Factory:
libs/api/services-api/src/lib/integrations/vendor-credentials/strategies/vendor-credential-strategy.factory.ts— looks up the strategy by integration vendor - ServiceTitan implementation:
libs/api/services-api/src/lib/integrations/vendor-credentials/strategies/servicetitan-credential.strategy.ts - Service consumer:
libs/api/services-api/src/lib/integrations/vendor-credentials/vendor-credentials.service.ts
Adding a second vendor (OAuth2-based, for example) means implementing the strategy interface and registering it in the factory. The service contract stays unchanged.
Product-industry config
A product's availability in a given org depends on the org's industry — a residential service business needs different required integrations than a commercial one. This mapping lives in libs/shared/utils-core-logic/src/lib/product/product.config.ts and is consumed both at activation time (which integrations does this product require for this industry?) and at the UI layer (is this product available to this Brand?).
The config is runtime-agnostic (lives in utils-core-logic, not services-api) so the same rules apply on the frontend and backend.
OpenSpec contract: openspec/specs/product-industry-config/spec.md.
Activation rules
OrgProduct transitions through OrgProductStatus under enforcement at the DTO layer:
- Only
PENDING_SETUP → ACTIVEandACTIVE → PENDING_SETUPtransitions are allowed from the admin endpoints. SUSPENDEDis not currently writable (the DTO whitelist rejects it).- Activation to
ACTIVEdoes not block on required-integration presence — the platform currently warns rather than gates. Hard gating is deferred (see Future state → Catalog and enablement).
OpenSpec contract: openspec/specs/product-enablement/spec.md.
Related
- OpenSpec contracts:
openspec/specs/product-enablement/spec.md,openspec/specs/integration-enablement/spec.md,openspec/specs/product-industry-config/spec.md,openspec/specs/org-validation-enforcement/spec.md - Future direction: Future state → Catalog and enablement — CatalogAdmin CRUD, integration-availability validation, orphan cleanup
- Data model: Data model and migrations — how catalog seeds ship