Skip to main content

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

EntityLayerLifecycleSource
ProductDefinitionCatalogSeed-driven; replaced via migrationlibs/shared/types-data/src/lib/product/product.entity.ts
IntegrationDefinitionCatalogSeed-driven; replaced via migrationlibs/shared/types-data/src/lib/integrations/integration.entity.ts
OrgProductEnablementPer-org row; CRUD via admin endpointslibs/shared/types-data/src/lib/product/org-product.entity.ts
IntegrationInstanceEnablementPer-org row; created on connectlibs/shared/types-data/src/lib/integrations/integration-instance.entity.ts
VendorCredentialEnablementPer-instance row; encrypted vendor secretslibs/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.key is a ProductKey enum value: libs/shared/types-data/src/lib/product/product.enum.ts:1 (enum ProductKey).
  • IntegrationDefinition.key is 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.findAllForOrgproductDefinitionService.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:

TriggerWhenRole
OnModuleInitDuring Nest module initializationNon-discretionary boot load — guarantees the cache is populated against whatever DB state exists
MigrationsAppliedListener.onMigrationsAppliedAfter MigrationBootCoordinator.runAsBootstrap reaches CONVERGEDPost-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 walkRegistry only after every migration's writes are committed.
  • Loser pod returns from waitForRegistryConvergence only after observing terminalCount === registryNames.length in Mongo — i.e. the winner's writes are durably visible.
  • Both paths flow through notifyOrAbortBootstrap, so by the time either pod's listeners fire, their repository.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):

StateWhose callMeaning
PENDING_SETUPsystemCreated, not yet ready for activation
ACTIVEadminTurned on; surfaced in UI gating
DISABLEDoperatorManual deactivation; product hidden from customer-facing flows but enablement state preserved for re-enable
SUSPENDEDbillingBilling-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 → ACTIVE and ACTIVE → PENDING_SETUP transitions are allowed from the admin endpoints.
  • SUSPENDED is not currently writable (the DTO whitelist rejects it).
  • Activation to ACTIVE does 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.

  • 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