admin-integrations-registryrun.md00_intake/stub.mdThe IntegrationsCard (connected systems with status, last-sync, record counts) and the AvailableIntegrationsCard are dummy/static. There is no model recording which integrations a tenant has. Per the user's decision, this round delivers a config registry — a per-tenant record of integrations with a manually/heuristically set status — not live OAuth or sync.
integrationsConnected readiness check in admin-readiness-gaps derives from this model.touches: packages/services/src/db/models (new integration model),
apps/web/components/admin/dashboard/{integrations-card,available-integrations-card}.tsx.01_define/output/spec.mdOn the admin "Platform setup" dashboard, the IntegrationsCard (connected systems with status,
last-sync and impact) renders from an empty mock array (integrations: [] in lib/mock/admin.ts),
and the AvailableIntegrationsCard renders a hardcoded constant list. There is no model recording
which integrations a tenant has, so the integrations stage of setup can never reflect real state and
the downstream integrationsConnected readiness check (built later in admin-readiness-gaps) has
nothing real to derive from. This is feature 6 of 7 in the admin-dashboard-data epic, which
advances Build the Bridge / Q2 2026 Objective 3 — validate technical infrastructure & payout
flow by making each dashboard section operational from real, per-tenant data. Unlike the escalation
and commercial configs (single key/value flags stored in TenantSetting), an integrations registry
is a per-tenant collection of rich rows, so it warrants its own model. Per the epic's decision,
this round delivers a config registry — a per-tenant record of integrations whose status is set
by the admin — not live OAuth or sync.
TenantIntegration model (packages/services/src/db/models/tenant-integration.ts),
following the established tenantPlugin + schemaPlugin + softDeletePlugin model pattern. Fields:catalogueKey — string, the catalogue entry this row was added from (e.g. salesforce). Unique
per tenant (a tenant adds a given catalogue integration once).name — string (display name, seeded from the catalogue entry).type — enum CRM | Notifications | Automation | Billing.status — enum healthy | warning | disconnected, default warning.lastSyncLabel — optional string (display-only label, e.g. "Synced 2h ago"; no real sync).impact — optional string (what this integration powers, e.g. "Lead sync").issueNote — optional string (shown by the card when status is warning).{ tenantId: 1, catalogueKey: 1 } unique. Exported from models/index.ts.TenantIntegrationService (services/tenant-integration/ with index.ts + instance.ts,
registered in services/index.ts as tenantIntegrationService), exposing:listIntegrations(tenantId) — the tenant's rows (newest-first or stable order).addIntegration(tenantId, { catalogueKey, name, type, status?, lastSyncLabel?, impact?, issueNote? })
— creates a row; rejects/ignores a duplicate catalogueKey for that tenant.updateIntegration(tenantId, id, { status?, lastSyncLabel?, impact?, issueNote? }) — edits an
existing row's status and detail fields.removeIntegration(tenantId, id) — soft-deletes a row (admin removed it wrongly-added).IntegrationStatus / IntegrationType type and a row type for the web layer.apps/web/lib/integrations-catalogue.ts — the defined "available" list
with display metadata, replacing the inline AVAILABLE_INTEGRATIONS array in the card. Each entry:
{ key, name, type, abbr, color }. This round's catalogue:salesforce → Salesforce (CRM), hubspot → HubSpot (CRM)stripe → Stripe (Billing), xero → Xero (Billing)teams → Teams (Notifications), zapier → Zapier (Automation)/admin/integrations page (server component + actions.ts +
_components/ form, mirroring the /admin/escalation and /admin/commercial pattern):name, type) with a default status of warning ("needs attention") and empty detail
fields. New rows are therefore added but unverified, not auto-counted as connected.healthy | warning | disconnected) and edit its
last-sync label, impact text, and issue note, then save.runActionBody / resolveActionContext({ allowedRoles: ["admin"] })
convention and revalidatePath("/admin/integrations") + revalidatePath("/admin/dashboard").
Sentence-case copy throughout.integrations slice to loadAdminDashboardData
(apps/web/lib/admin-dashboard-data.ts): the tenant's real rows mapped to the card's shape, plus
the available catalogue entries (catalogue minus the keys already added). AdminDashboardData
gains a typed integrations field.d.integrations mock. The card already renders name / status / issue / impact —
no shape change beyond sourcing real data; its "Manage integrations" footer already links to
/admin/integrations. The empty-state copy already exists for a tenant with no integrations.available prop
(catalogue entries the tenant has not added) instead of its hardcoded constant, so adding an
integration removes it from "available". Its display metadata (abbr, colour) comes from the
catalogue constant.integrations: [] array's use from lib/mock/admin.ts
wiring (the dashboard no longer reads it for this section)./admin/integrations; it persists per
tenant in the new TenantIntegration collection, seeded with the catalogue entry's name and
type and a default status of warning.healthy | warning | disconnected) and its
last-sync label, impact text, and issue note, and the changes persist per tenant.tenantIntegrationService exposes listIntegrations / addIntegration / updateIntegration /
removeIntegration and a typed row/status/type contract; adding a duplicate catalogue entry for
the same tenant does not create a second row.integrationsConnected readiness check and config-gap recomputation — owned by
admin-readiness-gaps (this feature provides the model it derives from).02_build/output/notes.mdfeat: admin-integrations-registry — model + service, feat: admin-integrations-registry — catalogue, loader + config UIpackages/services/src/db/models/tenant-integration.ts — per-tenant
TenantIntegration collection with catalogueKey, name, type
(CRM | Notifications | Automation | Billing), status
(healthy | warning | disconnected, default warning), and optional
lastSyncLabel / impact / issueNote. Unique index on
{ tenantId, catalogueKey }. Uses the standard
schemaPlugin + softDeletePlugin + tenantPlugin stack. Exports
INTEGRATION_TYPES / INTEGRATION_STATUSES and their literal types. Exported
from models/index.ts.packages/services/src/db/services/tenant-integration/ —
TenantIntegrationService with listIntegrations / addIntegration
(duplicate catalogueKey rejected) / updateIntegration /
removeIntegration (soft delete). Instance tenantIntegrationService
registered in services/index.ts; AddIntegrationInput /
UpdateIntegrationInput / TenantIntegrationService exported.apps/web/lib/integrations-catalogue.ts — the defined
available list (Salesforce/HubSpot → CRM, Stripe/Xero → Billing, Teams →
Notifications, Zapier → Automation) with key/name/type/abbr/color and a
getCatalogueEntry helper. Shared by the loader, the config page, and the
available card.apps/web/lib/admin-dashboard-data.ts — adds a typed
integrations slice (connected real rows mapped to the card shape +
available = catalogue minus added keys).integrations.connected rows.available prop (now a server
component) sourced from the catalogue minus added entries, with an
all-added empty state.apps/web/app/(app)/admin/integrations/ — server page.tsx
(admin-gated), actions.ts (add / update / remove, following the
resolveActionContext + runActionBody + zodToActionError pattern, each
revalidating /admin/integrations and /admin/dashboard), and a client
IntegrationsConfig form: add a catalogue entry (status defaults to
warning), edit each row's status + last-sync/impact/issue note, remove a row.integrations: [] mock field was dropped from lib/mock/admin.ts./admin/integrations; persists per
tenant seeded with the catalogue name/type and default warning status.healthy | warning | disconnected) + last-sync,
impact, issue note; changes persist.tenantIntegrationService exposes list/add/update/remove + typed contract;
duplicate catalogueKey is rejected (unique index + duplicate-key guard).gh pr checks. No check is
expected to fail.TenantSetting key/value store —
appropriate for a list of rich rows and the seam admin-readiness-gaps will
derive integrationsConnected from.type is a structurally
identical union in both the catalogue (UI) and the model (DB) to avoid a
client→/server value import.components/admin/integrations-card/ (AdminIntegrationsCard,
backed by /app/(app)/integrations/actions) is an unrelated feature and was left
untouched.03_ship/output/changelog.mdPersona: admin
Admins can now track their organisation's integrations from the platform setup dashboard:
This is part of making your platform setup dashboard reflect your organisation's real configuration.
03_ship/output/investor-update.mdWe've made the integrations section of the admin platform setup dashboard real: admins can now record which systems their organisation uses — CRM, billing, notifications, automation — and set each one's status, so the dashboard reflects their actual integration setup instead of placeholder content. It turns another part of the admin control surface into a working control panel, and lays the per-organisation record that the platform's go-live readiness checks build on.
This advances the Build the Bridge initiative and our Q2 2026 objective to validate technical infrastructure & payout flow — bringing each organisation's integration setup under explicit, real control.
03_ship/output/pr.mdapps/docs/technical describes the db/ layer only at a high level ("Mongoose models, db services, plugins"); it does not enumerate individual models, services, or admin routes (mirrors the escalation/commercial siblings).feature-role-matrix/service-journey entity covers admin platform-setup config; this is operational configuration capture, not a service-journey step (mirrors the siblings).apps/help/app/changelog/page.mdx).{tenantId, catalogueKey} counted soft-deleted rows, so removing then re-adding an integration threw a duplicate-key error, breaking the core remove/re-add loop. Fixed: added partialFilterExpression (matching csat.ts/lead.ts).updateIntegration returns null (not a throw) when the row was concurrently removed or the id is stale, and runActionBody treated that as success (green toast, nothing saved). Fixed: the action now throws "This integration no longer exists." when the update returns null.IntegrationType + available-list filter (cleanup) — Fixed: catalogue now sources IntegrationType from the model via @sustentus/services/server; availableCatalogueEntries(addedKeys) is the single helper used by both the loader and the config page.catalogueKey (consistency) — Fixed: loader now maps id: row._id.toString(), matching the config page and the documented row-id semantics.AddIntegrationInput mirror the tenant-setting precedent; the status→label vocabulary in the form/card is left inline (consolidating it is a larger cross-component refactor, out of scope)./admin/integrations; persists per tenant seeded with the catalogue name/type and default warning status.tenantIntegrationService exposes list/add/update/remove + typed contract; duplicate catalogueKey rejected (unique index + duplicate-key guard)./admin/integrations added to the proxy route policy (admin-only) so the page loads instead of bouncing to the role home.