SculptFlow system design

How SculptFlow (repo name MySculptFlow, assembly PlasticSurgery) is built today: the Razor Pages dashboard, the .NET API and its workers, the WhatsApp/Telegram/calendar/knowledge integrations, the n8n workflows as seen from the app, and the PostgreSQL schema. Written from the code, not from older docs.

Last verified against code: 2026-10-15 Code baseline: 12fad04 (main) + branch feature/subscription-billing .NET 10 · EF Core / Npgsql · PostgreSQL + pgvector · SignalR

This is a living document. Any change that adds or alters a feature, endpoint, table, integration, background job, config key or n8n contract must update the matching section here in the same change, and add a line to the change log. The rule is also in CLAUDE.md at the repo root.

1. Overview

SculptFlow is a multi-tenant SaaS for plastic surgery clinics. A prospective patient (a lead) messages a clinic on WhatsApp or Telegram. An AI agent, orchestrated in n8n, answers from the clinic's Knowledge Base, qualifies the lead and books, moves or cancels consultations through SculptFlow's own tool endpoints. Staff watch every conversation live in the Inbox and can take over at any moment. Clinics also run WhatsApp template campaigns, mainly to reactivate old leads.

Conversations

Unified Inbox for WhatsApp and Telegram, AI / human modes, take over and return to AI, unread tracking, WhatsApp 24-hour service window, template sends.

Leads & booking

Leads ("Interested People"), procedures catalog, structured availability, month calendar, AI booking tools with a per-clinic lock, procedure bookings.

WhatsApp business

Embedded Signup, message templates with Meta review status, account and phone health, campaigns with audience picker and reply/booking attribution.

Knowledge Base

Manual entries, PDF/DOCX/TXT upload, website crawling, chunking + embeddings, pgvector semantic search for the AI, retrieval benchmark.

Clinic admin

Self-service registration (one new clinic per signup), Google sign-in, clinic info and opening hours, staff list, notification bell, calendar and TikTok connections.

Billing

Subscription plans with entitlements and included credit; prepaid wallet; usage billed per billable event from versioned rate cards (reserve, settle, release); append-only financial ledger. Off until Billing:Enabled.

2. High-level design

2.1 System context

One ASP.NET Core process serves the dashboard (server-rendered Razor Pages plus small vanilla JS files), a JSON API, a SignalR hub and two in-process background workers. PostgreSQL (Supabase) is the single source of truth. n8n holds no business state: it runs the AI agent and two helper workflows, and talks to the app only over HTTP.

