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.
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.
/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 toclinics,on delete cascade), and every service method takes aclinicIdand filters on it. - Dashboard requests never trust a clinic id from the browser.
ICurrentClinicContextresolves it from the signed-in Identity user throughclinic_users(one active membership per user, enforced by the partial unique indexux_clinic_users_user_active). - Webhooks resolve the clinic from the stored connection: Meta events by
phone_number_idthen WABA id onchannel_integrations; Telegram by theconnectionIdin the URL plus its secret token. - n8n tool calls pass
clinicIdexplicitly (query or body) and are trusted because of the sharedX-Ingest-Key. This is the weakest link; see §10. - Registration always creates a brand-new, empty clinic plus its first
clinic_usersrow 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) andconversation:{id}(joined only after an ownership check).
2.3 Trust zones and authentication
| Model | Who | How it is checked | Where |
|---|---|---|---|
| session | Staff in the browser | ASP.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 key | n8n (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) |
| provider | Meta, Telegram, TikTok | Meta: 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 key | SculptFlow 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/** |
| Google sign-in in progress | Short-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 frommaintohttps://sculptflowapp.onrender.com. No CI workflow files are in the repo. UseForwardedHeaderstrustsX-Forwarded-Protoso redirect URIs and secure cookies see https.- Swagger UI at
/swaggerin Development only. HSTS and/Errorhandler outside Development. - Schema is applied by hand:
psql -f Database/schema.sql(idempotent, append-only blocks), optionalDatabase/seed.sql. EF Core migrations are not used. - Secrets:
dotnet user-secretslocally, Render env vars (Section__Key) in production.Properties/launchSettings.jsonon 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
- PostgreSQL is authoritative; SignalR only says "something changed". Every client handler re-fetches over REST.
- Business logic lives in
Business/, shared by Razor PageModels and API controllers. Controllers and PageModels hold no logic; database access belongs inPersistence/repositories (§4.1). - The backend decides; the AI asks. Mode checks, slot validity, duplicate-booking guard and AI eligibility are decided in .NET, not in n8n.
- Idempotency at the database. Partial unique indexes on external ids (messages, leads, Telegram threads, health events) make webhook retries safe.
- Side effects after commit, never throwing. Notifications, calendar sync and AI triggers run after the main write succeeds and swallow their own failures.
- 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. - Tolerate provider vocabularies. Meta template and health statuses are free text (CHECK constraints dropped) so unknown values never fail a webhook.
- 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:
- 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.
- 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.
- 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).
- Billable event, not message. A typed event (
whatsapp_marketing_message,telegram_message, latersms_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.accountsrow 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.
| Route | Sidebar label | Pattern | JS | Main backend |
|---|---|---|---|---|
/Account/Login, /Account/Register, /Account/CompleteGoogleSignup, /Account/Logout | (anonymous) | server | auth.js | SignInManager, ClinicRegistrationService, GoogleAuthController |
/dashboard | Dashboard | server | whatsapp-health.js | DashboardService, WhatsAppHealthService |
/inbox | Inbox | JS | inbox.js, inbox-voice.js | /api/conversations/**, /api/messages/{id}/media, /api/whatsapp/templates, hub |
/dashboard/leads, /dashboard/leads/{id} | Interested People | server (+ partial refresh) | leads.js | DashboardService, LeadService |
/dashboard/appointments, /dashboard/appointments/{id} | Appointments | JS calendar / server detail | appointments-calendar.js | /api/appointments/**, AppointmentService, hub AppointmentChanged |
/Campaigns, /Campaigns/Create, /Campaigns/{id} | Campaigns | server | campaign-create.js, campaign-audience.js, multi-select.js | CampaignService, CampaignAudienceService, /api/campaigns/audience-preview* |
/WhatsApp/Templates | WhatsApp Templates | server | templates-page.js, whatsapp-templates.js | WhatsAppTemplateService, hub WhatsAppTemplateUpdated |
/WhatsApp/Health | WhatsApp Health | server + live | whatsapp-health.js | WhatsAppHealthService, hub WhatsAppHealthUpdated |
/Procedures, /Procedures/Edit/{id?} | Procedures | server | procedures-page.js | ProcedureService |
/KnowledgeBase, /KnowledgeBase/Edit/{id?}, /KnowledgeBase/Settings, /KnowledgeBase/Websites/{id} | Knowledge Base | server | knowledge-page.js | KnowledgeService, KnowledgeSettingsService, WebsiteSourceService |
/settings/clinic-info | Clinic Info | server | clinic-info.js | Clinic 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 |
/Staff | Staff | server | none | StaffService |
/settings/integrations | Messaging Integrations | server + JS | meta-connect.js | /api/channel-integrations/**, TikTokIntegrationService |
/settings/calendar-integrations | Calendar Integrations | server + JS | inline | /calendar-oauth/*, /api/calendar-integrations/** |
/settings/billing | Billing (only while Billing:Enabled; the page answers 404 otherwise) | server, read-only | none | BillingQueryService: plan, renewal date, included credit, wallet, reserved, entitlements, this period's usage, recent transactions |
/Status?code=…, /Error | (anonymous) | server | none | Status: 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.
| File | Responsibility | SignalR 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.js | Month grid, create appointment dialog, status changes, Clinic/My time reload. | AppointmentChanged |
notifications.js | Bell 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.js | Loaded 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.js | Inbox unread count in the sidebar (sums unreadCount from GET /api/conversations). | NewMessage, ConversationUpdated |
whatsapp-templates.js / templates-page.js | Live template status list / presentation, preview bubble, new-template popup. | WhatsAppTemplateUpdated |
whatsapp-health.js | Live health fields on /WhatsApp/Health and the dashboard indicator. | WhatsAppHealthUpdated |
campaign-create.js, campaign-audience.js, multi-select.js | Template variables ("fill in the blanks"), live preview, audience picker mapped onto AudienceType + ManualSelection, matching-count preview, chip multiselect. | none |
meta-connect.js | Facebook JS SDK login for WhatsApp Embedded Signup and Facebook Page connect. Reads window.PS_META_CONFIG. | none |
local-time.js | Renders 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.js | Form 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.jsare the vendored Aurora kit, shared with the admin portal and extracted fromdocs/UI_GUIDE.md's appendices (never edited by hand).site.cssis 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 withnode docs/tools/icon-font.jsafter 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_ThemeScriptbefore the first paint. The collapsed sidebar rail islocalStorage['client.sidebarCollapsed'](<html class="au-side-collapsed">). The oldsf-theme/sf-sidebarkeys 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-tzcookie viaViewerTimeZone.
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).
| Folder | Contents |
|---|---|
Program.cs | All 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/Client | Clinic dashboard APIs for signed-in staff; DashboardApiController base resolves the clinic. |
Controllers/Admin | Platform-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/Integrations | Meta/Telegram/TikTok webhooks, n8n ingest and AI tool endpoints (AiController, MessagesController, *IngestController), calendar and TikTok OAuth callbacks. |
Controllers/Filters | RequireIngestKeyAttribute, 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/Managers | CurrentClinicContext, ClinicContext, EventLogger, InboxNotifier (SignalR). |
Business/Providers | WhatsApp/: 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/Jobs | BillingMaintenanceWorker, CampaignSenderWorker, WebsiteScrapeWorker + queue, KnowledgeBenchmarkRunWorker + queue. |
Business/Contracts | Interfaces of all of the above in the mirrored path (design notes live in their doc comments). |
Entities/Models | EF table classes. |
Entities/Requests, Responses, Dtos | Per-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/Mappers | Static 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.
| Service | Owns | Notable rules |
|---|---|---|
CurrentClinicContext / ClinicContext | Clinic resolution | Session → clinic_users; or lookup by explicit id for trusted callers. |
ClinicRegistrationService | Sign-up | New 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. |
LeadService | Leads | Dedup by (clinic, external_lead_id); GetOrCreateByPhoneAsync for WhatsApp; GetOrCreateByExternalIdAsync for Telegram (no phone); AI-only UpdateContextAsync cannot touch identity fields. |
InboxSettingsService | Inbox settings, who answers | The 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. |
ConversationService | Conversations | One 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. |
MessageService | All writes to messages | SendAsync (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 / WhatsAppChannelSender | WhatsApp send | Only 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: MetaWhatsAppProvider | Meta Cloud API send | SendText / SendTemplate through Graph /{phone-number-id}/messages; returns Meta's message id. |
MessageMediaService | Inbox playback | GetAudioAsync: 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. |
AppointmentService | Appointments | Booking/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. |
AvailabilityService | Slots | Weekly 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, ProcedureBookingService | Catalog, procedure deals | AI only sees active procedures. |
CampaignService, CampaignAudienceService | Campaigns | Audience 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. |
WhatsAppTemplateService | Template lifecycle | Create → submit to Meta for review through IWhatsAppTemplateProvider (MetaWhatsAppTemplateProvider); sync status; retry submit; apply Meta events by template id then name+language. |
WhatsAppHealthService | Account/phone health | Maps Meta vocab to a health level, appends history, INTEGRATION_UNHEALTHY on transition into a problem state. |
ChannelIntegrationService | WhatsApp/Facebook connections | Embedded 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. |
TelegramIntegrationService | Telegram bot connection | getMe, setWebhook with a random secret, refresh, disconnect. |
KnowledgeService, KnowledgeChunkingService, KnowledgeSettingsService, DocumentTextExtractor (singleton) | KB content | Embeddings computed before the transaction, chunk swap is transactional; uploads extracted with PdfPig / OpenXml, file not stored. |
KnowledgeSearchService, OpenAiEmbeddingService | Retrieval | Exact cosine scan (1 - (embedding <=> q)) over the clinic's active chunks, top-K then minimum similarity. |
WebsiteSourceService, WebsiteScrapeProcessor | Website sources | See §5.8. |
KnowledgeBenchmarkService | Retrieval benchmark | Cases, async generations via n8n, runs, scoring (Top-1/3/5, MRR), stale detection. |
NotificationService | Bell | Created only after a confirmed event; never throws. |
CalendarIntegrationService | Google/Outlook connection + sync | Owns OAuth tokens and refresh; n8n only executes the event API call. |
TikTokIntegrationService | TikTok Login Kit | Account connection only; refreshes on page load. |
DashboardService, StaffService | Read models | Funnel summary, attention list, lead/appointment tables, procedure stats; staff list. |
EventLogger | events table | Adds rows to the caller's unit of work; saved together with the business change. |
BillingService | Usage records + SculptFlow money | Reserve: 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. |
ProviderBillingService | Who pays the provider | Resolves 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). |
SubscriptionService | Plan lifecycle | Start/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 + IChannelBillingPolicy | Messaging ↔ billing bridge | Asks 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, BillingQueryService | Billing admin + reads | Plan 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)
| Base | Endpoints |
|---|---|
/api/conversations | GET 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}/media | GET 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/leads | GET · GET {id} · PATCH {id} · POST {id}/status · POST create needs the ingest key (body clinic id) |
/api/appointments | GET · POST · GET {id} · PATCH {id}/status · GET available · GET calendar |
/api/procedures | GET · GET {id} · POST · PUT {id} · POST {id}/active |
/api/procedure-bookings | GET · POST · PATCH {id} |
/api/campaigns | GET · 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/templates | GET · GET {id} · POST · POST {id}/sync |
/api/whatsapp/health | GET · GET events |
/api/channel-integrations | GET · PUT · POST whatsapp/connect · POST facebook/connect · POST telegram/connect · POST telegram/refresh · POST {channel}/disconnect |
/api/calendar-integrations | GET · POST {provider}/refresh-calendars · POST {provider}/select-calendar · POST {provider}/sync-enabled · POST {provider}/disconnect |
/api/knowledge | GET · GET {id} · POST · POST upload (multipart) · PUT {id} · POST {id}/active · DELETE {id} · GET/PUT settings |
/api/knowledge/websites | GET · POST · GET {id} · GET {id}/pages · POST {id}/rescrape · POST {id}/active · DELETE {id} |
/api/notifications | GET · GET unread-count · POST {id}/read · POST read-all |
/api/dashboard | GET summary · GET attention · GET leads · GET appointments · GET procedures |
/api/staff | GET |
/api/billing | GET 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/billing | Endpoints |
|---|---|
| Plans | GET plans · GET plans/{code} · POST plans · PUT plans/{code} · PUT plans/{code}/entitlements · GET entitlements (catalog) |
| Rate cards | GET 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 |
| Clinics | GET 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 billing | GET 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 |
| Reporting | GET 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/automation | Endpoints |
|---|---|
| Clinics | GET 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/settings | Endpoints |
|---|---|
| Settings | GET (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/cache | Endpoints |
|---|---|
| Cache | GET 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-timings | Endpoints |
|---|---|
| API timings | GET ?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/logs | Endpoints |
|---|---|
| Problems | GET 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}]} |
| Rows | GET ?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 row | GET {id} → the row plus messageTemplate, exceptionMessage, stackTrace, instance, properties (name → text) |
| Summary | GET 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-traces | Endpoints |
|---|---|
| List | GET ?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 |
| Timeline | GET {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}]} |
| Search | GET resolve?q= (trace id, message id or provider message id) → {traceId} |
| Summary | GET 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 |
|---|---|
clinics | GET ?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) |
channels | GET ?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 |
staff | GET ?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} |
leads | GET ?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) |
content | GET 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} |
overview | GET 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 tool | Endpoint | Notes |
|---|---|---|
search_clinic_knowledge | POST /api/ai/knowledge/search | Body {clinicId, query, limit}. Returns chunks, never an answer. Information only. |
get_clinic_info | GET /api/ai/clinic-info?clinicId= | Free-text clinic fields. Partly redundant with the KB, kept for now. |
get_procedures | GET /api/ai/procedures?clinicId= | Active procedures only. |
get_lead_context / update_lead | GET / PATCH /api/ai/leads/{leadId}?clinicId= | Update cannot change name/phone/email. |
get_available_slots | GET /api/ai/appointments/available | Slots plus the lead's booking context (CanCreateNewBooking, upcoming appointments). |
schedule_consultation | POST /api/ai/appointments/schedule | Single scheduling mutation; backend chooses create / move / CONFIRMATION_REQUIRED. Structured outcome codes with AI instructions (AiResultInstructions). |
cancel_consultation | POST /api/ai/appointments/cancel | Structured outcome. |
get_my_appointments | GET /api/ai/appointments/upcoming | Lead's upcoming booked/confirmed, clinic-local wording. |
(legacy) book_consultation, reschedule_consultation | POST /api/ai/appointments/book, /reschedule | Superseded by schedule; still live. |
handoff_to_human | POST /api/ai/conversations/{id}/handoff?clinicId= | Body {reason}. HANDOFF notification. |
| AI reply | POST /api/conversations/{id}/messages/send?clinicId= | Body {content, sender:"ai"}. 409 conversation_in_human_mode if a human took over. |
| test only | GET /api/ai/test/codes, POST /api/ai/test/schedule, POST /api/ai/test/cancel | Canned outcomes, no DB access. |
Other server-to-server and provider endpoints
| Endpoint | Auth | Purpose |
|---|---|---|
GET/POST /api/integrations/whatsapp/webhook | open | Meta handshake and every Meta delivery (§5.1); POSTs need a valid X-Hub-Signature-256 (Meta:AppSecret). |
POST /api/integrations/telegram/webhook/{connectionId} | secret header | Telegram updates (§5.4). |
GET/POST /api/integrations/tiktok/webhook | open | Placeholder: 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/ingest | ingest | Legacy normalized ingest from the n8n-relay era (customer, echo, AI-sent, status). Same MessageService.IngestAsync. |
POST /api/integrations/whatsapp/templates/events, /health/events | ingest | Pre-normalized template/health events (tooling, tests). Same services as the webhook handlers. |
POST /api/calendar-integrations/sync-callback | ingest | n8n calendar sync result (§5.7). |
POST /api/knowledge/benchmark/generations/{id}/questions | ingest | n8n benchmark question callback (§5.9). |
GET /calendar-oauth/{provider}/connect, /callback | session | Google/Outlook OAuth. |
GET /tiktok-oauth/connect, /callback | session | TikTok Login Kit OAuth. |
POST /auth/google/start, GET /auth/google/callback | Continue 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).
| Event | Payload | Sent when |
|---|---|---|
NewMessage | conversationId, messageId, leadId, direction, senderType, origin, createdAt | Any message persisted |
MessageStatusUpdated | conversationId, messageId, deliveryStatus, updatedAt | Meta status webhook |
ConversationUpdated | conversationId | Read state, status (close / reopen) or conversation fields change |
ConversationModeChanged | conversationId, mode | ai ↔ human |
AppointmentChanged | appointmentId, change (created | rescheduled | canceled | status_changed) | After commit |
NotificationCreated | NotificationResponse | New bell item |
WhatsAppTemplateUpdated | templateId, name, status, rejectionReason, updatedAt | Template event or sync |
WhatsAppHealthUpdated | connectionId, healthLevel, account/review/quality/phone status, problemMessage, updatedAt | Health event |
4.5 Background workers
| Hosted service | Queue | Unit of work | Recovery on startup |
|---|---|---|---|
WebsiteScrapeWorker | WebsiteScrapeQueue (unbounded Channel<Guid>, singleton) | One knowledge_website_scrape_runs row, one at a time, own DI scope | crawling runs → failed ("interrupted"); pending runs re-queued |
KnowledgeBenchmarkRunWorker | KnowledgeBenchmarkRunQueue | One knowledge_retrieval_benchmark_runs row | running → failed; pending re-queued |
BillingMaintenanceWorker | timer, every Billing:MaintenanceIntervalMinutes (5); idle while billing is off | Renew / 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 |
CampaignSenderWorker | timer, 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 |
ConfigRefreshJob | timer, every 60 s | Re-reads config.settings into IConfigManager (§9) | Loads it at startup; defaults until then |
ApiTimingFlushJob | timer, on every whole minute | Drains 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) |
AppLogFlushJob | timer, every 5 s | Copies 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 |
MessageTraceFlushJob | timer, every 5 s | Writes 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:
eventsrows viaIEventLogger, typed byEventTypesconstants (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]onDashboardApiController,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,/hubsand the status-page re-execute are skipped) into the singletonIApiTimingCollector: 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;ApiTimingFlushJobwrites the minute (§4.5). A request at or overMonitoring:SlowRequestMs(1000) logs a warningSlow request: {Method} {Route} took {ElapsedMs} ms.Monitoring:Enabledswitches 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/DatabaseLoggerProvideris registered as an extraILoggerProvider, so every warning, error and critical anyILoggerwrites (including the framework's "unhandled exception" log) is also queued in the singletonIAppLogCollector; 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 mostErrorLog:MaxPerFingerprintPerMinute(20) rows per minute; the rest of that minute becomes one row withrepeat_count. Up to 5,000 entries wait in memory; beyond that they are dropped and one row says how many.ErrorLog:Enabled,MinimumLevel(Warning) andExcludedCategoriesare copied in byAppLogFlushJob(§4.5) every flush, because the logging path can't depend onConfigManager(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
IMessageTracerrecords each step a message takes under the request's W3C trace id (Activity.Current;TraceContext.StartNewgives 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 putstraceparentin the payload);bot.tool_calledfor every/api/ai/*call ([TraceBotTool]onAiController);send.requested/blocked(billing or plan) /rejected_mode/provider_accepted/provider_failedwith timings (MessageServicesend paths). Every message row saved getstrace_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-Idon n8n's calls is stored.MessageTrace:Enabledswitches 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 useRemoveAllLoggers()because their URLs carry secrets. The crawler client disables redirects, cookies and proxy, and routes every connect throughSsrfGuard.ConnectAsync. - Testing:
PlasticSurgery.Tests(xUnit) covers billing: pure rules (rating, phone country, WhatsApp policy, entitlements) and database tests that needSCULPTFLOW_TEST_DBpointing at a throwaway local PostgreSQL (each run creates and drops its own database fromschema.sql; without it those tests are skipped; remote hosts are refused). Nothing else is covered by automated tests yet.AiTestControllergives n8n canned outcomes.
5. Key flows (low-level)
5.1 Inbound WhatsApp message to AI reply
- Meta posts raw JSON to
POST /api/integrations/whatsapp/webhook. - MetaWebhookParser splits it into
ParsedMetaEvents (customer message, business-app echo, status, template, health, history, app-state, unknown). Interactive/button replies become clean text plusSelectedValue. - MetaWebhookProcessor resolves the clinic per event from
channel_integrations(phone_number_id, else WABA id) and dispatches to the handler. - 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. - The conversation's mode decides who answers: a new conversation starts in
humanwhen the plan lacksai_agentor the clinic turned the bot off for new conversations; a patient reopening a closed AI conversation moves it tohumanwhen the clinic sends reopened conversations to staff (or the plan lacksai_agent). SeeInboxSettingsService. - If the event is AI-eligible (customer message, conversation mode
ai, not a duplicate) the controller callsAiTriggerNotifier→N8n:AiWebhookUrlwith{clinicId, conversationId, leadId, messageId, channel, messageType, messageText, selectedValue, traceparent}. Never throws. - Meta always gets HTTP 200.
- n8n AI agent calls
/api/ai/*tools, thenPOST /api/conversations/{id}/messages/send?clinicId=withsender:"ai". - MessageService.SendAiReplyAsync re-reads the mode; if a human took over it returns 409 and nothing is sent. Otherwise
WhatsAppChannelSendersends through the Graph API and the message is stored withorigin=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) → storeorigin=dashboard→ mode flips tohuman. - Closed window: 409; the Inbox offers
send-templatewith an approved template. - Coexistence echo: a reply typed in the WhatsApp Business phone app arrives as a
BusinessAppEcho, is stored withorigin=whatsapp_business_app, flips the conversation to human, and never triggers AI. - Delivery status:
MessageStatusHandlerupdatesdelivery_statusand the matchingdelivered_at/read_at/failed_at, mirrors campaign recipient status, raises OUTBOUND_MESSAGE_FAILED on failure, broadcastsMessageStatusUpdated.
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
- Staff paste a BotFather token on Messaging Integrations →
POST /api/channel-integrations/telegram/connect→getMe, thensetWebhookto{App:PublicBaseUrl}/api/integrations/telegram/webhook/{channel_integrations.id}with a randomsecret_token. - Telegram posts an update; the controller checks the row is
telegram+connected(else 404) and the secret header (else 403). TelegramWebhookProcessor→ lead byexternal_lead_id(no phone) → conversation keyed byexternal_thread_id = chat.id→IngestAsyncwith external idchatId:messageId,origin=telegram_customer.- Same AI trigger as WhatsApp with
channel:"telegram". Outbound goes throughTelegramChannelSender→sendMessage. No 24h window; templates are WhatsApp-only. - Voice notes arrive as
message_type=audio(Telegram'sfile_idkept inmetadata_json). They are saved and played in the Inbox throughMessageMediaService→TelegramVoiceMediaSource(getFile+ download with the clinic's own bot token); they are not yet forwarded to n8n (onlytext/interactiveare AI-eligible).
5.5 Booking (AI and dashboard)
get_available_slots→AvailabilityService.GetSlotsAsyncbuilds slots in the clinic's timezone and returns the lead's booking context.schedule_consultation→AppointmentService.ScheduleAsync: no upcoming appointment → book; one upcoming andConfirmReplaceExisting→ reschedule; otherwise CONFIRMATION_REQUIRED.- Every mutation takes
pg_advisory_xact_lock(hashtext(clinicId)), re-runsCheckSlotAsync(excluding the appointment being moved) and the one-upcoming-per-lead guard. - After commit: event row, APPOINTMENT_* notification,
AppointmentChanged, andCalendarIntegrationService.TriggerAppointmentSyncAsync.
5.6 Campaigns
- Create (
/Campaigns/Create): pick an approved template, fill variables, choose audienceall_eligible|reactivation_no_consultation|custom(filters JSON or manual selection). Recipients are snapshotted; non-contactable leads are skipped with a reason. - Send:
POST /api/campaigns/{id}/sendmoves torunning, queues pending recipients and processes the first batch (20);CampaignSenderWorkersends the rest (process-batchstill works by hand). Ascheduledcampaign is started by the worker oncescheduled_atpasses. - Stop:
POST /api/campaigns/{id}/stop(Details page "Stop campaign") moves ascheduledorrunningcampaign topaused, 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" issendagain (keeps the firststarted_at); Cancel ends it. A campaign stopped or cancelled during its last batch is never marked completed. - Each recipient: conversation get-or-create →
MessageService.SendCampaignTemplateAsync(origin=campaign, links campaign ids) → mode flips to human. - Attribution: status webhooks move recipients through sent → delivered → read; the first inbound reply after
sent_atsetsreplied_atand CAMPAIGN_REPLY; bookings setbooked_at/appointment_id.
5.7 Calendar sync (one way, SculptFlow → Google/Outlook)
- Connect:
/calendar-oauth/{provider}/connectmarks the row pending and redirects to consent;/callbackexchanges the code, stores access/refresh tokens oncalendar_integrations, lists calendars intocalendar_integration_calendars. - Staff select a calendar and enable sync.
- On every committed appointment change, the service refreshes the token if needed, upserts
appointment_calendar_syncs(one row per appointment × integration, newlast_request_id,last_operation) and postsCalendarSyncTriggerPayloadtoN8n:CalendarSyncWebhookUrl(access token, calendar id, operation create/update/cancel, request id, external event id, start/end with offset, clinic timezone). - n8n makes the provider call and posts
{clinicId, appointmentId, calendarIntegrationId, requestId, success, externalEventId, errorMessage}to/api/calendar-integrations/sync-callback. A stalerequestIdis 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_chunksfor that document. - Website: create source →
pendingrun enqueued → worker crawls breadth-first (robots.txt, SSRF-checked fetch, same-site links, max pages/depth/concurrency fromKnowledge: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 createsource_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
- 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 apendinggeneration row, samples 20 active chunks and posts them with thegenerationIdtoN8n:KnowledgeBenchmarkWebhookUrl; returns 202. - 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. - Run: a
pendingrun is queued; the worker searches each non-stale case viaSearchRankedAsyncand 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. WithAccounts:SignupEnabledoff, 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/CompleteGoogleSignupto collect the clinic name. Errors return to Login withexternalError=cancelled|failed.
5.11 Billing an outbound WhatsApp template (usage)
- 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. - 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, providermeta, settle on delivery. - ProviderBillingService resolves the clinic's WhatsApp account: who pays the provider (account override, else the default —
platform_fundedfor WhatsApp) and whether SculptFlow charges usage. - 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 updateprovider_outcome. - 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
failedusage row; the send is refused with 422 before the provider is called), else holds it and inserts the usage record keyedwhatsapp:message:{messageId}. - The provider call runs. If it throws, the hold is released (
send_failed) and the error goes back as before. - 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. - 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
- 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.
- Renewal (worker, when
current_period_endhas 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 forBilling:GracePeriodDays; every worker run retries, so a top-up renews it (from the old period end). - 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
| Integration | Direction | Code | Stored in | Status |
|---|---|---|---|---|
| WhatsApp Cloud API (Meta) | in: webhook · out: Graph send, templates, signup | Engines/WhatsApp/*, Providers/WhatsApp/MetaWhatsApp*, WhatsAppService, MetaGraphClient, ChannelIntegrationService, meta-connect.js | channel_integrations (channel=whatsapp), whatsapp_templates, whatsapp_health_events | Live. Graph v21.0. Embedded Signup registers the number with a generated PIN. |
| Facebook Page | out: Login + long-lived page token | ChannelIntegrationService.ConnectFacebookAsync | channel_integrations (channel=facebook) | Connect only; no Messenger messaging yet. |
| Telegram Bot API | in: webhook · out: sendMessage | HttpClients/Telegram, Engines/Telegram, TelegramIntegrationService, TelegramChannelSender | channel_integrations (channel=telegram, bot id/username, webhook status) | Live. |
| Google Calendar / Outlook (Graph) | out: OAuth, list calendars · via n8n: events | GoogleCalendarProviderClient, OutlookCalendarProviderClient, CalendarOAuthController, CalendarIntegrationService | calendar_integrations, calendar_integration_calendars, appointment_calendar_syncs | Connect works; event sync depends on the n8n workflow and N8n:CalendarSyncWebhookUrl. |
| TikTok Login Kit | out: OAuth, profile, refresh, revoke | TikTokProviderClient, TikTokOAuthController, TikTokController (webhook placeholder) | tiktok_integrations | Account connection only. |
| Google sign-in | OIDC (openid, email, profile) | Program.cs, GoogleAuthController, CompleteGoogleSignup | identity_user_logins | Live when configured. |
| Embeddings | out: /embeddings | OpenAiEmbeddingService | knowledge_chunks.embedding vector(1536) | OpenAI text-embedding-3-small, 1536 dims. |
| Website crawling | out: HTTP GET | Engines/WebScraping, HttpClients/WebScraping, WebsiteScrapeWorker, WebsiteSourceService | knowledge_website_* + knowledge_documents | Live; SSRF guard, robots.txt, per-clinic source cap. |
| n8n | both | see §7 | none (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.
| Workflow | Trigger (app → n8n) | Calls back (n8n → app, X-Ingest-Key) | Config |
|---|---|---|---|
| Patient AI agent | AiTriggerNotifier 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 generator | N8nKnowledgeBenchmarkGeneratorClient 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 sync | CalendarSyncNotifier 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 inadmin.publickeeps onlyset_updated_at()and extensions. SQL always schema-qualifies table names and everyToTablenames its schema. The top ofschema.sqlcreates the schemas and moves any table still inpublic(databases created before the split) withalter table … set schema, which keeps data, indexes, constraints, triggers and foreign keys; on a current database it does nothing. Supabase's Data API only exposespublic, so no app table is reachable through it. Database/schema.sqlis the source of truth: idempotent, append-only blocks (create … if not exists,add column if not exists, guardeddo $$constraint blocks). New changes are appended at the end, never edited in place.ApplicationDbContextis updated by hand to match.- Keys:
uuidwithgen_random_uuid(), except Identity (text ids) andknowledge_retrieval_benchmark_generations.id(app-assigned). created_at/updated_aton most tables;set_updated_at()trigger maintainsupdated_at.- All instants are
timestamptz; a guard block at the end converts any straytimestampcolumn. Only availabilitytime/datecolumns are zone-less (clinic-local). - Status columns are
varcharwith 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 cascadeeverywhere. Tokens (access_token,refresh_token) are plain text today.
8.2 Core relations
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
| Table | Key columns | Constraints / indexes |
|---|---|---|
core.clinics | name, slug, phone, email, website, country_code, timezone (IANA, default UTC), address, operating_hours, consultation_info, is_active | slug unique |
identity.identity_users, identity.identity_user_claims, identity.identity_user_logins, identity.identity_user_tokens | Role-free ASP.NET Identity, snake_case | text ids; no role tables |
identity.clinic_users | clinic_id, user_id (text), is_active | unique (clinic, user); unique active row per user |
CRM and scheduling
| Table | Key columns | Constraints / indexes |
|---|---|---|
core.procedures | name, code, description, consultation_duration (min), is_active | |
crm.leads | procedure_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_at | unique (clinic, external_lead_id) partial; indexes on phone, email, status |
scheduling.appointments | lead_id, procedure_id, appointment_type, status, scheduled_start/end, location, external_calendar_id, external_event_id, notes | index on scheduled_start, status |
scheduling.procedure_bookings | lead_id, procedure_id (restrict), appointment_id, status, quoted/deposit/final amount, currency, procedure_date | |
scheduling.clinic_availability_rules | day_of_week (0 = Sunday), is_open, start_time, end_time (clinic-local) | unique (clinic, day) |
scheduling.clinic_availability_exceptions | date, is_closed, start_time, end_time, reason | unique (clinic, date) |
scheduling.clinic_booking_settings | default duration (30), buffer (0), minimum notice (240 min), max advance days (60) | one per clinic |
Messaging
| Table | Key columns | Constraints / indexes |
|---|---|---|
crm.conversations | lead_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_at | unique (clinic, channel, external_thread_id) partial |
crm.inbox_settings | clinic_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.messages | conversation_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_id | unique (clinic, channel, external_message_id) partial |
channels.channel_integrations | channel, 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_status | unique (clinic, channel); unique phone_number_id; unique connected telegram_bot_id |
channels.whatsapp_templates | channel_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_reason | unique (clinic, name, language) |
channels.whatsapp_health_events | channel_integration_id, event_type, severity, status, code, message, raw_metadata, occurred_at | unique (connection, event_type, occurred_at) |
marketing.campaigns | name, whatsapp_template_id, campaign_type, channel, audience_type, audience_filters, status, scheduled/started/completed_at, created_by_user_id | |
marketing.campaign_recipients | campaign_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_id | unique (campaign, lead) |
activity.notifications | type, title, message, lead/conversation/appointment/channel_integration ids, link, is_read, read_at | index (clinic, created_at desc); partial unread |
activity.events | event_type, source, metadata jsonb, lead/conversation/appointment ids | append-only log |
activity.api_route_stats | bucket_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 id | bigint identity; index (bucket_start); rows past Monitoring:RetentionDays deleted by ApiTimingFlushJob |
activity.app_logs | occurred_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 keys | bigint 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_steps | trace_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
| Table | Key columns | Constraints / indexes |
|---|---|---|
knowledge.knowledge_documents | title, category, content, is_active, source_type (manual | upload | website), original_file_name, mime_type, file_size_bytes, source_url | |
knowledge.knowledge_chunks | knowledge_document_id, chunk_index, content, embedding vector(1536) | no ANN index (exact scan per clinic) |
knowledge.knowledge_search_settings | embedding_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_sources | start_url, normalized_start_url, host, crawl_mode, category, status, boilerplate_block_hashes | unique (clinic, normalized_start_url) |
knowledge.knowledge_website_pages | url, normalized/canonical url, title, http_status, content_hash, etag, status, depth, knowledge_document_id, duplicate_of_page_id, links, missing_count, removed_at | unique (source, normalized_url) |
knowledge.knowledge_website_scrape_runs | status, started/completed_at, page counters, error_summary | |
knowledge.knowledge_retrieval_benchmark_cases | question, expected_document_id, expected_chunk_id (no FK), case_type, is_reviewed, source_chunk_hash/preview, is_stale, generation_id | unique (clinic, chunk, lower(question)) |
knowledge.knowledge_retrieval_benchmark_generations | status (pending | completed | failed | cancelled), sent_chunks, counts, rejected_json, raw_response | id = generationId sent to n8n |
knowledge.knowledge_retrieval_benchmark_runs | case_scope, generation_id, status, counters, chunk/document Top-1/3/5, MRR, latency, settings snapshot | |
knowledge.knowledge_retrieval_benchmark_results | run, case (set null), ranks, scores, pass flags, classification, retrieved_json |
External accounts
| Table | Key columns | Constraints / indexes |
|---|---|---|
scheduling.calendar_integrations | provider (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_calendars | external_calendar_id, name, is_primary | unique per integration |
scheduling.appointment_calendar_syncs | appointment_id, calendar_integration_id, external_event_id, status, last_request_id, last_operation, last_error | unique (appointment, integration) |
channels.tiktok_integrations | status, open_id, union_id, display_name, avatar_url, tokens and expiries, health | one per clinic |
Settings — Postgres schema config (§9)
| Table | Key columns | Constraints / indexes |
|---|---|---|
config.settings | section, key (e.g. Availability / DefaultDurationMinutes), value, description (the admin's note), updated_by | primary key (section, key); only settings declared in ConfigDefaults; never secrets |
Subscriptions and billing — Postgres schema billing (§2.6)
| Table | Key columns | Constraints / indexes |
|---|---|---|
billing.plans | code, name, price numeric(18,2), currency, billing_period (month | year), included_usage_credit, rate_card_id, is_active, sort_order | code unique (lowercase); not clinic-scoped |
billing.plan_entitlements | plan_id, entitlement_key, value (true | false | number | unlimited) | unique (plan, key); value CHECK |
billing.subscriptions | plan_id (restrict), status, current_period_start/end, cancel_at_period_end, past_due_since, ended_at | one per clinic; due index on period end |
billing.accounts | currency, wallet_balance, included_credit_balance (≥ 0), reserved_amount (≥ 0) — numeric(18,6) | one per clinic; backfilled for existing clinics |
billing.rate_cards | code, name, clinic_id (custom card), is_default, is_active | one default card; one card per clinic |
billing.rates | rate_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_by | exclusion constraint: versions of one rate never overlap (btree_gist); trigger blocks edits except closing |
billing.usage_records | idempotency_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_at | unique (clinic, idempotency_key); settled ⇒ credit + wallet = amount; trigger freezes final rows except refund columns |
billing.channel_account_settings | channel_integration_id (PK, FK to the connected account), provider_billing, omni_usage_billing, reason, updated_by | one 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_id | unique (clinic, idempotency_key); sign and balance-type CHECKs; update-blocking trigger (append-only) |
8.4 Status vocabularies (CHECK-enforced)
| Column | Values |
|---|---|
leads.status | new, contacted, qualified, consultation_booked, consultation_attended, no_show, surgery_booked, not_interested, needs_human, lost |
leads.qualification_status | unknown, hot, warm, cold, needs_human, medical_question, spam |
conversations.channel / .status / .mode | whatsapp, instagram, website, facebook, sms, email, telegram / active, closed, archived / ai, human, approval |
messages.direction / .sender_type / .origin | inbound, outbound / lead, ai, staff, system / whatsapp_customer, telegram_customer, whatsapp_business_app, dashboard, ai, system, campaign |
appointments.status | booked, confirmed, attended, no_show, canceled, rescheduled |
procedure_bookings.status | considering, quoted, deposit_paid, booked, completed, canceled, lost |
campaigns.status / .audience_type / .channel | draft, scheduled, running, paused, completed, cancelled, failed / all_eligible, reactivation_no_consultation, custom / whatsapp, instagram, messenger |
campaign_recipients.status | pending, queued, sent, delivered, read, replied, booked, failed, skipped |
channel_integrations.channel / .status | whatsapp, instagram, facebook, telegram / disconnected, connected, error |
notifications.type | NEW_LEAD, HANDOFF, APPOINTMENT_BOOKED, APPOINTMENT_RESCHEDULED, APPOINTMENT_CANCELLED, CAMPAIGN_REPLY, OUTBOUND_MESSAGE_FAILED, INTEGRATION_UNHEALTHY |
| Billing | billing.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_]+) |
| Knowledge | source_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.
| Key | Secret | Used by |
|---|---|---|
ConnectionStrings:Postgres | yes | EF Core (key=value format, Supabase session pooler) |
App:PublicBaseUrl | no | Telegram webhook URL, OAuth redirect URIs |
Meta:AppId, GraphApiVersion, FacebookLoginConfigId, WhatsAppLoginConfigId | no | Graph client, Embedded Signup |
Meta:AppSecret, Meta:WebhookVerifyToken | yes | Token exchange; webhook handshake |
N8n:IngestApiKey | yes | All ingest-key endpoints |
N8n:AiWebhookUrl, N8n:KnowledgeBenchmarkWebhookUrl, N8n:CalendarSyncWebhookUrl | treat as secret | Outbound n8n triggers (empty = feature inert, logged) |
GoogleCalendar:ClientId/ClientSecret, MicrosoftCalendar:ClientId/ClientSecret/TenantId | secrets | Calendar OAuth |
GoogleLogin:ClientId/ClientSecret | secret | Continue with Google (button hidden if either is empty) |
TikTok:ClientKey/ClientSecret | secret | TikTok Login Kit |
Telegram:ApiBaseUrl | no | Optional override (default https://api.telegram.org), used for fakes in testing |
Billing:WhatsApp:*, Billing:ProviderBilling:Defaults:* | no | Structured billing rules (lists and maps). Billing:Enabled, Currency and the other billing values are settings (§9 above); the switch defaults to on |
PlatformAdmin:ApiKey | yes | Platform-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:* | no | Default provider billing per {channel} or {channel}_{provider} (e.g. whatsapp_meta = customer_direct); built-ins in ProviderBillingService | no | Optional WhatsApp pricing rules: TemplateCategoryEvents, ServiceMessageEvent, FreeInsideServiceWindow, BillableStatuses, NonBillableStatuses (defaults in WhatsAppBillingPolicy) |
10. Known gaps and risks (from the code)
| Area | Gap | Impact |
|---|---|---|
| n8n trust | AI endpoints trust a caller-supplied clinicId behind one shared key. | A leaked key reaches every clinic. Derive clinic from the conversation server-side. |
| Self-signup | Rate 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 rest | Channel, calendar and TikTok tokens are plain text. | DB leak exposes provider access. |
| DataProtection | No persistent key ring configured. | Restarts invalidate auth cookies and in-flight OAuth state. |
| Single instance | In-memory queues, no SignalR backplane, advisory locks only per DB. | Scaling out needs a durable queue and a backplane. |
| Campaign sender pace | A 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. |
| Roles | No roles or invites; one clinic per user. | Every member has full access; staff can't join an existing clinic. |
| Tests | Only billing has automated tests (PlasticSurgery.Tests). | Other regressions are caught manually. |
| Billing: payments | No 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 timing | A 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 changes | Changing 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 delete | Billing rows cascade with clinics like everything else. | No clinic-delete feature exists; archive the ledger before building one. |
| Billing: provider responsibility defaults | Every 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 screens | The 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 usage | For 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 send | A 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. |
| Rotation | Meta: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 sendsAppointmentChangedandNotificationCreated.PROJECT_HANDOFF.md§8 tool table shows/api/ai/appointments/{id}/reschedule|cancel; the real routes are/api/ai/appointments/rescheduleand/cancel, and the preferred tools areschedule,cancel,upcoming.PROJECT_HANDOFF.md§28 says Google sign-in is not pushed; it is onmain(9cd4091,febef3c).PROJECT_HANDOFF.mdand §20 mentionN8n:CalendarConnectWebhookUrl; the code no longer reads it (SculptFlow owns calendar OAuth directly).- The
calendar_integrationscomment inschema.sqlstill says n8n owns the OAuth tokens; a later block in the same file moved tokens into SculptFlow. - The comment on
channel_integrationssays Settings → "Channels & Integrations"; the sidebar now says "Messaging Integrations". schema.sqlis described as safe to re-run, but a second run stops atalter 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,CacheandMonitoringsections 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
| Date | Change |
|---|---|
| 2026-10-04 | First version, written from the code at febef3c. |
| 2026-10-04 | WhatsApp 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-05 | Infobip 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-05 | Template 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-05 | Subscriptions & 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-06 | Billing 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-06 | Automation 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-06 | Platform-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-06 | Code 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-07 | Repositories 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-07 | Database 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-07 | Errors: 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-07 | Caching: 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-07 | Admin 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-09 | Aurora 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-13 | A 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-13 | The 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-08 | Bug 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-09 | Creating 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-10 | Conversations: 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-11 | Voice 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-12 | Infobip 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-13 | Input 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-13 | Closed 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-13 | Knowledge 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-14 | Table 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-14 | Cache 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-14 | API 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-15 | Error 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-15 | Message 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-15 | AI 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. |