System context: browsers, the SculptFlow app, external providers, n8n and PostgreSQL Browser SculptFlow app (Render, Docker) External Data Staff dashboard Razor Pages HTML wwwroot/js/*.js (vanilla) Aurora kit + site.css SignalR JS client (CDN) Meta JS SDK Embedded Signup popup (meta-connect.js) OAuth redirects Google login, calendars, TikTok consent screens Razor Pages Pages/** (cookie auth) server-side PageModels API controllers /api/** , OAuth, webhooks 4 trust models (§2.3) InboxHub /hubs/inbox (SignalR) Webhook processors Meta, Telegram Services (business logic, clinic-scoped) Lead · Conversation · Message · Appointment · Availability · ProcedureBooking Campaign · Audience · WhatsAppTemplate · WhatsAppHealth · Channels Knowledge · Search · Embedding · Notification · Calendar TikTok · ClinicRegistration · Staff · Dashboard · EventLogger · Billing (§2.6) IChannelSender → WhatsApp | Telegram senders n8n out: AiTrigger · CalendarSync · BenchmarkGenerator WebsiteScrapeWorker in-memory queue, 1 crawl at a time BenchmarkRunWorker in-memory queue, 1 run at a time ApplicationDbContext (EF Core, mapped to Database/schema.sql) Meta (WhatsApp Cloud, Graph) webhooks in, sends + templates out Telegram Bot API n8n (3 workflows) patient AI · KB benchmark · calendar sync OpenAI-compatible embeddings Google / Microsoft / TikTok OAuth Clinic websites (crawler) PostgreSQL (Supabase) 45 tables · pgvector schema.sql is the source of truth
Blue arrows are the n8n round trip: the app triggers a workflow, the workflow calls back into /api/** with X-Ingest-Key. Not drawn: the billing maintenance worker (§4.5) and the platform-admin API the admin portal calls (§2.3).

2.2 Multi-tenancy

  • Tenant = clinic. Every business table carries clinic_id (FK to clinics, on delete cascade), and every service method takes a clinicId and filters on it.
  • Dashboard requests never trust a clinic id from the browser. ICurrentClinicContext resolves it from the signed-in Identity user through clinic_users (one active membership per user, enforced by the partial unique index ux_clinic_users_user_active).
  • Webhooks resolve the clinic from the stored connection: Meta events by phone_number_id then WABA id on channel_integrations; Telegram by the connectionId in the URL plus its secret token.
  • n8n tool calls pass clinicId explicitly (query or body) and are trusted because of the shared X-Ingest-Key. This is the weakest link; see §10.
  • Registration always creates a brand-new, empty clinic plus its first clinic_users row in one transaction (ClinicRegistrationService). There is no invite or join flow yet, and no roles: every member of a clinic has full access.
  • SignalR groups are clinic:{id} (joined on connect from the server-resolved clinic) and conversation:{id} (joined only after an ownership check).

2.3 Trust zones and authentication

ModelWhoHow it is checkedWhere
sessionStaff in the browserASP.NET Core Identity cookie (AddIdentityCore, role-free). Razor Pages: AuthorizeFolder("/") with Login, Register, CompleteGoogleSignup and Error opened up. API: DashboardApiController base class is [Authorize] and exposes the resolved clinic id.Pages/**, most /api/**, /hubs/inbox, /calendar-oauth/*, /tiktok-oauth/*
ingest keyn8n (server to server)[RequireIngestKey] action filter compares header X-Ingest-Key to N8n:IngestApiKey; 500 if the key is not configured, 401 if it does not match./api/ai/**, /api/ai/test/**, /api/messages/ingest, /api/integrations/whatsapp/{templates,health}/events, /api/calendar-integrations/sync-callback, /api/knowledge/benchmark/generations/{id}/questions, and the AI branch of POST /api/conversations/{id}/messages/send (checked inline)
providerMeta, Telegram, TikTokMeta: GET handshake against Meta:WebhookVerifyToken; POST deliveries are not signature-checked yet. Telegram: connectionId must be a connected telegram row and X-Telegram-Bot-Api-Secret-Token must match (constant time). TikTok: placeholder, logs only./api/integrations/whatsapp/webhook, /api/integrations/telegram/webhook/{connectionId}, /api/integrations/tiktok/webhook
platform admin keySculptFlow platform admins through the SculptFlowAdmin portal (server to server), or curl[RequirePlatformAdminKey] compares header X-Platform-Admin-Key to PlatformAdmin:ApiKey in constant time; 404 when the key is not configured, 401 if it does not match. X-Admin-Actor names the admin in the ledger. Clinic staff accounts can never reach it (no roles exist). Convention for every admin domain: the main app owns the rules and writes under /api/platform-admin/{domain}; the portal is the control panel and never writes those tables itself./api/platform-admin/**
external cookieGoogle sign-in in progressShort-lived (10 min) IdentityConstants.ExternalScheme cookie holding the Google identity; only a verified email is trusted. Registered only when GoogleLogin:ClientId and ClientSecret are both set./auth/google/start, /auth/google/callback, /signin-google, /Account/CompleteGoogleSignup

OAuth state for calendar and TikTok connects is protected with ASP.NET Core DataProtection. POST /api/leads takes the clinic id from the body, so it needs the ingest key ([RequireIngestKey], 401 without it). Sign-up POSTs (/Account/Register, /Account/CompleteGoogleSignup) are rate limited per client IP (Accounts:SignupsPerIpPerHour, 429 over the limit; IP from X-Forwarded-For), and Accounts:SignupEnabled = false closes self-signup altogether.

2.4 Deployment and runtime

  • Multi-stage Dockerfile (SDK 10.0 build, aspnet 10.0 runtime). Listens on plain HTTP ${PORT:-8080}; Render terminates TLS. Deployed from main to https://sculptflowapp.onrender.com. No CI workflow files are in the repo.
  • UseForwardedHeaders trusts X-Forwarded-Proto so redirect URIs and secure cookies see https.
  • Swagger UI at /swagger in Development only. HSTS and /Error handler outside Development.
  • Schema is applied by hand: psql -f Database/schema.sql (idempotent, append-only blocks), optional Database/seed.sql. EF Core migrations are not used.
  • Secrets: dotnet user-secrets locally, Render env vars (Section__Key) in production. Properties/launchSettings.json on dev machines holds real keys and must never be committed.
  • Single instance assumed: the scrape and benchmark queues are in-memory Channel<Guid>s, recovered from DB state on startup. SignalR has no backplane. (Billing is the exception: its correctness rests on database row locks and unique keys, so two instances running the billing worker can't double-charge.)

2.5 Design principles visible in the code

  1. PostgreSQL is authoritative; SignalR only says "something changed". Every client handler re-fetches over REST.
  2. Business logic lives in Business/, shared by Razor PageModels and API controllers. Controllers and PageModels hold no logic; database access belongs in Persistence/ repositories (§4.1).
  3. The backend decides; the AI asks. Mode checks, slot validity, duplicate-booking guard and AI eligibility are decided in .NET, not in n8n.
  4. Idempotency at the database. Partial unique indexes on external ids (messages, leads, Telegram threads, health events) make webhook retries safe.
  5. Side effects after commit, never throwing. Notifications, calendar sync and AI triggers run after the main write succeeds and swallow their own failures.
  6. Time: every instant is timestamptz (UTC). Activity shows in the viewer's timezone; appointments default to clinic time with a Clinic/My time switch; availability is entered and stored clinic-local.
  7. Tolerate provider vocabularies. Meta template and health statuses are free text (CHECK constraints dropped) so unknown values never fail a webhook.
  8. Money moves only through the ledger. Every balance change is an append-only ledger row written in the same transaction, under the clinic's billing-account row lock, with an idempotency key (§2.6).

2.6 Subscriptions and usage billing

An internal module (the Billing feature folders under Business/, Entities/ and Persistence/, tables in the Postgres schema billing.*, guide in docs/billing.md) built around three separate questions that are never collapsed into one:

  1. Platform subscription: what the clinic pays SculptFlow for access. The plan decides features and limits (entitlements) and may grant a monetary included credit each period.
  2. Provider billing responsibility: who pays the upstream provider (Meta, an SMS operator…), set per connected channel account: customer pays directly, SculptFlow pays, customer pays an external provider, or no provider usage fee. Two WhatsApp accounts can differ.
  3. SculptFlow usage billing: whether SculptFlow charges the clinic for the usage. By default only when SculptFlow pays the provider; an account can also carry a SculptFlow usage fee, or SculptFlow can absorb the cost.

Every usage is a billable event and is recorded (volumes, provider cost where known). Only what SculptFlow charges is rated, reserved, settled and paid, included credit first and then the prepaid wallet, through the ledger. There are no per-channel quotas. Everything is off while the Billing / Enabled setting is false (on by default; switch it on the admin portal's Configuration page).

Subscription to entitlements; channel account to provider billing responsibility; usage to billable event, which branches by who pays the provider 1 · Subscription · pays for access 2 + 3 · Usage · who pays the provider, and does SculptFlow charge Clinic the tenant Subscription active · past due · expired Plan price · period · credit Entitlements features + limits Feature checks send · campaigns · AI · channels Channel account e.g. one WhatsApp WABA Provider billing responsibility who pays the provider · per account Usage a message is sent Billable event what · how many · who pays · charged? SculptFlow pays provider (platform_funded): charged Rating rate cards Reservation hold funds Settle / release on outcome Credit → wallet + ledger Customer pays provider directly (customer_direct) Usage record · provider cost where known · no SculptFlow money Customer pays external provider (external_provider_direct) Usage record · no SculptFlow funding No provider usage fee (no_provider_usage_fee) Usage record · monetized through the subscription only
Top: what a clinic may do comes only from its subscription's plan; the plan's price is charged from the wallet and its included credit granted each period, through the ledger. Bottom: the channel says what the usage is, the connected account says who pays the provider. Only the blue branch moves SculptFlow money. The grey branches record usage without a reservation, wallet, included credit or ledger row; an admin can still turn on a SculptFlow usage fee for such an account, which then follows the blue branch with a fee rate.
  • Billable event, not message. A typed event (whatsapp_marketing_message, telegram_message, later sms_segment, voice_minute, ai_token…) with a quantity, plus who pays the provider and whether SculptFlow charges. New types need only rates, and only where SculptFlow charges.
  • Channel policies own channel rules (IChannelBillingPolicy: which event, how many units, when it counts); accounts own commercial arrangements (ProviderBillingService: account override → configured default per channel/provider → built-in default).
  • Usage records keep money and provider apart: charge_status (reserved, settled, released, failed, not_charged) is SculptFlow's money; provider_outcome (pending, billable, not_billable) is what happened at the provider.
  • Rates hold provider cost and client rate as independent numbers, optionally narrowed by country, operator, provider and responsibility, versioned by effective period; never edited, never backdated, so history is never re-priced. A customer-paid account is only ever charged a rate set for its own responsibility (a usage fee), never the platform rate by fallback.
  • Prepaid. Spendable = wallet + included credit − reserved. The wallet and credit only finance what the clinic owes SculptFlow. Only a settlement that costs more than its estimate can take the wallet below zero.
  • Correctness. Every money operation runs in one transaction holding the clinic's billing.accounts row lock, checks idempotency after taking the lock, and writes the ledger; unique keys, CHECKs, an exclusion constraint on rate versions and triggers that block ledger/rate/final-usage edits back it up. Cached balances must equal the ledger sums (reconciliation endpoint).
  • Postpaid later fits as a credit limit on the billing account (added to the spendable formula) plus invoices built from the ledger; nothing else has to change.

3. Frontend

Server-rendered ASP.NET Core Razor Pages with two layouts (Pages/Shared/_Layout.cshtml for signed-in staff, _AuthLayout.cshtml for sign-in and error pages), the vendored "Aurora" UI kit plus an app stylesheet, and one small vanilla JS file per interactive page. There is no bundler, no npm and no frontend framework. Razor runtime compilation is off, so .cshtml changes need a rebuild. How screens look is governed by docs/UI_GUIDE.md (§0.4 = how this app applies it).

3.1 Page map

Two data patterns coexist: server-rendered pages whose PageModel calls services directly (forms post back with OnPost* handlers), and JS-driven pages that render a shell and then call /api/** with the session cookie.

RouteSidebar labelPatternJSMain backend
/Account/Login, /Account/Register, /Account/CompleteGoogleSignup, /Account/Logout(anonymous)serverauth.jsSignInManager, ClinicRegistrationService, GoogleAuthController
/dashboardDashboardserverwhatsapp-health.jsDashboardService, WhatsAppHealthService
/inboxInboxJSinbox.js, inbox-voice.js/api/conversations/**, /api/messages/{id}/media, /api/whatsapp/templates, hub
/dashboard/leads, /dashboard/leads/{id}Interested Peopleserver (+ partial refresh)leads.jsDashboardService, LeadService
/dashboard/appointments, /dashboard/appointments/{id}AppointmentsJS calendar / server detailappointments-calendar.js/api/appointments/**, AppointmentService, hub AppointmentChanged
/Campaigns, /Campaigns/Create, /Campaigns/{id}Campaignsservercampaign-create.js, campaign-audience.js, multi-select.jsCampaignService, CampaignAudienceService, /api/campaigns/audience-preview*
/WhatsApp/TemplatesWhatsApp Templatesservertemplates-page.js, whatsapp-templates.jsWhatsAppTemplateService, hub WhatsAppTemplateUpdated
/WhatsApp/HealthWhatsApp Healthserver + livewhatsapp-health.jsWhatsAppHealthService, hub WhatsAppHealthUpdated
/Procedures, /Procedures/Edit/{id?}Proceduresserverprocedures-page.jsProcedureService
/KnowledgeBase, /KnowledgeBase/Edit/{id?}, /KnowledgeBase/Settings, /KnowledgeBase/Websites/{id}Knowledge Baseserverknowledge-page.jsKnowledgeService, KnowledgeSettingsService, WebsiteSourceService
/settings/clinic-infoClinic Infoserverclinic-info.jsClinic fields, AvailabilityService (weekly rules, exceptions, booking rules); AI assistant tab (?tab=ai, only when the plan includes ai_agent): InboxSettingsService switches for new and reopened conversations
/StaffStaffservernoneStaffService
/settings/integrationsMessaging Integrationsserver + JSmeta-connect.js/api/channel-integrations/**, TikTokIntegrationService
/settings/calendar-integrationsCalendar Integrationsserver + JSinline/calendar-oauth/*, /api/calendar-integrations/**
/settings/billingBilling (only while Billing:Enabled; the page answers 404 otherwise)server, read-onlynoneBillingQueryService: plan, renewal date, included credit, wallet, reserved, entitlements, this period's usage, recent transactions
/Status?code=…, /Error(anonymous)servernoneStatus: friendly 404/400 page via UseStatusCodePagesWithReExecute (not for /api, /hubs or static files), in the shell when signed in. Error: unhandled exceptions outside Development, always on _AuthLayout, with a short reference from the trace id.

Shared partials: _Sidebar, _Header, _ThemeScript (pre-paint theme), _Messages (success toast / error alert), _EmptyState, _NoClinic, _GoogleButton, _StatusHelp (status glossary overlay), _TimeViewSwitch (Clinic time / My time). C# view helpers in Common/Helpers: ChannelIconHelper, FilterLabelHelper, LocalTimeHelper, StatusBadgeHelper, UiTextHelper, UrlDisplayHelper, ViewerTimeZone; view models in Entities/Dtos/Ui.

3.2 JavaScript modules

Every file is an IIFE-style script loaded by its page; none depends on another except through globals noted below. Loaded by the layout: local-time.js (head) and aurora.js on every page; for signed-in staff also the SignalR client (@microsoft/signalr@8.0.0 from jsDelivr, once: in <head> on pages that set ViewData["SignalRFirst"] because their own scripts use it while loading, at the end otherwise), notifications.js and sidebar.js.

FileResponsibilitySignalR events
inbox.js (~800 lines)Conversation list, thread, composer, Take Over / Return to AI / Close / Reopen (a closed conversation shows Reopen instead of the ⋯ menu), template send when the 24h window is closed, unread badges, tab title count, mode-change dividers.NewMessage, MessageStatusUpdated, ConversationUpdated, ConversationModeChanged; joins conversation:{id}
appointments-calendar.jsMonth grid, create appointment dialog, status changes, Clinic/My time reload.AppointmentChanged
notifications.jsBell badge and dropdown over /api/notifications.NotificationCreated
aurora.js (vendored kit)Theme toggle, sidebar drawer / icon rail, user menu dropdown, toasts, styled confirm (data-au-confirm), submit busy state and top progress bar. Opt-in by data-au-* markup; no business logic.none
inbox-voice.jsLoaded before inbox.js. Turns a message of type audio into an audio player streaming GET /api/messages/{id}/media (preload none; a failed load shows "can't be played here"); inbox.js asks window.InboxVoice for the bubble body and renders everything else itself.none
sidebar.jsInbox unread count in the sidebar (sums unreadCount from GET /api/conversations).NewMessage, ConversationUpdated
whatsapp-templates.js / templates-page.jsLive template status list / presentation, preview bubble, new-template popup.WhatsAppTemplateUpdated
whatsapp-health.jsLive health fields on /WhatsApp/Health and the dashboard indicator.WhatsAppHealthUpdated
campaign-create.js, campaign-audience.js, multi-select.jsTemplate variables ("fill in the blanks"), live preview, audience picker mapped onto AudienceType + ManualSelection, matching-count preview, chip multiselect.none
meta-connect.jsFacebook JS SDK login for WhatsApp Embedded Signup and Facebook Page connect. Reads window.PS_META_CONFIG.none
local-time.jsRenders UTC instants in the viewer's zone, writes the sf-tz cookie used by server day math, handles data-clinic-tz and the sf-time-view event.none
clinic-info.js, knowledge-page.js, procedures-page.js, leads.js, auth.js, status-help.jsForm niceties, search/filter, unsaved-changes bar, password checks.none

3.3 CSS, theming and time display

  • Styles load as fonts.css → aurora.css → site.css. aurora.css / aurora.js are the vendored Aurora kit, shared with the admin portal and extracted from docs/UI_GUIDE.md's appendices (never edited by hand). site.css is the app layer: older token and class names mapped onto the kit's tokens (the page scripts still build markup with them), the brand calibration (teal, calm, AA) and per-screen styles.
  • Fonts are self-hosted in wwwroot/fonts: Inter and a Material Symbols subset holding only the icons in use, rebuilt with node docs/tools/icon-font.js after adding an icon.
  • Light and dark: the theme follows the OS until the user picks one; the choice is stored in localStorage['client.themeMode'] and applied as <html data-theme> by _ThemeScript before the first paint. The collapsed sidebar rail is localStorage['client.sidebarCollapsed'] (<html class="au-side-collapsed">). The old sf-theme / sf-sidebar keys are read once as a fallback.
  • Brand: wwwroot/logo.svg (also the favicon), teal (<html data-brand="teal">), dark sidebar. User-visible name is MySculptFlow.
  • Time rule: UTC in the DB; activity in viewer time; appointments in clinic time with a switch; availability clinic-local. Server-side day boundaries use the sf-tz cookie via ViewerTimeZone.

4. Backend

4.1 Layers and folders

Agreed code layout (standing rule in CLAUDE.md; the admin portal uses the same one). One top-level type per file, namespace = folder path. A request flows Controller or PageModel → Service → Repository; controllers and PageModels hold no logic and never touch the DbContext, and only repositories query the database. Services decide when to save through IUnitOfWork; billing money operations use IBillingUnitOfWork (their own context).

FolderContents
Program.csAll DI registration and the middleware pipeline (forwarded headers → exception/HSTS → https redirect → routing → request timing (RequestTimingMiddleware, §4.6) → rate limiter → authn → authz → static assets → pages, controllers, hub). / redirects to /dashboard.
Controllers/ClientClinic dashboard APIs for signed-in staff; DashboardApiController base resolves the clinic.
Controllers/AdminPlatform-admin APIs under /api/platform-admin/{domain} (BillingAdminController, AutomationAdminController, SettingsAdminController, CacheAdminController, and the portal's cross-clinic APIs ClinicsAdminController, ChannelsAdminController, StaffAdminController, LeadsAdminController, ContentAdminController, OverviewAdminController over Business/Services/PlatformAdmin and Persistence/Repositories/PlatformAdmin).
Controllers/IntegrationsMeta/Telegram/TikTok webhooks, n8n ingest and AI tool endpoints (AiController, MessagesController, *IngestController), calendar and TikTok OAuth callbacks.
Controllers/FiltersRequireIngestKeyAttribute, RequirePlatformAdminKeyAttribute.
Pages/Razor Pages and their PageModels (see §3.1).
Hubs/InboxHub.
Business/Services/<Feature>What controllers and pages call: Leads, Appointments (+ availability), Calendars, Campaigns, Channels (channel connect incl. Telegram), Clinics, Staff, Inbox (conversations, messages, WhatsApp send), Dashboard, Knowledge (documents, search, settings, website sources, benchmark), Notifications, Procedures, WhatsApp (templates, health), TikTok, Billing (plans, rate cards, subscriptions, queries, provider billing, ledger service, entitlements).
Business/Engines/<Feature>Backend logic: MetaWebhookParser → MetaWebhookProcessor → 8 handlers (Engines/WhatsApp/Handlers); Telegram webhook parser/processor; billing (MessageBillingService, channel billing policies, RateResolver/RateSelector); knowledge chunking and document text extraction; benchmark scorer; website scraping (processor, HTML extractor, robots.txt, SSRF guard).
Business/ManagersCurrentClinicContext, ClinicContext, EventLogger, InboxNotifier (SignalR).
Business/ProvidersWhatsApp/: the Meta send and template providers behind IWhatsAppProvider/IWhatsAppTemplateProvider (one implementation each; WhatsApp is Meta Cloud API only). Channels/: IChannelSender per channel. Media/: IVoiceMediaSource per channel, the only code that knows how a channel stores a voice note's audio and which token opens it (Telegram today).
Business/HttpClients/<Provider>Meta Graph, Telegram Bot API, Google/Outlook calendar, TikTok, OpenAI embeddings, n8n (AI trigger, calendar sync, benchmark generator), website fetch client.
Business/JobsBillingMaintenanceWorker, CampaignSenderWorker, WebsiteScrapeWorker + queue, KnowledgeBenchmarkRunWorker + queue.
Business/ContractsInterfaces of all of the above in the mirrored path (design notes live in their doc comments).
Entities/ModelsEF table classes.
Entities/Requests, Responses, DtosPer-feature input, output and internal shapes (provider payloads, list rows, results).
Common/Enums (enums and string vocabularies such as LeadStatus), Statics (EventTypes, billing event/entitlement keys, AI result instructions), Configs (options), Helpers (static: phone/URL/vector/time helpers, page display helpers, ConversationModeSync), Exceptions, Converters (lenient JSON for n8n), Extensions (BillingModule.AddBilling).
Persistence/Contexts/ApplicationDbContext (Fluent API mapping to snake_case tables, Identity tables renamed). Repositories/<Feature> + Contracts/<Feature>: one repository per area (Clinics, Users, Events, Leads, Procedures, Notifications, Dashboard, Inbox, Channels, WhatsApp, TikTok, Campaigns, Appointments, Calendars, Knowledge, Billing), registered in Common/Extensions/PersistenceModule. UnitOfWork: the request's save / transaction / "lost an insert race" boundary; unique and exclusion violations surface as DuplicateRecordException / OverlappingRecordException. Repositories/Billing: BillingUnitOfWorkFactory → BillingUnitOfWork, each money operation on its own context with the billing repositories (account lock, usage, ledger, plans, rate cards, subscriptions, channel-account settings). Helpers/DbErrors. Nothing outside Persistence/ references EF Core or Npgsql.
Business/MappersStatic entity → response mapping (LeadMapper, InboxMapper, AppointmentMapper, ProcedureMapper, NotificationMapper).

4.2 Service catalog

All scoped per request unless noted. Each interface file carries the authoritative behavioral notes.

ServiceOwnsNotable rules
CurrentClinicContext / ClinicContextClinic resolutionSession → clinic_users; or lookup by explicit id for trusted callers.
ClinicRegistrationServiceSign-upNew user + new clinic + membership + empty billing account in one transaction; external (Google) variant links the login. With billing on and Billing:SignupPlanCode set, the clinic then starts on that plan with its first period free.
LeadServiceLeadsDedup by (clinic, external_lead_id); GetOrCreateByPhoneAsync for WhatsApp; GetOrCreateByExternalIdAsync for Telegram (no phone); AI-only UpdateContextAsync cannot touch identity fields.
InboxSettingsServiceInbox settings, who answersThe clinic's Inbox settings (crm.inbox_settings, clinic-wide row; no row = defaults) combined with the plan's ai_agent entitlement. Today: the AI bot switches ai_bot_new_conversations, ai_bot_reopened_conversations (default on); the first save creates the row. The bot answers a new conversation only if both allow it, and may keep a patient-reopened one only if both allow it; otherwise the conversation is in human mode (same state as a handoff), so n8n is never called and nothing AI is charged. Read from the cached settings row, clinic and entitlements. Edited on Clinic Info → AI assistant and through /api/platform-admin/clinics/{id}/inbox-settings.
ConversationServiceConversationsOne conversation per lead per channel; take over, handoff (AI), return to AI, close, mark read. Close sets status=closed; ReopenAsync (staff) and any inbound patient message (MessageService.IngestAsync, AddMessageAsync) put it back to active through ConversationStatusSync.ReopenIfNotActive, log conversation_reopened and broadcast ConversationUpdated; a human-mode conversation stays human, and a patient reopening an AI-mode one moves it to human when InboxSettingsService says the bot doesn't answer reopened conversations. New conversations start in the mode InboxSettingsService.InitialModeAsync gives (human when the plan lacks ai_agent or the clinic turned the bot off for new conversations). Return to AI is refused (422) without ai_agent. Mode changes go through ConversationModeSync.Apply which keeps ai_enabled/human_takeover in sync and inserts a system divider message.
MessageServiceAll writes to messagesSendAsync (staff, flips to human), SendAiReplyAsync (re-checks mode, never flips), SendTemplateAsync, SendCampaignTemplateAsync, IngestAsync (customer/echo/status; updates 24h window only on genuine customer inbound; campaign reply attribution; NEW_LEAD and CAMPAIGN_REPLY notifications; a customer message in an AI-mode conversation moves it to human, logging human_handoff from system, when the plan lacks ai_agent or it reopened the conversation and the clinic sends reopened conversations to staff). Text sends routed to IChannelSender by conversation.Channel. Every send first checks the subscription (IEntitlementService) and reserves usage (IMessageBillingService) under the message id it is about to create; a provider failure releases it; status updates are passed to billing to settle or release.
WhatsAppService / WhatsAppChannelSenderWhatsApp sendOnly caller of WhatsApp send; free text only inside the 24h window; templates any time. Checks the clinic's WhatsApp row is connected and ready, then calls IWhatsAppProvider (Meta).
IWhatsAppProvider: MetaWhatsAppProviderMeta Cloud API sendSendText / SendTemplate through Graph /{phone-number-id}/messages; returns Meta's message id.
MessageMediaServiceInbox playbackGetAudioAsync: finds the message in the caller's clinic (KeyNotFound otherwise; ArgumentException when it isn't audio), then asks the IVoiceMediaSource for its channel. Nothing is stored: the file is fetched from the provider on each request (the browser caches it privately). Size limit Voice:MaxBytes.
AppointmentServiceAppointmentsBooking/reschedule/cancel under pg_advisory_xact_lock(hashtext(clinicId)); one upcoming appointment per lead; ScheduleAsync decides create vs move vs CONFIRMATION_REQUIRED; month calendar grouped by display zone; fires calendar sync, notifications and AppointmentChanged after commit.
AvailabilityServiceSlotsWeekly rules + date exceptions + duration + buffer + minimum notice + horizon + existing appointments, in the clinic's timezone. No open weekday means no slots (never 24/7).
ProcedureService, ProcedureBookingServiceCatalog, procedure dealsAI only sees active procedures.
CampaignService, CampaignAudienceServiceCampaignsAudience snapshot at creation; contactability exclusions (opt-out, missing phone); sends in batches of 20 via SendAsync/ProcessBatchAsync. Needs the campaigns entitlement. Running out of prepaid balance (or losing the plan) stops the batch and leaves the rest queued, so the campaign resumes after a top-up instead of failing every recipient.
WhatsAppTemplateServiceTemplate lifecycleCreate → submit to Meta for review through IWhatsAppTemplateProvider (MetaWhatsAppTemplateProvider); sync status; retry submit; apply Meta events by template id then name+language.
WhatsAppHealthServiceAccount/phone healthMaps Meta vocab to a health level, appends history, INTEGRATION_UNHEALTHY on transition into a problem state.
ChannelIntegrationServiceWhatsApp/Facebook connectionsEmbedded Signup code exchange, WABA and phone lookup, phone registration with generated PIN; Facebook Page connect. Every connect path (also Telegram) first checks the plan's max_channel_connections / max_whatsapp_numbers.
TelegramIntegrationServiceTelegram bot connectiongetMe, setWebhook with a random secret, refresh, disconnect.
KnowledgeService, KnowledgeChunkingService, KnowledgeSettingsService, DocumentTextExtractor (singleton)KB contentEmbeddings computed before the transaction, chunk swap is transactional; uploads extracted with PdfPig / OpenXml, file not stored.
KnowledgeSearchService, OpenAiEmbeddingServiceRetrievalExact cosine scan (1 - (embedding <=> q)) over the clinic's active chunks, top-K then minimum similarity.
WebsiteSourceService, WebsiteScrapeProcessorWebsite sourcesSee §5.8.
KnowledgeBenchmarkServiceRetrieval benchmarkCases, async generations via n8n, runs, scoring (Top-1/3/5, MRR), stale detection.
NotificationServiceBellCreated only after a confirmed event; never throws.
CalendarIntegrationServiceGoogle/Outlook connection + syncOwns OAuth tokens and refresh; n8n only executes the event API call.
TikTokIntegrationServiceTikTok Login KitAccount connection only; refreshes on page load.
DashboardService, StaffServiceRead modelsFunnel summary, attention list, lead/appointment tables, procedure stats; staff list.
EventLoggerevents tableAdds rows to the caller's unit of work; saved together with the business change.
BillingServiceUsage records + SculptFlow moneyReserve: charged usage is rated, checked against spendable and held; uncharged usage (the event says SculptFlow doesn't charge it) is just recorded as not_charged with the provider cost where a rate knows it — no rate required, no hold. Settle / Release: charged usage is charged (credit then wallet, ledger) or its hold returned; uncharged usage only gets its provider outcome. Charge (record+settle), Refund (once, back to source), TopUp, Adjust (reason required). Own DbContext per operation; READ COMMITTED transaction holding the clinic's billing.accounts row lock; idempotent by key; refused attempts are kept as failed usage rows under a suffixed key.
ProviderBillingServiceWho pays the providerResolves a connected channel account's arrangement: its override (billing.channel_account_settings, only while on the same provider) → Billing:ProviderBilling:Defaults → built-in per-channel defaults; SculptFlow usage billing defaults to on only for platform_funded. Lists accounts, sets/resets overrides (reason required, audited as provider_billing_changed).
SubscriptionServicePlan lifecycleStart/change plan (charge from wallet unless waived, credit expired + granted), renew when due, past due → expired after grace, cancel now or at period end, resume. Ledger keys derived from the period, so a repeated run is a no-op. Audit rows in events (subscription_*).
EntitlementService"May this clinic do X?"Reads the clinic's subscription + plan entitlements through ICacheManager (CacheKeys.Entitlements, every call); EnsureCanSendMessages, EnsureFeature, EnsureWithinLimit, EnsureCanConnectChannel. Billing off = unrestricted.
MessageBillingService + IChannelBillingPolicyMessaging ↔ billing bridgeAsks the channel's policy what an outbound message is, asks ProviderBillingService who pays and whether SculptFlow charges for the clinic's account, then reserves (charged) or records (uncharged) under {channel}:message:{messageId}; releases on send failure, settles/releases on status callbacks, and resolves records older than Billing:ReservationTimeoutHours from the message row. A failure to record uncharged usage never blocks the message; it never throws after the send (the worker recovers).
PlanService, RateCardService, BillingQueryServiceBilling admin + readsPlan catalog and entitlement sets; rate cards and rate versions (close + add, never edit, never backdate); clinic summary, usage, ledger, reconciliation, quotes, revenue/cost/margin report.

4.3 API surface

Tags: session logged-in staff, ingest X-Ingest-Key, open provider-authenticated or anonymous.

Dashboard APIs (session)

BaseEndpoints
/api/conversationsGET list · POST create · GET {id} · GET {id}/messages · POST {id}/messages · POST {id}/messages/send (staff branch) · POST {id}/messages/send-template · POST {id}/read · POST {id}/take-over · POST {id}/return-to-ai · POST {id}/close · POST {id}/reopen
/api/messages/{id}/mediaGET the audio of a voice note (signed-in staff, own clinic; Range supported, Cache-Control: private). 400 not an audio message, 404 unknown or no longer available, 422 channel not connected or file over Voice:MaxBytes, 502 provider failure.
/api/leadsGET · GET {id} · PATCH {id} · POST {id}/status · POST create needs the ingest key (body clinic id)
/api/appointmentsGET · POST · GET {id} · PATCH {id}/status · GET available · GET calendar
/api/proceduresGET · GET {id} · POST · PUT {id} · POST {id}/active
/api/procedure-bookingsGET · POST · PATCH {id}
/api/campaignsGET · GET {id} · POST · GET audience-preview · GET audience-preview/leads · POST {id}/schedule · POST {id}/send · POST {id}/process-batch · POST {id}/stop · POST {id}/cancel
/api/whatsapp/templatesGET · GET {id} · POST · POST {id}/sync
/api/whatsapp/healthGET · GET events
/api/channel-integrationsGET · PUT · POST whatsapp/connect · POST facebook/connect · POST telegram/connect · POST telegram/refresh · POST {channel}/disconnect
/api/calendar-integrationsGET · POST {provider}/refresh-calendars · POST {provider}/select-calendar · POST {provider}/sync-enabled · POST {provider}/disconnect
/api/knowledgeGET · GET {id} · POST · POST upload (multipart) · PUT {id} · POST {id}/active · DELETE {id} · GET/PUT settings
/api/knowledge/websitesGET · POST · GET {id} · GET {id}/pages · POST {id}/rescrape · POST {id}/active · DELETE {id}
/api/notificationsGET · GET unread-count · POST {id}/read · POST read-all
/api/dashboardGET summary · GET attention · GET leads · GET appointments · GET procedures
/api/staffGET
/api/billingGET summary · GET usage · GET transactions (read-only, clinic-safe: no providers, costs or margins)

Billing admin API (platform admin key; used by the SculptFlowAdmin portal's Billing pages)

Base /api/platform-admin/billingEndpoints
PlansGET plans · GET plans/{code} · POST plans · PUT plans/{code} · PUT plans/{code}/entitlements · GET entitlements (catalog)
Rate cardsGET rate-cards · POST rate-cards (clinicId = custom pricing) · PUT rate-cards/{code} · GET rate-cards/{code}/rates?history= · POST rate-cards/{code}/rates (new version) · POST rates/{id}/close
ClinicsGET accounts · GET clinics/{id} (overview with costs + reconciliation) · POST clinics/{id}/subscription* · …/subscription/cancel · …/subscription/resume · …/subscription/renew · POST clinics/{id}/wallet/top-ups* · …/wallet/adjustments* · GET clinics/{id}/usage · POST clinics/{id}/usage/{usageId}/refund · GET clinics/{id}/ledger · GET clinics/{id}/reconciliation · GET clinics/{id}/quote
Provider billingGET provider-billing (modes + defaults) · GET clinics/{id}/channel-accounts · PUT channel-accounts/{id}/provider-billing (providerBilling, omniUsageBilling, reason) · POST channel-accounts/{id}/provider-billing/reset
ReportingGET report?from=&to=: usage by clinic/channel/event type/who pays; SculptFlow usage revenue, provider cost paid by SculptFlow vs paid externally, margin, subscription revenue, top-ups

* requires an Idempotency-Key header; repeating a request with the same key returns the first result.

Automation cleanup API (platform admin key; used by the SculptFlowAutomation tester)

The separate SculptFlowAutomation app (own folder and repo next to this one) tests the system end to end: each run registers a throwaway clinic through /Account/Register, exercises the APIs and pages as that clinic, then deletes it here. AutomationAdminController only acts on an automation clinic: name starts with [Automation] AND every user's email ends with @sculptflow-automation.test; anything else gets 409 and nothing changes.

Base /api/platform-admin/automationEndpoints
ClinicsGET clinics (leftover automation clinics, oldest first) · DELETE clinics/{id} (204; deletes the clinic row, so all its data goes through the schema's ON DELETE CASCADE keys, plus its identity users, in one transaction)

Configuration API (platform admin key; used by the SculptFlowAdmin portal's Configuration page)

SettingsAdminController → SettingsService. Only settings declared in ConfigDefaults, only values that fit their type and range (400); X-Admin-Actor is stored as updated_by.

Base /api/platform-admin/settingsEndpoints
SettingsGET (every declared setting: type, range, default, stored value, value in use) · PUT {section}/{key} {value, note} (store a value; the app uses it at once) · DELETE {section}/{key} (204; back to the default; 404 when nothing was stored)

Cache (CacheAdminController): the in-process cache behind ICacheManager. Keys contain :, so they go in the query string. Clear-all also reloads the settings snapshot. Per instance: with more than one app instance a remove reaches only the instance that answers.

Base /api/platform-admin/cacheEndpoints
CacheGET keys?prefix= (live keys: expiry, size) · GET key?key= (one value as JSON; 404 when not cached) · DELETE key?key= (204; 404 when not cached) · DELETE keys?prefix= ({removed}) · DELETE (clear all + reload settings; {removed})

API timings (ApiTimingsAdminController, ApiTimingAdminService): read-only per-route summary of activity.api_route_stats (§4.6) for the portal's API timings page. Totals cover every route; routes holds the first take after sorting (always descending). p95Ms is the request-weighted average of the per-minute p95s (exact percentiles can't be combined); avgMs and maxMs are exact.

Base /api/platform-admin/api-timingsEndpoints
API timingsGET ?hours=24&sort=slowest&take=50 → {from, to, hours, sort, enabled, slowRequestMs, retentionDays, totalRequests, totalErrors, totalSlowRequests, routeCount, routes: [{method, route, requests, errors, clientErrors, slowRequests, avgMs, p95Ms, maxMs, totalSeconds, lastSeenAt}]}. hours 1–8760, take 1–500; sort = slowest (avg) · p95 · max · calls · errors (5xx) · slow · time (total time); anything else 400

Error log (AppLogsAdminController, AppLogAdminService): read-only activity.app_logs (§4.6) for the portal's Errors page and Overview tile. level is a minimum: warning (default, everything) · error (errors + criticals) · critical; hours 1–8760 (default 24). Occurrence counts sum repeat_count. Unknown level or sort is 400, an unknown id 404.

Base /api/platform-admin/logsEndpoints
ProblemsGET groups?hours&level&clinicId&search&sort=recent|count&take=100 (take 1–500) → {from, to, hours, level, sort, enabled, minimumLevel, warningRetentionDays, errorRetentionDays, totalOccurrences, groupCount, groups: [{fingerprint, level, category, messageTemplate, latestMessage, exceptionType, occurrences, firstSeenAt, lastSeenAt, latestId, latestClinicId}]}
RowsGET ?hours&level&clinicId&search&fingerprint&traceId&page=1&pageSize=50 (pageSize 1–200), newest first → {from, to, page, pageSize, total, items: [{id, occurredAt, level, category, message, exceptionType, fingerprint, repeatCount, traceId, clinicId, conversationId, messageId, method, route}]}. search matches message, category or exception type (case-insensitive, literal)
One rowGET {id} → the row plus messageTemplate, exceptionMessage, stackTrace, instance, properties (name → text)
SummaryGET summary?hours=24 → {hours, from, enabled, warnings, errors, criticals, lastErrorAt}

Message trace (MessageTracesAdminController, MessageTraceAdminService, reads in MessageTraceAdminRepository): read-only, for the portal's Message traces pages. A timeline merges the trace's steps (§4.6), the messages created in it (crm.messages.trace_id) or named by its steps with their delivery statuses, their charges (billing.usage_records), the conversations' activity.events from a minute before the trace to 10 minutes after it, and its saved logs. Unknown traces 404, an empty search 400.

Base /api/platform-admin/message-tracesEndpoints
ListGET ?hours=24&clinicId&conversationId&failedOnly&page=1&pageSize=50 (pageSize 1–200), latest activity first → {from, to, page, pageSize, total, enabled, retentionDays, failedRetentionDays, items: [{traceId, startedAt, endedAt, steps, failedSteps, errors, clinicId, conversationId, channel, firstStep, failedStep, failedDetail}]}. failedOnly = a failed step or a saved error/critical
TimelineGET {traceId} and GET by-message/{messageId} → {traceId, startedAt, endedAt, clinicId, conversationIds, messageIds, hasFailure, entries: [{at, kind (step|message|status|billing|event|error|warning), name, outcome (ok|skipped|failed), summary, durationMs, clinicId, conversationId, messageId, n8nExecutionId, logId, detail}]}
SearchGET resolve?q= (trace id, message id or provider message id) → {traceId}
SummaryGET summary?hours=24 → {hours, from, enabled, traces, failedTraces, lastFailedAt}

Admin portal APIs: everything the SculptFlowAdmin portal reads or changes about clinics goes through these (the portal has no access to the main tables). Reads come from the cross-clinic repositories in Persistence/Repositories/PlatformAdmin; writes go through the same services a clinic uses (disconnects at the provider, calendar sync, plan limits, cache clearing). Writes answer {clinicId} so the portal can file its audit entry. Details never include secrets (only hasAccessToken). Lists take page and answer {items, total, page, pageSize}.

Base /api/platform-admin/…Endpoints
clinicsGET ?search&active&page · GET options · GET {id} · GET {id}/counts · PUT {id} (details, country, time zone) · PUT {id}/active {active} · GET {id}/inbox-settings {planIncludesAiBot, aiBotNewConversations, aiBotReopenedConversations, aiBotAnswersNew, aiBotAnswersReopened} · PUT {id}/inbox-settings {aiBotNewConversations, aiBotReopenedConversations} (through InboxSettingsService; saved even when the plan lacks the bot)
channelsGET ?clinicId&channel&problemsOnly · GET {id} · GET {id}/health-events · POST {id}/disconnect · GET calendars · POST calendars/{id}/disconnect · PUT calendars/{id}/sync {value} · GET tiktok · POST tiktok/{id}/disconnect
staffGET ?clinicId&search&page · GET {userId} · PUT {userId}/membership-active {active} · PUT {userId}/locked {value} (locking signs the user out) · PUT {userId}/password {password} (Identity password rules) · PUT {userId}/email-confirmed {value}
leadsGET ?clinicId&status&search&page · GET {id} · GET {id}/events · PUT {id} (status, qualification, opt-in, notes) · GET conversations · GET conversations/{id} · GET conversations/{id}/messages?take · PUT conversations/{id}/mode (take over / return to AI) · PUT conversations/{id}/status · GET messages · GET appointments · PUT appointments/{id}/status (calendar sync runs)
contentGET campaigns · GET campaigns/{id} · GET campaigns/{id}/recipients · POST campaigns/{id}/cancel · GET templates · GET procedures · PUT procedures/{id}/active · GET knowledge · GET knowledge/{id} · PUT knowledge/{id}/active · GET knowledge/websites · GET/PUT knowledge/settings/{clinicId} {topK, minimumSimilarity}
overviewGET totals · GET daily-messages?days · GET problems · GET recent-clinics?take · GET events?clinicId&eventType&page · GET event-types
knowledge-benchmark/clinics/{clinicId}The Knowledge Retrieval Benchmark, one clinic at a time (KnowledgeBenchmarkAdminController → KnowledgeBenchmarkAdminService, which checks the clinic and delegates to KnowledgeBenchmarkService; the portal's Search benchmark page). GET dashboard · cases (GET ?view&generationId&skip&take, GET {id}, POST, PUT {id}, POST {id}/review, DELETE {id} 204, POST cases/generate 202 while n8n works) · generations (GET, GET {id}, POST {id}/cancel, 422 if it already finished) · GET source-documents, GET source-documents/{id}/chunks · runs (POST 202, GET, GET {id}, GET {id}/results?classification, GET {id}/results/{resultId}, GET {id}/generations). Writes answer the changed record, not {clinicId}: the clinic is in the route. 404 for an unknown clinic or a record that isn't that clinic's; 409 run/generation already in progress; 503/502 generator not configured/failed.

AI tool surface for n8n (ingest)

AI toolEndpointNotes
search_clinic_knowledgePOST /api/ai/knowledge/searchBody {clinicId, query, limit}. Returns chunks, never an answer. Information only.
get_clinic_infoGET /api/ai/clinic-info?clinicId=Free-text clinic fields. Partly redundant with the KB, kept for now.
get_proceduresGET /api/ai/procedures?clinicId=Active procedures only.
get_lead_context / update_leadGET / PATCH /api/ai/leads/{leadId}?clinicId=Update cannot change name/phone/email.
get_available_slotsGET /api/ai/appointments/availableSlots plus the lead's booking context (CanCreateNewBooking, upcoming appointments).
schedule_consultationPOST /api/ai/appointments/scheduleSingle scheduling mutation; backend chooses create / move / CONFIRMATION_REQUIRED. Structured outcome codes with AI instructions (AiResultInstructions).
cancel_consultationPOST /api/ai/appointments/cancelStructured outcome.
get_my_appointmentsGET /api/ai/appointments/upcomingLead's upcoming booked/confirmed, clinic-local wording.
(legacy) book_consultation, reschedule_consultationPOST /api/ai/appointments/book, /rescheduleSuperseded by schedule; still live.
handoff_to_humanPOST /api/ai/conversations/{id}/handoff?clinicId=Body {reason}. HANDOFF notification.
AI replyPOST /api/conversations/{id}/messages/send?clinicId=Body {content, sender:"ai"}. 409 conversation_in_human_mode if a human took over.
test onlyGET /api/ai/test/codes, POST /api/ai/test/schedule, POST /api/ai/test/cancelCanned outcomes, no DB access.

Other server-to-server and provider endpoints

EndpointAuthPurpose
GET/POST /api/integrations/whatsapp/webhookopenMeta handshake and every Meta delivery (§5.1); POSTs need a valid X-Hub-Signature-256 (Meta:AppSecret).
POST /api/integrations/telegram/webhook/{connectionId}secret headerTelegram updates (§5.4).
GET/POST /api/integrations/tiktok/webhookopenPlaceholder: reachability; POSTs need a valid TikTok-Signature (HMAC-SHA256 of t.body with TikTok:ClientSecret, 5-minute window); logs only the size.
POST /api/messages/ingestingestLegacy normalized ingest from the n8n-relay era (customer, echo, AI-sent, status). Same MessageService.IngestAsync.
POST /api/integrations/whatsapp/templates/events, /health/eventsingestPre-normalized template/health events (tooling, tests). Same services as the webhook handlers.
POST /api/calendar-integrations/sync-callbackingestn8n calendar sync result (§5.7).
POST /api/knowledge/benchmark/generations/{id}/questionsingestn8n benchmark question callback (§5.9).
GET /calendar-oauth/{provider}/connect, /callbacksessionGoogle/Outlook OAuth.
GET /tiktok-oauth/connect, /callbacksessionTikTok Login Kit OAuth.
POST /auth/google/start, GET /auth/google/callbackanonymousContinue with Google (sign in or sign up).

4.4 SignalR

One hub, InboxHub at /hubs/inbox, [Authorize]. Server-to-client only, all sent to clinic:{id} through IInboxNotifier. Client methods: JoinConversation(id) (ownership checked), LeaveConversation(id).

EventPayloadSent when
NewMessageconversationId, messageId, leadId, direction, senderType, origin, createdAtAny message persisted
MessageStatusUpdatedconversationId, messageId, deliveryStatus, updatedAtMeta status webhook
ConversationUpdatedconversationIdRead state, status (close / reopen) or conversation fields change
ConversationModeChangedconversationId, modeai ↔ human
AppointmentChangedappointmentId, change (created | rescheduled | canceled | status_changed)After commit
NotificationCreatedNotificationResponseNew bell item
WhatsAppTemplateUpdatedtemplateId, name, status, rejectionReason, updatedAtTemplate event or sync
WhatsAppHealthUpdatedconnectionId, healthLevel, account/review/quality/phone status, problemMessage, updatedAtHealth event

4.5 Background workers

Hosted serviceQueueUnit of workRecovery on startup
WebsiteScrapeWorkerWebsiteScrapeQueue (unbounded Channel<Guid>, singleton)One knowledge_website_scrape_runs row, one at a time, own DI scopecrawling runs → failed ("interrupted"); pending runs re-queued
KnowledgeBenchmarkRunWorkerKnowledgeBenchmarkRunQueueOne knowledge_retrieval_benchmark_runs rowrunning → failed; pending re-queued
BillingMaintenanceWorkertimer, every Billing:MaintenanceIntervalMinutes (5); idle while billing is offRenew / past due / expire / end-cancelled subscriptions whose period ended; resolve reservations older than Billing:ReservationTimeoutHours (settle if the message was delivered, else release). One transaction per clinic.Nothing to recover: state is in the DB and every step is idempotent
CampaignSenderWorkertimer, every Campaigns:SenderIntervalSeconds (60)Starts every scheduled campaign whose scheduled_at has passed and sends the next batch (Campaigns:SendBatchSize, 20) of every running one, active clinics only. One scope per campaign; ProcessBatchAsync holds a per-campaign session advisory lock (pg_try_advisory_lock) so the Send button, the worker and a second instance never send the same campaign at once, and re-reads the status before every message so Stop/Cancel apply mid-batch. Out of credit or plan: recipients stay queued and the next run retries.Nothing to recover: queued recipients are picked up by the next run
ConfigRefreshJobtimer, every 60 sRe-reads config.settings into IConfigManager (§9)Loads it at startup; defaults until then
ApiTimingFlushJobtimer, on every whole minuteDrains IApiTimingCollector (in memory) into one activity.api_route_stats row per method + route; hourly deletes rows older than Monitoring:RetentionDays (30). A failed write drops that minute (logged), never retried.Nothing to recover; flushes the last partial minute on shutdown (a crash loses at most one minute)
AppLogFlushJobtimer, every 5 sCopies the ErrorLog settings into IAppLogCollector, then writes the captured warnings/errors to activity.app_logs in batches of 500; hourly deletes warnings past ErrorLog:WarningRetentionDays (14) and errors/criticals past ErrorLog:ErrorRetentionDays (90). Runs inside the collector's suppression scope, so its own failures (and EF's) only reach the console. A failed batch is put back and retried.Flushes what is left on shutdown; a crash loses at most the last few seconds of entries
MessageTraceFlushJobtimer, every 5 sWrites the steps IMessageTracer recorded to activity.message_trace_steps in batches of 500; hourly deletes steps older than MessageTrace:RetentionDays (14), keeping traces with a failed step until MessageTrace:FailedRetentionDays (60). A failed batch is put back and retried; steps dropped because the 10,000-step buffer was full are logged.Flushes what is left on shutdown

The billing worker and the campaign sender are the scheduled business jobs (the config refresh only reloads settings). The campaign sender is what makes "Schedule" on the Create Campaign page actually send, and it finishes a running campaign after the dashboard sent its first batch.

4.6 Cross-cutting concerns

  • Audit/analytics: events rows via IEventLogger, typed by EventTypes constants (lead_created, consultation_booked, human_handoff, ai_message_sent, conversation_closed, conversation_reopened, whatsapp_* …).
  • Notifications: types NEW_LEAD, HANDOFF, APPOINTMENT_BOOKED / RESCHEDULED / CANCELLED, CAMPAIGN_REPLY, OUTBOUND_MESSAGE_FAILED, INTEGRATION_UNHEALTHY; each row precomputes a link.
  • Errors: one exception filter, Controllers/Filters/ApiErrorsAttribute ([ApiErrors] on DashboardApiController, ConversationsController, the platform-admin controllers and the n8n/AI controllers), turns service exceptions into { "error": message }: ArgumentException → 400, KeyNotFoundException → 404, duplicate/overlapping record, closed WhatsApp window, benchmark already running → 409, InvalidOperationException → 422, channel send / Meta Graph / benchmark generator failure → 502, generator not configured → 503; anything else stays 500. Controllers catch only where a response must differ (AI tool results for n8n, the human-mode conflict body, OAuth redirects, the knowledge "embeddings not configured" 503). Provider webhooks don't use it, so they keep answering 500 and the provider retries.
  • API timings: Middleware/RequestTimingMiddleware (after routing) times every request by method + matched route template (never the raw path; no match = (unmatched); static files, /hubs and the status-page re-execute are skipped) into the singleton IApiTimingCollector: counts, 5xx / 4xx / slow counts, total, max and a bounded sample (2,000 per route per minute) for the p95. No database write per request; ApiTimingFlushJob writes the minute (§4.5). A request at or over Monitoring:SlowRequestMs (1000) logs a warning Slow request: {Method} {Route} took {ElapsedMs} ms. Monitoring:Enabled switches it all off. Each app instance writes its own rows; readers sum them. Read through /api/platform-admin/api-timings (§4.3).
  • Error log: Business/Providers/Logging/DatabaseLoggerProvider is registered as an extra ILoggerProvider, so every warning, error and critical any ILogger writes (including the framework's "unhandled exception" log) is also queued in the singleton IAppLogCollector; no database write on the caller's path, and a logging failure never reaches the caller. Each entry keeps the rendered message, template, exception (type, message, full text capped at 8,000 chars), a fingerprint (category + template + exception type + top stack frame) that groups one problem, the W3C trace id (Activity.Current), method + route template (never the raw path), clinic/conversation/message ids found in the logged values or scopes, and the other values as properties with names containing token/secret/password/authorization/apikey/signature/cookie/credential masked. Per fingerprint at most ErrorLog:MaxPerFingerprintPerMinute (20) rows per minute; the rest of that minute becomes one row with repeat_count. Up to 5,000 entries wait in memory; beyond that they are dropped and one row says how many. ErrorLog:Enabled, MinimumLevel (Warning) and ExcludedCategories are copied in by AppLogFlushJob (§4.5) every flush, because the logging path can't depend on ConfigManager (it logs itself). [ApiErrors] now logs the 502/503 answers it maps (provider failures) as errors so they reach the log; 4xx answers are not logged. Read by /api/platform-admin/logs (§4.6) and the portal's Errors page.
  • Message trace: the singleton IMessageTracer records each step a message takes under the request's W3C trace id (Activity.Current; TraceContext.StartNew gives each campaign recipient's send its own trace). Steps (Common/Statics/TraceSteps): webhook.received / rejected (signature, bad JSON, unknown Telegram connection or secret) / clinic_unresolved / ignored (MetaWebhookProcessor, WhatsAppWebhookController, TelegramWebhookProcessor; a status-only Meta batch is not a step); message.saved / deduplicated, conversation.reopened / mode_changed (with reason), inbox.notified, bot.skipped (why: conversation_with_staff, message_type_not_supported, plan_without_ai_agent, reopened_to_staff, n8n_not_configured) (MessageService.IngestAsync); bot.triggered / trigger_failed (AiTriggerNotifier, which also puts traceparent in the payload); bot.tool_called for every /api/ai/* call ([TraceBotTool] on AiController); send.requested / blocked (billing or plan) / rejected_mode / provider_accepted / provider_failed with timings (MessageService send paths). Every message row saved gets trace_id. Delivery statuses, charges and business events are not steps; the trace view reads them. Details carry ids, codes and reasons only, never message text or phones. X-N8n-Execution-Id on n8n's calls is stored. MessageTrace:Enabled switches it off. Read by /api/platform-admin/message-traces (§4.6).
  • Billing logs: structured Billing: log lines for reservations, settlements, releases, missing rates, insufficient balance, duplicates, renewals and wallet adjustments; no phone numbers or message bodies.
  • HTTP clients: typed clients via AddHttpClient. Telegram and the benchmark generator use RemoveAllLoggers() because their URLs carry secrets. The crawler client disables redirects, cookies and proxy, and routes every connect through SsrfGuard.ConnectAsync.
  • Testing: PlasticSurgery.Tests (xUnit) covers billing: pure rules (rating, phone country, WhatsApp policy, entitlements) and database tests that need SCULPTFLOW_TEST_DB pointing at a throwaway local PostgreSQL (each run creates and drops its own database from schema.sql; without it those tests are skipped; remote hosts are refused). Nothing else is covered by automated tests yet. AiTestController gives n8n canned outcomes.

5. Key flows (low-level)

5.1 Inbound WhatsApp message to AI reply

  1. Meta posts raw JSON to POST /api/integrations/whatsapp/webhook.
  2. MetaWebhookParser splits it into ParsedMetaEvents (customer message, business-app echo, status, template, health, history, app-state, unknown). Interactive/button replies become clean text plus SelectedValue.
  3. MetaWebhookProcessor resolves the clinic per event from channel_integrations (phone_number_id, else WABA id) and dispatches to the handler.
  4. CustomerMessageHandler → LeadService.GetOrCreateByPhoneAsync → conversation get-or-create → MessageService.IngestAsync: idempotent on (clinic, channel, external_message_id), stores the message, opens the 24h window, unread, campaign reply attribution, events, notifications, NewMessage.
  5. The conversation's mode decides who answers: a new conversation starts in human when the plan lacks ai_agent or the clinic turned the bot off for new conversations; a patient reopening a closed AI conversation moves it to human when the clinic sends reopened conversations to staff (or the plan lacks ai_agent). See InboxSettingsService.
  6. If the event is AI-eligible (customer message, conversation mode ai, not a duplicate) the controller calls AiTriggerNotifier → N8n:AiWebhookUrl with {clinicId, conversationId, leadId, messageId, channel, messageType, messageText, selectedValue, traceparent}. Never throws.
  7. Meta always gets HTTP 200.
  8. n8n AI agent calls /api/ai/* tools, then POST /api/conversations/{id}/messages/send?clinicId= with sender:"ai".
  9. MessageService.SendAiReplyAsync re-reads the mode; if a human took over it returns 409 and nothing is sent. Otherwise WhatsAppChannelSender sends through the Graph API and the message is stored with origin=ai.

5.2 Staff reply, echo and status

  • Staff reply from the Inbox: POST …/messages/send (session) → MessageService.SendAsync → channel sender (throws before persisting if it cannot send; WhatsApp requires an open 24h window) → store origin=dashboard → mode flips to human.
  • Closed window: 409; the Inbox offers send-template with an approved template.
  • Coexistence echo: a reply typed in the WhatsApp Business phone app arrives as a BusinessAppEcho, is stored with origin=whatsapp_business_app, flips the conversation to human, and never triggers AI.
  • Delivery status: MessageStatusHandler updates delivery_status and the matching delivered_at/read_at/failed_at, mirrors campaign recipient status, raises OUTBOUND_MESSAGE_FAILED on failure, broadcasts MessageStatusUpdated.

5.3 Conversation modes

mode ∈ {ai, human, approval}. Switches to human: staff Take Over, a staff message or template, a business-app echo, the AI's handoff_to_human, a campaign template send, a customer message while the plan lacks ai_agent, a patient reopening a conversation when the clinic's reopened switch is off. A new conversation starts in human when the plan or the clinic's new-conversation switch says the bot doesn't answer. Only Return to AI switches back (refused without ai_agent; the Inbox hides the button). approval exists in the schema but is never set automatically.

5.4 Telegram

  1. Staff paste a BotFather token on Messaging Integrations → POST /api/channel-integrations/telegram/connect → getMe, then setWebhook to {App:PublicBaseUrl}/api/integrations/telegram/webhook/{channel_integrations.id} with a random secret_token.
  2. Telegram posts an update; the controller checks the row is telegram + connected (else 404) and the secret header (else 403).
  3. TelegramWebhookProcessor → lead by external_lead_id (no phone) → conversation keyed by external_thread_id = chat.id → IngestAsync with external id chatId:messageId, origin=telegram_customer.
  4. Same AI trigger as WhatsApp with channel:"telegram". Outbound goes through TelegramChannelSender → sendMessage. No 24h window; templates are WhatsApp-only.
  5. Voice notes arrive as message_type=audio (Telegram's file_id kept in metadata_json). They are saved and played in the Inbox through MessageMediaService → TelegramVoiceMediaSource (getFile + download with the clinic's own bot token); they are not yet forwarded to n8n (only text/interactive are AI-eligible).

5.5 Booking (AI and dashboard)

  1. get_available_slots → AvailabilityService.GetSlotsAsync builds slots in the clinic's timezone and returns the lead's booking context.
  2. schedule_consultation → AppointmentService.ScheduleAsync: no upcoming appointment → book; one upcoming and ConfirmReplaceExisting → reschedule; otherwise CONFIRMATION_REQUIRED.
  3. Every mutation takes pg_advisory_xact_lock(hashtext(clinicId)), re-runs CheckSlotAsync (excluding the appointment being moved) and the one-upcoming-per-lead guard.
  4. After commit: event row, APPOINTMENT_* notification, AppointmentChanged, and CalendarIntegrationService.TriggerAppointmentSyncAsync.

5.6 Campaigns

  1. Create (/Campaigns/Create): pick an approved template, fill variables, choose audience all_eligible | reactivation_no_consultation | custom (filters JSON or manual selection). Recipients are snapshotted; non-contactable leads are skipped with a reason.
  2. Send: POST /api/campaigns/{id}/send moves to running, queues pending recipients and processes the first batch (20); CampaignSenderWorker sends the rest (process-batch still works by hand). A scheduled campaign is started by the worker once scheduled_at passes.
  3. Stop: POST /api/campaigns/{id}/stop (Details page "Stop campaign") moves a scheduled or running campaign to paused, shown as "Stopped", with a conditional set-based update. Nothing more is sent, including the rest of a batch in progress; waiting recipients stay pending/queued. "Resume sending" is send again (keeps the first started_at); Cancel ends it. A campaign stopped or cancelled during its last batch is never marked completed.
  4. Each recipient: conversation get-or-create → MessageService.SendCampaignTemplateAsync (origin=campaign, links campaign ids) → mode flips to human.
  5. Attribution: status webhooks move recipients through sent → delivered → read; the first inbound reply after sent_at sets replied_at and CAMPAIGN_REPLY; bookings set booked_at/appointment_id.

5.7 Calendar sync (one way, SculptFlow → Google/Outlook)

  1. Connect: /calendar-oauth/{provider}/connect marks the row pending and redirects to consent; /callback exchanges the code, stores access/refresh tokens on calendar_integrations, lists calendars into calendar_integration_calendars.
  2. Staff select a calendar and enable sync.
  3. On every committed appointment change, the service refreshes the token if needed, upserts appointment_calendar_syncs (one row per appointment × integration, new last_request_id, last_operation) and posts CalendarSyncTriggerPayload to N8n:CalendarSyncWebhookUrl (access token, calendar id, operation create/update/cancel, request id, external event id, start/end with offset, clinic timezone).
  4. n8n makes the provider call and posts {clinicId, appointmentId, calendarIntegrationId, requestId, success, externalEventId, errorMessage} to /api/calendar-integrations/sync-callback. A stale requestId is ignored. Failures mark the integration unhealthy.

5.8 Knowledge Base ingestion and search

  • Manual / upload: text (or extracted text, max 5 MB file, 250k chars) → chunk by the clinic's settings (tokens ≈ chars ÷ 4) → embed all chunks first → transaction replaces knowledge_chunks for that document.
  • Website: create source → pending run enqueued → worker crawls breadth-first (robots.txt, SSRF-checked fetch, same-site links, max pages/depth/concurrency from Knowledge:WebScraping) → analyze (canonicals, boilerplate stripping by block hash, SHA-256 of normalized text, duplicates) → apply (unchanged pages skip embedding, changed pages rebuild only their chunks, new pages create source_type='website' documents, missing pages deactivate after confirmed absence).
  • Search: embed the query with the clinic's model, exact cosine scan over the clinic's active documents' chunks, take top-K, drop anything under minimum_similarity. No ANN index on purpose.

5.9 Retrieval benchmark

  1. Driven from the admin portal (Search benchmark page, clinic picked in its header) through /api/platform-admin/knowledge-benchmark/clinics/{clinicId}; clinic staff have no benchmark page or API. Generate: the app creates a pending generation row, samples 20 active chunks and posts them with the generationId to N8n:KnowledgeBenchmarkWebhookUrl; returns 202.
  2. n8n's LLM writes one question per chunk and calls back POST /api/knowledge/benchmark/generations/{id}/questions. Every id is validated against the chunks that generation sent; the clinic comes from the generation row. Cancelled generations refuse late callbacks.
  3. Run: a pending run is queued; the worker searches each non-stale case via SearchRankedAsync and records chunk/document ranks, Top-1/3/5, MRR, latency and a settings snapshot.

5.10 Sign up and sign in

  • Register: full name, clinic name, email, password → new clinic (unique slug) + user + membership in one transaction → signed in → /dashboard. With Accounts:SignupEnabled off, Register shows "sign-ups are closed" instead of the form, Login hides its "Create a clinic account" link, a new Google identity is sent back to Login (externalError=signup_closed), and both register methods refuse before creating anything.
  • Google: /auth/google/start → Google → /auth/google/callback: existing linked login or verified matching email signs in; a new email goes to /Account/CompleteGoogleSignup to collect the clinic name. Errors return to Login with externalError=cancelled|failed.

5.11 Billing an outbound WhatsApp template (usage)

  1. MessageService (Inbox template, campaign recipient, or a billed free-form message) checks the subscription (EnsureCanSendMessages; expired/cancelled/no plan → 422) and generates the message id it will save.
  2. WhatsAppBillingPolicy describes the message: template category → whatsapp_{category}_message (utility inside an open 24h window and free-form replies are free by default), quantity 1, destination country from the lead's number, provider meta, settle on delivery.
  3. ProviderBillingService resolves the clinic's WhatsApp account: who pays the provider (account override, else the default — platform_funded for WhatsApp) and whether SculptFlow charges usage.
  4. SculptFlow doesn't charge (customer pays Meta directly, external provider, no fee): BillingService.Reserve only records the usage as not_charged, with Meta's cost if a rate knows it. No rate is required, nothing is held, the send goes ahead even with an empty wallet, and the outcome steps below only update provider_outcome.
  5. SculptFlow charges: BillingService.Reserve (own transaction, clinic account locked) rates it (clinic card → plan card → default, rate effective now, most specific country/operator/provider/arrangement; a customer-paid account only matches a fee rate for its own arrangement), refuses if no rate or spendable < price (recorded as a failed usage row; the send is refused with 422 before the provider is called), else holds it and inserts the usage record keyed whatsapp:message:{messageId}.
  6. The provider call runs. If it throws, the hold is released (send_failed) and the error goes back as before.
  7. The message row is saved with the same id. Later, the provider's status callback reaches MessageService.HandleStatusUpdateAsync → MessageBillingService: delivered/read → settle (release the hold, charge included credit first, then wallet, one ledger row per balance), failed → release. Repeated or out-of-order callbacks are no-ops; settle-after-release is refused and logged.
  8. If no outcome arrives within Billing:ReservationTimeoutHours, the maintenance worker settles it when the message row shows it was delivered (callback processed but billing failed) and releases it otherwise.

5.12 Subscription start, renewal and expiry

  1. Start / change plan (admin API, or the signup plan): wallet must cover the price while keeping existing holds covered (unless the first period is waived); old included credit expires, the plan's credit is granted, the price is charged (a 0 charge row for free/trial periods), the period runs from now. Same idempotency key → no second charge.
  2. Renewal (worker, when current_period_end has passed): same steps for the next contiguous period. If the wallet can't pay: past_due, the ended period's credit expires, the plan keeps working for Billing:GracePeriodDays; every worker run retries, so a top-up renews it (from the old period end).
  3. Expiry: grace over without payment → expired. Cancel: now, or at period end instead of renewing → cancelled. Expired/cancelled clinics can sign in and see everything, but can't send, run campaigns, use the AI or connect channels until a plan is started again.

6. Integrations

IntegrationDirectionCodeStored inStatus
WhatsApp Cloud API (Meta)in: webhook · out: Graph send, templates, signupEngines/WhatsApp/*, Providers/WhatsApp/MetaWhatsApp*, WhatsAppService, MetaGraphClient, ChannelIntegrationService, meta-connect.jschannel_integrations (channel=whatsapp), whatsapp_templates, whatsapp_health_eventsLive. Graph v21.0. Embedded Signup registers the number with a generated PIN.
Facebook Pageout: Login + long-lived page tokenChannelIntegrationService.ConnectFacebookAsyncchannel_integrations (channel=facebook)Connect only; no Messenger messaging yet.
Telegram Bot APIin: webhook · out: sendMessageHttpClients/Telegram, Engines/Telegram, TelegramIntegrationService, TelegramChannelSenderchannel_integrations (channel=telegram, bot id/username, webhook status)Live.
Google Calendar / Outlook (Graph)out: OAuth, list calendars · via n8n: eventsGoogleCalendarProviderClient, OutlookCalendarProviderClient, CalendarOAuthController, CalendarIntegrationServicecalendar_integrations, calendar_integration_calendars, appointment_calendar_syncsConnect works; event sync depends on the n8n workflow and N8n:CalendarSyncWebhookUrl.
TikTok Login Kitout: OAuth, profile, refresh, revokeTikTokProviderClient, TikTokOAuthController, TikTokController (webhook placeholder)tiktok_integrationsAccount connection only.
Google sign-inOIDC (openid, email, profile)Program.cs, GoogleAuthController, CompleteGoogleSignupidentity_user_loginsLive when configured.
Embeddingsout: /embeddingsOpenAiEmbeddingServiceknowledge_chunks.embedding vector(1536)OpenAI text-embedding-3-small, 1536 dims.
Website crawlingout: HTTP GETEngines/WebScraping, HttpClients/WebScraping, WebsiteScrapeWorker, WebsiteSourceServiceknowledge_website_* + knowledge_documentsLive; SSRF guard, robots.txt, per-clinic source cap.
n8nbothsee §7none (no state in n8n)AI workflow live; others per §7.

7. n8n (from the app's side)

The workflows themselves are not in this repo. This section documents only the contracts the app sends and accepts. If a workflow changes its expectations, update the payload records in code and this section together.

WorkflowTrigger (app → n8n)Calls back (n8n → app, X-Ingest-Key)Config
Patient AI agentAiTriggerNotifier POSTs AiTriggerPayload on every AI-eligible WhatsApp/Telegram customer message. No auth header is sent, so the n8n Webhook node must use Authentication = None and the production URL./api/ai/* tools and the AI reply send (§4.3). Send the reply before calling handoff; treat 409 conversation_in_human_mode as stop quietly.N8n:AiWebhookUrl, N8n:IngestApiKey
KB benchmark question generatorN8nKnowledgeBenchmarkGeneratorClient POSTs {generationId, clinicId, chunks:[{documentId, chunkId, documentTitle, content}]}. Webhook should respond immediately (any 2xx). 3-minute client timeout.POST /api/knowledge/benchmark/generations/{generationId}/questions with {generationId, questions:[{documentId, chunkId, question}]} (also accepts a wrapped or bare array).N8n:KnowledgeBenchmarkWebhookUrl
Calendar syncCalendarSyncNotifier POSTs CalendarSyncTriggerPayload after a committed appointment change.POST /api/calendar-integrations/sync-callback with CalendarSyncCallbackRequest.N8n:CalendarSyncWebhookUrl

Every outbound n8n call is best effort: a missing URL logs a warning and returns, a failure logs and is swallowed. The originating operation (webhook ack, appointment change) is never failed by n8n.

AiTriggerPayload (camelCase JSON):

{ "clinicId": "uuid", "conversationId": "uuid", "leadId": "uuid|null", "messageId": "uuid|null",
  "channel": "whatsapp|telegram", "messageType": "text|interactive|button|…", "messageText": "clean visible text",
  "selectedValue": "stable reply id or null",
  "traceparent": "00-{traceId}-{spanId}-01" }

Message trace (n8n side, to set up in the workflow): every HTTP Request node that calls back into the app (the /api/ai/* tools, the AI reply send, handoff) should send the header traceparent: {{ $('Webhook').item.json.body.traceparent }} (the value the trigger carried; the trigger request also carries it as a traceparent header) and X-N8n-Execution-Id: {{ $execution.id }}. ASP.NET Core continues the incoming trace, so the bot's tool calls and its reply land in the customer message's trace, with a link to the n8n execution. Without the header everything still works; the bot's calls just form their own traces.

CalendarSyncTriggerPayload: clinicId, appointmentId, calendarIntegrationId, provider, accessToken, externalCalendarId, operation (create|update|cancel), requestId, externalEventId, scheduledStart, scheduledEnd, timezone, eventTitle, eventDescription, sculptFlowAppointmentLink. n8n must not reinterpret times; offsets are already correct.

8. Database

8.1 Conventions

  • One Postgres schema per area (since 2026-10-07): core (clinics, procedures), identity (ASP.NET Identity, clinic_users), crm (leads, conversations, messages), scheduling (appointments, procedure bookings, availability, calendar sync), channels (channel integrations, WhatsApp templates and health, TikTok), marketing (campaigns), knowledge (knowledge base, scraping, benchmarks), activity (events, notifications), billing, config; the admin portal's own tables are in admin. public keeps only set_updated_at() and extensions. SQL always schema-qualifies table names and every ToTable names its schema. The top of schema.sql creates the schemas and moves any table still in public (databases created before the split) with alter table … set schema, which keeps data, indexes, constraints, triggers and foreign keys; on a current database it does nothing. Supabase's Data API only exposes public, so no app table is reachable through it.
  • Database/schema.sql is the source of truth: idempotent, append-only blocks (create … if not exists, add column if not exists, guarded do $$ constraint blocks). New changes are appended at the end, never edited in place. ApplicationDbContext is updated by hand to match.
  • Keys: uuid with gen_random_uuid(), except Identity (text ids) and knowledge_retrieval_benchmark_generations.id (app-assigned).
  • created_at/updated_at on most tables; set_updated_at() trigger maintains updated_at.
  • All instants are timestamptz; a guard block at the end converts any stray timestamp column. Only availability time/date columns are zone-less (clinic-local).
  • Status columns are varchar with CHECK constraints, except deliberately free-text provider vocabularies (template status/category, health fields, campaign_type, leads.source, knowledge_documents.category).
  • Tenant FK clinic_id … on delete cascade everywhere. Tokens (access_token, refresh_token) are plain text today.

8.2 Core relations

Core entity relationships around clinics, leads, conversations, messages, appointments and campaigns clinics clinic_users identity_users procedures leads channel_integrations whatsapp_templates conversations appointments procedure_bookings messages campaigns campaign_recipients whatsapp_health_events appointment_calendar_syncs calendar_integrations events notifications → leads, conversations, messages, appointments events / notifications reference clinic, lead, conversation, appointment (set null)
Arrows point from child to parent (FK direction). Every table also has clinic_id → clinics; only the most telling edges are drawn. Knowledge Base, availability and TikTok tables hang off clinics and are listed below.

8.3 Table catalog (46 tables)

Tenancy and identity

TableKey columnsConstraints / indexes
core.clinicsname, slug, phone, email, website, country_code, timezone (IANA, default UTC), address, operating_hours, consultation_info, is_activeslug unique
identity.identity_users, identity.identity_user_claims, identity.identity_user_logins, identity.identity_user_tokensRole-free ASP.NET Identity, snake_casetext ids; no role tables
identity.clinic_usersclinic_id, user_id (text), is_activeunique (clinic, user); unique active row per user

CRM and scheduling

TableKey columnsConstraints / indexes
core.proceduresname, code, description, consultation_duration (min), is_active
crm.leadsprocedure_id, names, phone (E.164), email, source, source_detail, campaign_name, external_lead_id, status, qualification_status, preferred_language, timeline, notes, marketing_opt_in, opted_out_at, last_contact_at, next_followup_atunique (clinic, external_lead_id) partial; indexes on phone, email, status
scheduling.appointmentslead_id, procedure_id, appointment_type, status, scheduled_start/end, location, external_calendar_id, external_event_id, notesindex on scheduled_start, status
scheduling.procedure_bookingslead_id, procedure_id (restrict), appointment_id, status, quoted/deposit/final amount, currency, procedure_date
scheduling.clinic_availability_rulesday_of_week (0 = Sunday), is_open, start_time, end_time (clinic-local)unique (clinic, day)
scheduling.clinic_availability_exceptionsdate, is_closed, start_time, end_time, reasonunique (clinic, date)
scheduling.clinic_booking_settingsdefault duration (30), buffer (0), minimum notice (240 min), max advance days (60)one per clinic

Messaging

TableKey columnsConstraints / indexes
crm.conversationslead_id, channel, external_thread_id, status, mode, ai_enabled + human_takeover (legacy mirrors), last_message_at/direction, last_customer_message_at, service_window_expires_at, last_read_atunique (clinic, channel, external_thread_id) partial
crm.inbox_settingsclinic_id, channel_integration_id (null = clinic-wide, the only kind used today; reserved for per-channel overrides), ai_bot_new_conversations, ai_bot_reopened_conversations (both default true). A clinic without a row uses the defaults.unique clinic_id where channel_integration_id is null; unique channel_integration_id where not null
crm.messagesconversation_id, lead_id, direction, sender_type, origin, channel, message_type, content, external_message_id, delivery_status, delivered/read/failed/deleted_at, failure_code/reason, metadata_json, is_ai_generated, whatsapp_template_id, campaign_id, campaign_recipient_idunique (clinic, channel, external_message_id) partial
channels.channel_integrationschannel, status, display_name, phone_number_id, whatsapp_business_id, page_id, instagram_business_id, access_token, webhook_verify_token, pin, health fields (account_status, phone_quality_rating, is_healthy, health_level, last_problem_*), telegram_bot_id/username, webhook_statusunique (clinic, channel); unique phone_number_id; unique connected telegram_bot_id
channels.whatsapp_templateschannel_integration_id, meta_template_id (the provider's template id), provider (null = meta), name, category, language, status, header/body/footer, buttons_json, variables_json, components, quality_rating, rejection_reasonunique (clinic, name, language)
channels.whatsapp_health_eventschannel_integration_id, event_type, severity, status, code, message, raw_metadata, occurred_atunique (connection, event_type, occurred_at)
marketing.campaignsname, whatsapp_template_id, campaign_type, channel, audience_type, audience_filters, status, scheduled/started/completed_at, created_by_user_id
marketing.campaign_recipientscampaign_id, lead_id, conversation_id, message_id, appointment_id, phone_number, variables_json, status, skip_reason, failure_code/reason, queued/sent/delivered/read/failed/replied/booked_at, external_message_idunique (campaign, lead)
activity.notificationstype, title, message, lead/conversation/appointment/channel_integration ids, link, is_read, read_atindex (clinic, created_at desc); partial unread
activity.eventsevent_type, source, metadata jsonb, lead/conversation/appointment idsappend-only log
activity.api_route_statsbucket_start (minute), method, route (template), request_count, error_count (5xx), client_error_count (4xx), slow_count, total_ms, p95_ms (that minute), max_ms; no clinic idbigint identity; index (bucket_start); rows past Monitoring:RetentionDays deleted by ApiTimingFlushJob
activity.app_logsoccurred_at, level (Warning/Error/Critical), category (logger), message, message_template, exception_type/_message, stack_trace, fingerprint, repeat_count, trace_id, clinic/conversation/message ids, method + route (template), instance, properties jsonb (secrets masked); no foreign keysbigint identity; indexes (occurred_at), (fingerprint, occurred_at desc), partial (trace_id), partial (clinic_id, occurred_at desc); warnings past ErrorLog:WarningRetentionDays and errors past ErrorLog:ErrorRetentionDays deleted by AppLogFlushJob
activity.message_trace_stepstrace_id (W3C), occurred_at, step, outcome (ok/skipped/failed), duration_ms, clinic/conversation/message ids, external_message_id, channel, n8n_execution_id, detail jsonb, instance; no foreign keys. Plus crm.messages.trace_id (the trace a message was created in)bigint identity; indexes (trace_id, occurred_at), partial (message_id), partial (conversation_id, occurred_at desc), partial failed (occurred_at desc), (occurred_at); crm.messages partial (trace_id). Purged by MessageTraceFlushJob

Knowledge Base

TableKey columnsConstraints / indexes
knowledge.knowledge_documentstitle, category, content, is_active, source_type (manual | upload | website), original_file_name, mime_type, file_size_bytes, source_url
knowledge.knowledge_chunksknowledge_document_id, chunk_index, content, embedding vector(1536)no ANN index (exact scan per clinic)
knowledge.knowledge_search_settingsembedding_model, vector_dimension, similarity_method, chunk_size_tokens (50–1000), chunk_overlap_tokens, top_k (1–10), minimum_similarity (0–1)one per clinic, created lazily
knowledge.knowledge_website_sourcesstart_url, normalized_start_url, host, crawl_mode, category, status, boilerplate_block_hashesunique (clinic, normalized_start_url)
knowledge.knowledge_website_pagesurl, normalized/canonical url, title, http_status, content_hash, etag, status, depth, knowledge_document_id, duplicate_of_page_id, links, missing_count, removed_atunique (source, normalized_url)
knowledge.knowledge_website_scrape_runsstatus, started/completed_at, page counters, error_summary
knowledge.knowledge_retrieval_benchmark_casesquestion, expected_document_id, expected_chunk_id (no FK), case_type, is_reviewed, source_chunk_hash/preview, is_stale, generation_idunique (clinic, chunk, lower(question))
knowledge.knowledge_retrieval_benchmark_generationsstatus (pending | completed | failed | cancelled), sent_chunks, counts, rejected_json, raw_responseid = generationId sent to n8n
knowledge.knowledge_retrieval_benchmark_runscase_scope, generation_id, status, counters, chunk/document Top-1/3/5, MRR, latency, settings snapshot
knowledge.knowledge_retrieval_benchmark_resultsrun, case (set null), ranks, scores, pass flags, classification, retrieved_json

External accounts

TableKey columnsConstraints / indexes
scheduling.calendar_integrationsprovider (google | outlook), status, account_display_name, selected_calendar_id/name, sync_enabled, access/refresh token, token_expires_at, health fields. (external_connection_ref is from the earlier n8n-owned design.)unique (clinic, provider)
scheduling.calendar_integration_calendarsexternal_calendar_id, name, is_primaryunique per integration
scheduling.appointment_calendar_syncsappointment_id, calendar_integration_id, external_event_id, status, last_request_id, last_operation, last_errorunique (appointment, integration)
channels.tiktok_integrationsstatus, open_id, union_id, display_name, avatar_url, tokens and expiries, healthone per clinic

Settings — Postgres schema config (§9)

TableKey columnsConstraints / indexes
config.settingssection, key (e.g. Availability / DefaultDurationMinutes), value, description (the admin's note), updated_byprimary key (section, key); only settings declared in ConfigDefaults; never secrets

Subscriptions and billing — Postgres schema billing (§2.6)

TableKey columnsConstraints / indexes
billing.planscode, name, price numeric(18,2), currency, billing_period (month | year), included_usage_credit, rate_card_id, is_active, sort_ordercode unique (lowercase); not clinic-scoped
billing.plan_entitlementsplan_id, entitlement_key, value (true | false | number | unlimited)unique (plan, key); value CHECK
billing.subscriptionsplan_id (restrict), status, current_period_start/end, cancel_at_period_end, past_due_since, ended_atone per clinic; due index on period end
billing.accountscurrency, wallet_balance, included_credit_balance (≥ 0), reserved_amount (≥ 0) — numeric(18,6)one per clinic; backfilled for existing clinics
billing.rate_cardscode, name, clinic_id (custom card), is_default, is_activeone default card; one card per clinic
billing.ratesrate_card_id, event_type, country_code, operator, provider, provider_billing (null = any; set = a SculptFlow fee for that arrangement), unit, provider_cost, client_rate, currency, effective_from/to, created_byexclusion constraint: versions of one rate never overlap (btree_gist); trigger blocks edits except closing
billing.usage_recordsidempotency_key, event_type, channel, channel_integration_id, provider_billing, quantity, unit, country/operator/provider, rate_id, rate_card_id, rate_source, unit price/cost snapshots, amount, provider_cost, reserved/credit/wallet/refunded amounts, charge_status, provider_outcome, failure/release reason, message/conversation/campaign ids (no FK), source, actor, occurred/reserved/settled/released/refunded_atunique (clinic, idempotency_key); settled ⇒ credit + wallet = amount; trigger freezes final rows except refund columns
billing.channel_account_settingschannel_integration_id (PK, FK to the connected account), provider_billing, omni_usage_billing, reason, updated_byone per account; null values = defaults
seq (posting order), entry_type, balance_type (wallet | included_credit), signed amount, balance_after, currency, idempotency_key, usage_record_id, subscription_id, plan_id, source, actor, reason, reference, correlation_idunique (clinic, idempotency_key); sign and balance-type CHECKs; update-blocking trigger (append-only)

8.4 Status vocabularies (CHECK-enforced)

ColumnValues
leads.statusnew, contacted, qualified, consultation_booked, consultation_attended, no_show, surgery_booked, not_interested, needs_human, lost
leads.qualification_statusunknown, hot, warm, cold, needs_human, medical_question, spam
conversations.channel / .status / .modewhatsapp, instagram, website, facebook, sms, email, telegram / active, closed, archived / ai, human, approval
messages.direction / .sender_type / .origininbound, outbound / lead, ai, staff, system / whatsapp_customer, telegram_customer, whatsapp_business_app, dashboard, ai, system, campaign
appointments.statusbooked, confirmed, attended, no_show, canceled, rescheduled
procedure_bookings.statusconsidering, quoted, deposit_paid, booked, completed, canceled, lost
campaigns.status / .audience_type / .channeldraft, scheduled, running, paused, completed, cancelled, failed / all_eligible, reactivation_no_consultation, custom / whatsapp, instagram, messenger
campaign_recipients.statuspending, queued, sent, delivered, read, replied, booked, failed, skipped
channel_integrations.channel / .statuswhatsapp, instagram, facebook, telegram / disconnected, connected, error
notifications.typeNEW_LEAD, HANDOFF, APPOINTMENT_BOOKED, APPOINTMENT_RESCHEDULED, APPOINTMENT_CANCELLED, CAMPAIGN_REPLY, OUTBOUND_MESSAGE_FAILED, INTEGRATION_UNHEALTHY
Billingbilling.subscriptions.status active, past_due, expired, cancelled · billing.usage_records.charge_status reserved, settled, released, failed, not_charged · .provider_outcome pending, billable, not_billable · provider_billing customer_direct, platform_funded, external_provider_direct, no_provider_usage_fee · billing.ledger_entries.entry_type wallet_top_up, usage_debit, included_credit_consumption, usage_refund, subscription_charge, included_credit_grant, included_credit_expiry, manual_adjustment · event types are free text (any priced [a-z0-9_]+)
Knowledgesource_type manual/upload/website · website source status pending/crawling/completed/completed_with_errors/failed · page status discovered/processing/indexed/unchanged/skipped/duplicate/failed/removed · benchmark classification EXACT_CHUNK_HIT/DOCUMENT_ONLY_HIT/MISS/STALE_CASE/ERROR

9. Configuration

Names only. Non-secret defaults live in appsettings.json; secrets via user-secrets or Render env vars (__ for :).

Settings in the database (config.settings, read through IConfigManager). Tunable values (limits, defaults, thresholds) are read with IConfigManager.GetInt/GetDecimal/GetBool/GetString(section, key) (Business/Managers/ConfigManager, singleton): the value stored in config.settings for that section and key when there is one, otherwise the constant default declared in Common/Statics/ConfigDefaults. ConfigDefaults is also the list of settings that exist (type, range, description); only those can be stored, and only values that fit. The manager answers from an in-memory copy of the table, loaded by ConfigRefreshJob at startup and every 60 seconds and refreshed right after a change, so a change applies at once without a restart. A stored value that doesn't parse is ignored (default used, warning logged). If the database is unreachable the defaults apply. 56 settings are declared, in sections Availability, Billing (incl. Enabled, Currency, grace period, reservation timeout, maintenance interval, signup plan), Knowledge, WebScraping, Benchmark, Campaigns, Embeddings (base URL, model, dimensions, batch size, and the API key as a secret setting: stored, but the API and the Configuration page only show whether it is set, and it never reaches logs or the audit log), Integrations, Dashboard, Cache and Monitoring (API timings: Enabled, SlowRequestMs, RetentionDays) and ErrorLog (Enabled, MinimumLevel, WarningRetentionDays, ErrorRetentionDays, MaxPerFingerprintPerMinute, ExcludedCategories) and MessageTrace (Enabled, RetentionDays, FailedRetentionDays); each has a typed property on IConfigManager (full list in docs/configuration.md). Database/seed-config.sql stores every default (never overwriting a stored value). Those keys are no longer read from appsettings or environment variables; the table below lists what stays there. Managed from the admin portal's Configuration page through /api/platform-admin/settings (§4.3). Secrets and environment values (below) stay in appsettings / user-secrets / environment variables.

KeySecretUsed by
ConnectionStrings:PostgresyesEF Core (key=value format, Supabase session pooler)
App:PublicBaseUrlnoTelegram webhook URL, OAuth redirect URIs
Meta:AppId, GraphApiVersion, FacebookLoginConfigId, WhatsAppLoginConfigIdnoGraph client, Embedded Signup
Meta:AppSecret, Meta:WebhookVerifyTokenyesToken exchange; webhook handshake
N8n:IngestApiKeyyesAll ingest-key endpoints
N8n:AiWebhookUrl, N8n:KnowledgeBenchmarkWebhookUrl, N8n:CalendarSyncWebhookUrltreat as secretOutbound n8n triggers (empty = feature inert, logged)
GoogleCalendar:ClientId/ClientSecret, MicrosoftCalendar:ClientId/ClientSecret/TenantIdsecretsCalendar OAuth
GoogleLogin:ClientId/ClientSecretsecretContinue with Google (button hidden if either is empty)
TikTok:ClientKey/ClientSecretsecretTikTok Login Kit
Telegram:ApiBaseUrlnoOptional override (default https://api.telegram.org), used for fakes in testing
Billing:WhatsApp:*, Billing:ProviderBilling:Defaults:*noStructured billing rules (lists and maps). Billing:Enabled, Currency and the other billing values are settings (§9 above); the switch defaults to on
PlatformAdmin:ApiKeyyesPlatform-admin API key (X-Platform-Admin-Key), shared with the SculptFlowAdmin portal (MainApp:PlatformAdminApiKey there); empty = every /api/platform-admin endpoint off (404)
Billing:ProviderBilling:Defaults:*noDefault provider billing per {channel} or {channel}_{provider} (e.g. whatsapp_meta = customer_direct); built-ins in ProviderBillingService
noOptional WhatsApp pricing rules: TemplateCategoryEvents, ServiceMessageEvent, FreeInsideServiceWindow, BillableStatuses, NonBillableStatuses (defaults in WhatsAppBillingPolicy)

10. Known gaps and risks (from the code)

AreaGapImpact
n8n trustAI endpoints trust a caller-supplied clinicId behind one shared key.A leaked key reaches every clinic. Derive clinic from the conversation server-side.
Self-signupRate limited per IP and can be switched off, but no email verification or CAPTCHA.Throwaway clinics from many IPs; switch Accounts:SignupEnabled off for a closed pilot.
Secrets at restChannel, calendar and TikTok tokens are plain text.DB leak exposes provider access.
DataProtectionNo persistent key ring configured.Restarts invalidate auth cookies and in-flight OAuth state.
Single instanceIn-memory queues, no SignalR backplane, advisory locks only per DB.Scaling out needs a durable queue and a backplane.
Campaign sender paceA scheduled campaign starts within one sender interval after scheduled_at (default 60 s) and goes out at Campaigns:SendBatchSize per interval.Large audiences take a while (1,000 people is about 50 minutes at the defaults); raise the batch size if needed.
RolesNo roles or invites; one clinic per user.Every member has full access; staff can't join an existing clinic.
TestsOnly billing has automated tests (PlasticSurgery.Tests).Other regressions are caught manually.
Billing: paymentsNo payment gateway, invoices, tax or multi-currency; top-ups are admin actions; postpaid not built.Money comes in outside the app and is recorded by an admin. Postpaid would add a credit limit to the spendable formula.
Billing: callback timingA delivery that arrives after its reservation timed out (and was released) is not charged; the "delivered" callback is logged as settle-after-release.Small under-charge, never an over-charge. Tune Billing:ReservationTimeoutHours.
Billing: plan changesChanging plan starts a new period now with no proration; reservations open across a credit reset may push the wallet slightly negative.Acceptable for now; visible in the ledger.
Billing: tenant deleteBilling rows cascade with clinics like everything else.No clinic-delete feature exists; archive the ledger before building one.
Billing: provider responsibility defaultsEvery WhatsApp account defaults to platform_funded (today's behaviour). A clinic that connected its own WABA with its own Meta payment method has to be switched to customer_direct by an admin; nothing detects it.Until switched, such a clinic would be charged SculptFlow's WhatsApp rate. Set the override when onboarding, or configure Billing:ProviderBilling:Defaults.
Billing: admin screensThe SculptFlowAdmin portal's Billing pages cover plans, rate cards and rates, wallets, subscriptions, provider billing per account, usage, ledger and the report. Usage refunds and the price check are there too; nothing in this app exposes billing admin to clinic staff.The portal needs MainApp:PlatformAdminApiKey = this app's PlatformAdmin:ApiKey.
Billing: provider cost for uncharged usageFor accounts SculptFlow does not pay for, provider cost is filled from a matching rate when one exists; otherwise it stays empty. Real provider invoices are not imported.Analytics show cost paid externally as an estimate only.
Campaign double sendA campaign batch that crashes after the provider accepted a send but before the recipient is marked sent can send that recipient again on retry (pre-existing).Billed per real message (two sends = two charges). Fix in the campaign flow if it matters.
RotationMeta:WebhookVerifyToken and N8n:IngestApiKey once appeared in git history.Rotate if not yet done.

11. Doc drift notes

Places where older documentation disagrees with the code as of the baseline above. Trust the code.

  • PROJECT_HANDOFF.md §9 lists six SignalR events; the code also sends AppointmentChanged and NotificationCreated.
  • PROJECT_HANDOFF.md §8 tool table shows /api/ai/appointments/{id}/reschedule|cancel; the real routes are /api/ai/appointments/reschedule and /cancel, and the preferred tools are schedule, cancel, upcoming.
  • PROJECT_HANDOFF.md §28 says Google sign-in is not pushed; it is on main (9cd4091, febef3c).
  • PROJECT_HANDOFF.md and §20 mention N8n:CalendarConnectWebhookUrl; the code no longer reads it (SculptFlow owns calendar OAuth directly).
  • The calendar_integrations comment in schema.sql still says n8n owns the OAuth tokens; a later block in the same file moved tokens into SculptFlow.
  • The comment on channel_integrations says Settings → "Channels & Integrations"; the sidebar now says "Messaging Integrations".
  • schema.sql is described as safe to re-run, but a second run stops at alter table campaign_recipients rename column error_message to failure_reason (the column no longer exists). Found while testing the Infobip change on a scratch database. Resolved since: on 2026-10-12 the full script ran twice in a row on a copy of the live schema without errors.
  • Found 2026-10-14 (API timings change): §4.6 "Testing" still says only billing is covered (the suite has ~1,540 tests across services, controllers, repositories and pages), and §9 still says "56 settings are declared" (the Accounts, Cache and Monitoring sections were added since).

12. Keeping this document current

Rule: whenever a change adds, removes or alters a feature, update this file in the same change. Bump "Last verified against code" and add a change-log row.

If you changed…Update
A Razor Page, sidebar entry or JS file§3.1 page map, §3.2 JS modules
A controller route or its auth§2.3 trust zones, §4.3 API surface
A service's responsibility or a business rule§4.2 catalog and the relevant §5 flow
A SignalR event§4.4 and the JS consumers in §3.2
A hosted service, queue or scheduled job§4.5
An external provider or OAuth flow§6 integrations, §9 config
Anything n8n sends or receives§7 contracts
Database/schema.sql§8 catalog, vocabularies, ER figure if a core relation changed
A config key§9
Closed or found a gap§10, §11
Billing: a plan rule, entitlement key, billable channel or ledger entry type§2.6, §4.2, §5.11/5.12, §8.3/8.4, and docs/billing.md

Change log

DateChange
2026-10-04First version, written from the code at febef3c.
2026-10-04WhatsApp provider seam (IWhatsAppProvider, WhatsApp:Provider) and Infobip as the MVP provider: §2.3, §4.1, §4.2, §4.3, §5.4b, §6, §8, §9, §10. Status callbacks are now idempotent and never move backwards (both providers).
2026-10-05Infobip made invisible to clinics: neutral webhook route /api/integrations/whatsapp/connections/{id}/events, neutral errors and failure reasons, provider fields hidden from the API, media URL replaced by mediaId: §2.3, §4.3, §5.4b.
2026-10-05Template provider seam (IWhatsAppTemplateProvider): the Templates page works through Infobip too; whatsapp_templates.provider; account-level webhook /api/integrations/whatsapp/account/events; Infobip:WebhookToken: §4.2, §4.3, §6, §8, §9, §10.
2026-10-05Subscriptions & usage billing module (Billing/, branch feature/subscription-billing): plans + entitlements, clinic subscriptions with renewal/past due/expiry, prepaid wallet + included credit, rate cards with versioned rates, billable events with reserve/settle/release, append-only ledger, WhatsApp/Telegram billing policies, entitlement checks on sending, campaigns, the AI trigger and channel connects, Settings → Billing page, /api/billing and the key-protected /api/billing/admin, BillingMaintenanceWorker, 8 new tables, Billing:* config, the first test project: §1, §2.3, §2.4, §2.5, §2.6 (new, with diagram), §3.1, §4.1–§4.6, §5.11–§5.12, §8.3, §8.4, §9, §10, §12.
2026-10-06Billing tables moved into the Postgres schema billing (billing.plans, billing.accounts, …). Provider billing responsibility per connected channel account (customer_direct, platform_funded, external_provider_direct, no_provider_usage_fee) with configured defaults and admin overrides (billing.channel_account_settings, ProviderBillingService); usage is always recorded but only charged when SculptFlow charges for it (charge_status vs provider_outcome, not_charged); rates can target one responsibility; reports split provider cost paid by SculptFlow vs paid externally; Settings → Billing shows who pays per account: §2.6 (text + diagram), §4.1, §4.2, §4.3, §5.11, §8.3, §8.4, §9, §10.
2026-10-06Automation cleanup API /api/platform-admin/automation (AutomationAdminController): lists and deletes [Automation] test clinics created by the new SculptFlowAutomation tester app, guarded by name prefix + .test email domain: §4.
2026-10-06Platform-admin convention: the billing admin API moved to /api/platform-admin/billing behind [RequirePlatformAdminKey] (PlatformAdmin:ApiKey, X-Platform-Admin-Key, actor in X-Admin-Actor), replacing Billing:AdminApiKey. The SculptFlowAdmin portal now has Billing pages that call it; the main app keeps owning the rules and writes: §2.3, §4.1, §4.3, §9, §10.
2026-10-06Code layout refactor (branch refactor/architecture): folders by layer (Controllers/{Client,Admin,Integrations,Filters}, Business/{Services,Engines,Managers,Providers,HttpClients,Jobs,Contracts}, Entities/{Models,Requests,Responses,Dtos}, Common/{Enums,Statics,Configs,Helpers,Exceptions,Converters,Extensions}, Persistence/{Contexts,Repositories}), one type per file, namespace = folder. No behaviour change. §2.5, §2.6, §4.1, §6.
2026-10-07Repositories and unit of work (branch refactor/architecture): every service, engine, job, hub, controller and page now reaches the database only through Persistence/ repositories; IUnitOfWork owns saves/transactions; billing moved from BillingDbFactory/BillingStore to IBillingUnitOfWorkFactory + BillingLedger; webhook auth/AI-trigger logic moved from the Telegram/Infobip webhook controllers into their processors (ReceiveAsync); automation cleanup moved into AutomationCleanupService; Clinic Info details save moved into ClinicProfileService; the Inbox hub checks conversation ownership through IConversationService; entity → response mapping in Business/Mappers. No behaviour change. §4.1.
2026-10-07Database split into one Postgres schema per area (core, identity, crm, scheduling, channels, marketing, knowledge, activity, plus the existing billing); schema.sql creates them and moves existing tables out of public, every statement is schema-qualified, every ToTable names its schema, the knowledge raw SQL is qualified. The campaign-recipient column rename is now guarded so schema.sql re-runs cleanly. Settings: new config.settings table keyed by (section, key), read through IConfigManager (one typed property per setting) with constant defaults in Common/Statics/ConfigDefaults; 56 settings moved there from appsettings (incl. the embeddings API key, as a masked secret setting) (billing switch, currency and tunables, knowledge, crawler (incl. the Development SSRF allow-list), embedding base URL/model/dimensions and Infobip limits) and from constants (availability defaults, benchmark, campaigns, embeddings batch, OAuth/token timings, leads page size); their appsettings keys and environment variables no longer apply, refreshed by ConfigRefreshJob; /api/platform-admin/settings (SettingsAdminController, SettingsService), managed from the admin portal's new Configuration page. §4.3, §4.5, §8.1, §8.3, §9.
2026-10-07Errors: one [ApiErrors] exception filter replaces the per-action try/catch in the JSON API controllers (same statuses and { error } bodies; unhandled duplicate/overlap/not-found now 409/404 instead of 500). Knowledge chunks are inserted 200 per statement (unnest of arrays) instead of one statement per chunk. §4.6.
2026-10-07Caching: ICacheManager over ICacheAdapter (MemoryCacheAdapter now; a Redis adapter can replace it). Keys and lifetimes in Common/Statics/CacheKeys; values stored as JSON copies. Cached: clinic entitlements (cleared by SubscriptionService/PlanService) and the WhatsApp clinic lookup for incoming Meta webhooks (cleared when a channel connection is saved or disconnected). New /api/platform-admin/cache.
2026-10-07Admin portal APIs: /api/platform-admin/{clinics,channels,staff,leads,content,overview} for every read and write the SculptFlowAdmin portal makes (it no longer touches the main tables). Writes reuse the clinic-side services. StaffClaims.FullName moved to Common/Statics.
2026-10-09Aurora UI (branch feature/aurora-ui, appearance only; docs/UI_GUIDE.md): vendored kit aurora.css/aurora.js, self-hosted fonts and icon subset (docs/tools/icon-font.js), new layouts and partials (_AuthLayout, _ThemeScript, _Sidebar, _Header, _Messages, _EmptyState, _NoClinic; _AuthSide removed), site.css rewritten as the app layer, every screen and the sign-in pages restyled; theme.js removed (the kit owns the theme toggle and drawer), sidebar.js keeps only the Inbox count. New /Status page for 404/400 (UseStatusCodePagesWithReExecute), friendlier /Error. Fixes: Interested People keeps local times after a live search; WhatsApp Templates keeps the typed template when it fails validation; /settings/billing answers 404 while billing is off; the SignalR client loads once per page; Dashboard revenue no longer depends on the server culture; every confirm() (inline and in inbox.js / the benchmark scripts) replaced by the kit's styled confirm, and "Send now" on a campaign now asks first; Inbox send failures show as toasts; a template name the clinic already uses shows "You already have a template called…" in the reopened popup instead of an HTTP 500 (the JSON API still answers 409); Swagger link only in Development. §3.
2026-10-13A consultation booked after a patient replied to a campaign (within Campaigns:BookingAttributionDays, default 14) marks that campaign recipient as booked, so the campaign's Booked figure is now filled in. A procedure booking moves the lead to surgery_booked (and logs procedure_booked once) when it is first set to booked, deposit paid or completed, not only completed: §4, §6, §8, §10.
2026-10-13The WhatsApp token inspector moved from the clinic API to POST /api/platform-admin/channels/whatsapp/debug-token (platform-admin key; ChannelsAdminController); the TikTok webhook verifies TikTok-Signature; Meta phone_number_quality_update health: the messaging tier is no longer stored as a quality rating, and FLAGGED / DOWNGRADE events raise a warning, UNFLAGGED clears it: §4, §5, §6.
2026-10-08Bug fixes from the unit-test pass: Meta webhook POSTs are rejected unless X-Hub-Signature-256 matches (HMAC-SHA256 of the raw body with Meta:AppSecret; no secret configured = rejected) and the AI is triggered once per conversation in a batch; dashboard appointment create and procedure-booking create reject another clinic's lead; embedding provider errors no longer echo the response body; ingest key compared in constant time; decimal settings reject thousands separators: §5.1, §10, §11.
2026-10-09Creating a campaign with malformed audience-filter JSON now fails with 400 instead of targeting everyone; the calendar sync-callback controller uses [ApiErrors]; the anonymous TikTok webhook logs only the body size: §4, §5.
2026-10-10Conversations: a closed conversation now reopens by itself when the patient writes again (ConversationStatusSync, called from MessageService.IngestAsync for genuine customer messages and from ConversationService.AddMessageAsync for inbound ones), staff get a Reopen button (POST /api/conversations/{id}/reopen), new event conversation_reopened. Close is kept for tidying the list. Staff replies and echoes do not reopen: §3.2, §4.2, §4.3, §4.4.
2026-10-11Voice notes in the Inbox (step 1 of voice support, Telegram): an audio message shows a player (inbox-voice.js) fed by GET /api/messages/{id}/media; MessageMediaService + IVoiceMediaSource/TelegramVoiceMediaSource + new getFile/download on TelegramBotClient; new setting Voice:MaxBytes. Nothing is stored and the text flow is untouched. Not yet sent to n8n: §3.2, §4.2, §4.3, §5.4.
2026-10-12Infobip removed: WhatsApp is Meta Cloud API only, one provider per channel. Gone: the Infobip client, provider, template provider, webhook parser/processor and controller (/api/integrations/whatsapp/connections/{id}/events, /account/events), the sender connect on Messaging Integrations and POST /api/platform-admin/channels/whatsapp/infobip/{clinicId}, the WhatsApp:Provider switch, Infobip:* config and the Infobip settings. IWhatsAppProvider/IWhatsAppTemplateProvider stay with the Meta implementation only. Dropped columns: channel_integrations.provider/provider_sender_id, whatsapp_templates.provider, billing.channel_account_settings.applies_to_provider (Infobip rows are disconnected and Infobip-approved templates reset to draft first). Billing records WhatsApp usage with provider meta. §2.3, §4.1–4.3, §5, §6, §8, §9, §10.
2026-10-13Input validation from SculptFlowAutomation findings (no more 500s on bad input): every clinic list clamps a negative skip to 0; POST /api/leads answers 404 for an unknown clinic and 400 for a lead with no name, phone or email; staff POST /api/conversations answers 404 unless the lead is the clinic's own; logging a message with an unknown senderType is 400; WhatsApp template names must be lower-case letters, digits and underscores (Meta's rule) before saving; POST /api/channel-integrations/{channel}/disconnect is 400 for an unknown channel type: §4.
2026-10-13Closed the open doors and made campaigns controllable: POST /api/leads now needs the ingest key; new settings Accounts:SignupEnabled (switch self-signup off) and Accounts:SignupsPerIpPerHour (per-IP limit on sign-up POSTs, 429; forwarded headers now also trust X-Forwarded-For); campaign Stop (POST /api/campaigns/{id}/stop, status paused shown as "Stopped", Resume = send) and the CampaignSenderWorker that starts scheduled campaigns and finishes running ones (Campaigns:SenderIntervalSeconds, Campaigns:SendBatchSize) under a per-campaign advisory lock: §2, §4, §4.5, §5, §10.
2026-10-13Knowledge Retrieval Benchmark moved to the admin portal: the clinic dashboard's /KnowledgeBase/Benchmark pages, their scripts and /api/knowledge/benchmark (KnowledgeBenchmarkController) are gone, and the "Test search quality" link left the Knowledge Base page. New platform-admin API /api/platform-admin/knowledge-benchmark/clinics/{clinicId}/… (KnowledgeBenchmarkAdminController, KnowledgeBenchmarkAdminService) with every previous function (generation, cancel, manual cases, review, runs, scores, results). The n8n callback POST /api/knowledge/benchmark/generations/{id}/questions is unchanged. Portal: Setup → Search benchmark.
2026-10-14Table caching: the ...ReadOnlyAsync reads of clinics, clinic memberships (signed-in clinic lookup), procedures, availability rules/booking settings/exceptions, calendar connections (+ "anything to sync?" check), channel connections, TikTok, WhatsApp templates, rating cards, rates and channel account billing go through ICacheManager (keys and lifetimes in CacheKeys). New CacheInvalidationInterceptor on every ApplicationDbContext clears the keys of each row a save adds, changes or deletes, again on transaction commit/rollback; clinic and user deletes clear theirs in the repository. Page/template/calendar/TikTok reads that only display now use read-only copies; anything that edits still loads tracked rows. EntitlementService lost its per-request dictionary. LastWebhookAt in cached channel copies can lag (the Integrations page reads the table).
2026-10-14Cache lifetimes are settings: new Cache section with ClinicsMinutes (60), StaffLookupMinutes (10), ProceduresMinutes, SchedulingMinutes, ChannelsMinutes, BillingRatesMinutes (60 each, 1–1440), editable on the admin portal's Configuration page. Repositories pass a CacheArea; ICacheManager.LifetimeOf reads the setting each time. Entitlements and WhatsApp routing keep fixed lifetimes.
2026-10-14API timings: new Middleware/RequestTimingMiddleware (after routing) records every request's method, route template, status and duration in memory (IApiTimingCollector); new ApiTimingFlushJob writes one row per route per minute to the new activity.api_route_stats table and deletes rows past retention. Slow requests are logged as warnings. New settings section Monitoring: Enabled (true), SlowRequestMs (1000, 50–120000), RetentionDays (30, 1–365). New read-only GET /api/platform-admin/api-timings (ApiTimingsAdminController) for the admin portal's API timings page. §4.1, §4.3, §4.5, §4.6, §8, §9.
2026-10-15Error log: every warning, error and critical logged through ILogger is also saved to the new activity.app_logs table (new DatabaseLoggerProvider + AppLogCollector in memory, new AppLogFlushJob writing every 5 s and purging hourly). Rows carry a fingerprint (grouping), repeat count (per-minute cap), trace id, route template, clinic/conversation/message ids and masked properties. [ApiErrors] now logs the 502/503 it maps. New settings section ErrorLog: Enabled (true), MinimumLevel (Warning), WarningRetentionDays (14), ErrorRetentionDays (90), MaxPerFingerprintPerMinute (20), ExcludedCategories (empty). New read-only /api/platform-admin/logs (groups, list, {id}, summary) for the portal's Errors page: §4.5, §4.6, §8, §9.
2026-10-15Message trace: new IMessageTracer (in memory) + MessageTraceFlushJob write each step a message takes (webhook in, inbox, bot trigger and tool calls, send and provider answer, with timings and reasons) to the new activity.message_trace_steps under the request's W3C trace id; new column crm.messages.trace_id; AiTriggerPayload gains traceparent (n8n should send it back as a header, plus X-N8n-Execution-Id); new [TraceBotTool] filter on AiController; each campaign recipient's send is its own trace. New settings section MessageTrace: Enabled (true), RetentionDays (14), FailedRetentionDays (60). New read-only /api/platform-admin/message-traces (list, timeline, by message, search, summary) for the portal's Message traces pages: §4.5, §4.6, §5, §7, §8, §9.
2026-10-15AI bot per plan and per clinic: new InboxSettingsService combines the plan's ai_agent entitlement with two clinic switches stored in the new crm.inbox_settings table (one column per Inbox setting; channel_integration_id null = clinic-wide row, reserved for later per-channel rows; no row = defaults, bot on, so existing clinics are unchanged). New conversations start in human mode when the bot doesn't answer them; a patient reopening an AI conversation moves it to human when the clinic's reopened switch is off; any customer message in an AI conversation moves it to human when the plan lacks ai_agent. Return to AI is refused (422) without ai_agent and hidden in the Inbox. New Clinic Info → AI assistant tab (only with ai_agent) and GET/PUT /api/platform-admin/clinics/{id}/inbox-settings for the admin portal. §3.1, §4.2, §4.3, §5.1, §5.3, §8.