SculptFlowApp

SculptFlow (MySculptFlow) — Project Handoff

Last updated: 2026-10-05 (§30 Subscriptions & usage billing added — built and tested on branch feature/subscription-billing, NOT committed yet; main is at 12fad04 with Google sign-in and Infobip merged, so §28/§29’s “not pushed / not merged” headings are stale). Earlier: 2026-09-27 doc sync through Calendar Integrations 895af7f. Written for session continuity — read this first after any context reset. This is the ONE handoff/state file; update it in place, don’t create another.

No secrets live in this file. Earlier versions of it (git commit 2aec468, already on GitHub) contained the real Meta:WebhookVerifyToken and N8n:IngestApiKey values — they were removed here but remain in git history, so rotate both (see §22). Config key names are listed in §20; values live in dotnet user-secrets locally and in Render env vars in production.

System design: docs/system-design.html is the code-verified high-level and low-level design (frontend, backend, integrations, n8n contracts, database). Any change that adds or alters a feature must update it in the same change (standing rule in CLAUDE.md; its §12 says which sections to touch). Where this handoff and that document disagree, the design doc was checked against the code more recently.


1. Product overview

SculptFlow (UI brand: MySculptFlow) is a multi-tenant SaaS platform for plastic surgery clinics. Core loop: a lead messages a clinic on WhatsApp → an AI agent (orchestrated via n8n) carries the first-line conversation, answers from the clinic’s Knowledge Base, qualifies the lead, books consultations → staff can take over any conversation at any moment from a dashboard Inbox → clinics run WhatsApp template campaigns (especially old-lead reactivation) to existing leads. Every actual message — customer, AI, staff, or campaign — lives in one unified conversation history per lead, in Postgres.

2. Current architecture

3. Database tables / entities

All tables are clinic_id-scoped except Identity tables (scoped via clinic_users). Since 2026-10-07 each table lives in the Postgres schema of its area (core.clinics, crm.leads, …; table in CLAUDE.md → “Database schemas”); the names below are unqualified.

Table Purpose
clinics Tenant root. name is shown in the sidebar. Also address, operating_hours, consultation_info (free text, read by AI get_clinic_info).
procedures Clinic service catalog: name, code, description, consultation_duration (minutes), is_active. Managed at /Procedures — §12. Never hard-deleted.
leads A person of interest. source (free text, no CHECK), external_lead_id (dedup), status, qualification_status (both CHECK-constrained), procedure_id, last_contact_at, marketing_opt_in/opted_out_at (the contactability flags — there is no do_not_contact/contact_consent column), campaign_name (acquisition campaign — NOT related to outbound SculptFlow campaigns).
conversations One per (lead, channel). mode (ai/human/approval), last_customer_message_at, service_window_expires_at (24h WhatsApp window — §10). CHECK on channel: whatsapp,instagram,website,facebook,sms,email,telegram (Telegram: external_thread_id = chat id).
messages Every message, one table: direction, sender_type, origin, channel, message_type, content, external_message_id (idempotency), delivery status + timestamps, failure_code/reason, metadata_json, whatsapp_template_id, campaign_id, campaign_recipient_id.
appointments Consultations. status: booked, confirmed, attended, no_show, canceled, rescheduled.
procedure_bookings A lead considering/booked/completing a procedure.
events Audit/analytics log (real FKs to leads/conversations/appointments/clinics).
channel_integrations Per-clinic connection config for WhatsApp/Instagram/Facebook (acts as “whatsapp_connections”). CHECK channel in ('whatsapp','instagram','facebook','telegram'). Fields include access_token (Telegram: bot token), webhook_verify_token (Telegram: webhook secret), phone_number_id, whatsapp_business_id, page_id, pin, health fields, and Telegram’s telegram_bot_id, telegram_bot_username, webhook_status, webhook_registered_at — §23.
whatsapp_templates Message templates (status/category CHECKs dropped).
campaigns Outbound campaigns. campaign_type (free string), channel (CHECK whatsapp,instagram,messenger), whatsapp_template_id (nullable), audience_type (CHECK all_eligible,reactivation_no_consultation,custom), audience_filters (jsonb), status (draft,scheduled,running,paused,completed,cancelled,failed).
campaign_recipients Audience snapshot, one row per lead per campaign (UNIQUE(campaign_id, lead_id)). status: pending,queued,sent,delivered,read,replied,booked,failed,skipped; skip_reason, failure_code, failure_reason, external_message_id, appointment_id, replied_at, booked_at, plus sent/delivered/read/failed timestamps; message_id → the outbound messages row.
whatsapp_health_events Append-only WhatsApp health history.
knowledge_documents Staff-entered knowledge (title, category, content, is_active) — §11.
knowledge_chunks Embedded slices of documents; embedding vector(1536) — §11.
knowledge_search_settings One row per clinic of KB retrieval/embedding settings — §11.
clinic_users Links an Identity user to a clinic (UNIQUE(clinic_id,user_id) + UNIQUE(user_id) WHERE is_active).
identity_users, identity_user_claims, identity_user_logins, identity_user_tokens ASP.NET Identity tables, role-free, snake_case.
billing.plans, billing.plan_entitlements, billing.subscriptions, billing.accounts, billing.rate_cards, billing.rates, billing.usage_records, billing.ledger_entries, billing.channel_account_settings Subscriptions & usage billing — §30, in their own Postgres schema billing. Plans/rate cards are global (not clinic-scoped); the rest is per clinic.

Clinic demo-clinic = abb02743-4563-4c5a-91e2-80508fb25a77 (“Demo Aesthetic Clinic”). Seed data (Database/seed.sql): 5 procedures (Rhinoplasty, Breast Augmentation, Facelift, Liposuction, Tummy Tuck) and 5 test leads.

4. Multi-tenant model

Two independent resolution paths that terminate at clinic_id:

LOGIN:    Authenticated User → clinic_users (is_active=true) → clinic_id      (dashboard, pages, dashboard APIs)
WEBHOOK:  Meta phone_number_id / WABA id → channel_integrations → clinic_id   (WhatsApp inbound)

5. Authentication / login

6. WhatsApp integration

7. Webhook GET/POST flow

Meta posts directly to .NET (n8n is not in the inbound path):

Meta ──GET/POST──> Controllers/Integrations/WhatsAppWebhookController.cs   /api/integrations/whatsapp/webhook   (no auth)
GET  → hub.mode/hub.verify_token/hub.challenge; token == config Meta:WebhookVerifyToken → echo challenge (200) else 403
POST → raw JSON → IMetaWebhookProcessor.ProcessAsync
         → MetaWebhookParser (only place that knows Meta's entry[].changes[].field shapes)
         → resolve clinic (phone_number_id → waba_id fallback)
         → Handlers/: CustomerMessage, BusinessAppEcho, MessageStatus, TemplateEvent, HealthEvent,
                       History, AppStateSync, Unknown  (thin adapters over existing services)
         → ONE flat WhatsAppWebhookResponse (…, ShouldRunAi, ClinicId, ConversationId, LeadId, MessageId, …)
       if ShouldRunAi → IAiTriggerNotifier calls n8n; always HTTP 200 to Meta.

Interactive replies normalize to messageText = visible title, selectedValue = stable Meta id. WhatsAppIntegrationEventsController keeps POST templates/events and POST health/events (ingest-key protected). Not implemented: Meta X-Hub-Signature-256 body-signature verification.

8. n8n / AI flow

Meta → .NET (classify + process) → n8n (normalized trigger, only when AI should reply) → AI Agent → AI tools → .NET
Tool Endpoint Responsibility
search_clinic_knowledge POST /api/ai/knowledge/search Clinic-approved information: hours, address, parking, policies, consultation info, pricing, payment/financing, doctors, procedure explanations, preparation, recovery, FAQs — §11
get_clinic_info GET /api/ai/clinic-info Hours/address/contact/consultation rules from clinics. Kept, but partly redundant with the KB; may be retired later (decision deferred).
get_procedures GET /api/ai/procedures?clinicId= Structured procedure records (id, name, active, duration) for booking. Always ACTIVE procedures only — no activeOnly parameter (a stray activeOnly=false is ignored). Stays; KB explains, this is authoritative.
get_lead_context GET /api/ai/leads/{leadId} What’s known about this lead
update_lead PATCH /api/ai/leads/{leadId} Interest, language, timeline, notes, follow-up, qualification (no identity fields). Rejects an inactive/foreign procedureId (400).
get_available_slots GET /api/ai/appointments/available?clinicId=&procedureId=&date=&days= Live availability from the structured schedule (§24)
book_consultation POST /api/ai/appointments/book Real booking; inactive/foreign procedure → 400
reschedule_consultation / cancel_consultation POST /api/ai/appointments/{id}/reschedule|cancel  
handoff_to_human POST /api/ai/conversations/{id}/handoff Switch conversation to human

Rule of thumb (documented in AiController’s class comment): Knowledge Base = information; structured APIs = live data and actions. The KB is never the source of truth for lead data, availability, bookings, conversation state or handoff.

A second, separate n8n workflow exists only for the Retrieval Benchmark’s question generator (N8n:KnowledgeBenchmarkWebhookUrl, §11) — it is not the patient AI workflow and shares nothing with it.

9. SignalR

Hubs/InboxHub.cs, /hubs/inbox, [Authorize]; groups clinic:{id} (server-resolved) and conversation:{id}. Events (IInboxNotifier): NewMessage, MessageStatusUpdated, ConversationUpdated, ConversationModeChanged, WhatsAppTemplateUpdated, WhatsAppHealthUpdated. Consumers: inbox.js, whatsapp-templates.js, whatsapp-health.js.

10. Inbox / conversation modes

11. Knowledge Base (clinic-specific, semantic search for the AI)

Purpose: each clinic keeps its own approved information; the AI searches ONLY that clinic’s knowledge and composes the answer itself. .NET stores, chunks, embeds, searches and isolates; n8n/AI decides when to search and what to ask. Two ways in: manual entry or upload ONE PDF/DOCX/TXT (see “Document upload” below) — no OCR, batch upload or crawling.

Tables (Database/schema.sql, applied live):

pgvector: create extension if not exists vector (v0.8.2). Vector index type = none (exact scan) — deliberate: every search is already narrowed by clinic_id (btree index), a clinic has hundreds of chunks, exact scan has perfect recall, and HNSW would apply the clinic filter after its approximate scan and can drop results. Add using hnsw (embedding vector_cosine_ops) only if one clinic reaches tens of thousands of chunks (and then update vector_index_type).

Embeddings (IEmbeddingService → OpenAiEmbeddingService, any OpenAI-compatible POST {BaseUrl}/embeddings):

Chunking (IKnowledgeChunkingService.Chunk(content, chunkSizeTokens, chunkOverlapTokens)): tokens are approximated as 4 characters per token (no tokenizer). Split on blank lines → long paragraphs on sentences → over-long sentences at word boundaries → greedily pack up to max (≥200 chars) → merge a tiny trailing chunk (Knowledge:ChunkMinChars, default 200, system-wide) into the previous → each chunk after the first is prefixed with a word-aligned tail of the previous (overlap). A short document is one chunk.

Settings — knowledge_search_settings (one row per clinic; UNIQUE(clinic_id) = ux_knowledge_search_settings_clinic_id; lazily created with system defaults by IKnowledgeSettingsService.GetAsync, race-safe; updated_at trigger):

Column Default Editable in UI/API?
embedding_model text-embedding-3-small (from Embeddings:Model) Read-only
vector_dimension 1536 (from Embeddings:Dimensions) Read-only
similarity_method cosine Read-only
vector_index_type none (shown as “None — exact scan”) Read-only
chunk_size_tokens 250 (= old 1000 chars) Editable, 50–1000
chunk_overlap_tokens 38 (≈ old 150 chars) Editable, 0 … size/2
top_k 5 Editable, 1–10
minimum_similarity 0.30 (from Knowledge:MinScore) Editable, 0–1

DB CHECKs enforce the ranges (ck_knowledge_search_settings_*; overlap < size). No secrets are stored in this table. UpdateKnowledgeSettingsRequest has only the 4 editable fields, so read-only ones can’t be changed via form or API (verified by tamper tests). The Embeddings__*/Knowledge__* config keys are now only defaults for a clinic’s first row; after that the persisted row is what’s used. Changing the embedding model/dimension is unsupported from the UI (needs re-embedding everything + altering vector(N)).

Search (IKnowledgeSearchService): read settings → embed query with the clinic’s model/dim → select … from knowledge_chunks c join knowledge_documents d … where c.clinic_id=@clinic and d.clinic_id=@clinic and d.is_active order by c.embedding <=> @q limit @n → drop rows below minimum_similarity → score = 1 - cosine distance rounded to 4 dp. Result count = clamp(limit ?? top_k, 1, top_k) — top_k is the clinic’s ceiling; the AI’s limit can only lower it. Nothing relevant → {"results":[]} (no fabricated answer). Inactive documents never appear.

Rebuild/re-index behavior: creating/updating a document computes embeddings before opening the transaction, then swaps chunks transactionally (a failed embedding call leaves the old chunks intact). Saving an entry always re-chunks + re-embeds with the current settings (that’s how new chunk size/overlap reach existing entries). Changing settings does not auto re-chunk existing entries — re-save each one; there is no bulk “reindex all” action (not implemented). Activate/deactivate does not re-embed; delete removes the chunks.

AI endpoint: POST /api/ai/knowledge/search ([RequireIngestKey]). Request { "clinicId": "...", "query": "...", "limit": 5 } (limit optional); response { "results": [ { "documentId", "title", "category", "content", "score" } ] }. 400 if clinicId/query missing; 503 if the embedding provider fails. No conversationId.

Document upload (pushed in 79a88a5): staff pick Manual Entry or Upload Document on /KnowledgeBase/Edit (tabs; “Upload Document” button on the list opens it preselected, ?mode=upload), or POST /api/knowledge/upload (multipart: file, optional title [defaults to file name], category, isActive). One file only; PDF, DOCX, TXT; max 5 MB (Knowledge:MaxUploadBytes, clamped to ≤25 MB) and max 250,000 extracted characters (Knowledge:MaxExtractedChars, guards decompression bombs/embedding cost). IKnowledgeService.CreateFromUploadAsync → IDocumentTextExtractor (Business/Engines/Knowledge/DocumentTextExtractor.cs): type is checked by extension and magic bytes (%PDF-, PK\x03\x04; TXT rejects NUL bytes; UTF-8/UTF-16 BOM/Latin-1 fallback); PDF via PdfPig 0.1.16 (ContentOrderTextExtractor), DOCX via DocumentFormat.OpenXml 3.5.1 (all paragraphs incl. table cells, no deleted text); KnowledgeTextNormalizer cleans (CRLF, control/zero-width chars, whitespace, ≤1 blank line). No text (scanned PDF/blank DOCX/empty TXT) → clear 400 “…does not contain readable text. Scanned documents are not supported yet.”; nothing is saved on any failure. Then the extracted text goes through the SAME CreateCoreAsync as a manual entry: clinic’s chunk settings → same embedding model/dimension → transactional chunk write → immediately searchable via the unchanged search_clinic_knowledge. Schema: knowledge_documents + source_type (manual|upload, CHECK), original_file_name, mime_type, file_size_bytes; extracted text lives in the existing content column. The original binary is discarded (only text + metadata kept), so re-saving never needs the file. Editing an uploaded document changes title/category/active only (service ignores a supplied content; the page shows the text read-only — re-upload to change it); re-saving re-chunks from stored content with the current settings; deactivate/delete behave as before. There is still no bulk “reindex all” action — re-save each document. List shows a Source column (Manual / Uploaded + file name). Not built: OCR, multi-file, legacy .doc, password-protected PDFs (reported as unreadable). Verified locally with generated PDF/DOCX/TXT plus 15 bad-file cases and two-clinic isolation (all 404 across clinics; upload ignores a posted clinicId).

Website scraping (pushed in 57cd713, UI refined in f9b306a/8580cd1): a STANDALONE ingestion subsystem in Integrations/Knowledge/WebScraping/ — a third way into the SAME knowledge_documents → knowledge_chunks → embeddings → search_clinic_knowledge pipeline (no second KB/vector/search). The crawler owns crawling, URL identity, fetching, extraction, page state, runs and change detection; the KB owns content, chunking, embeddings and search. Bridge = two small crawler-agnostic methods on IKnowledgeService: CreateFromSourceAsync / ReplaceSourceContentAsync (page text → the clinic’s normal chunk/embed settings). UI: /KnowledgeBase/Edit has a 3rd tab Website (URL, “Crawl website” | “Single page only”, category, active); the main /KnowledgeBase list shows each website as ONE record (its scraped pages are not listed there; the record has Open/Activate-Deactivate/Delete) and clicking it opens /KnowledgeBase/Websites/{id} (status, live counters, pages + failures, Re-scrape / Deactivate / Delete, run history; auto-refreshes while running). API (login, clinic from ICurrentClinicContext): POST/GET /api/knowledge/websites, GET …/{id}, GET …/{id}/pages?status=, POST …/{id}/rescrape|active, DELETE …/{id}. Tables (all clinic_id-scoped, cascade from clinic): knowledge_website_sources (unique (clinic_id, normalized_start_url), crawl_mode, category, is_active, status, boilerplate_block_hashes jsonb), knowledge_website_pages (unique (website_source_id, normalized_url), canonical_url, http_status, content_hash = SHA-256 of normalized extracted TEXT, etag, last_modified, status discovered|processing|indexed|unchanged|skipped|duplicate|failed|removed, failure_reason, knowledge_document_id → knowledge_documents ON DELETE SET NULL, duplicate_of_page_id, links jsonb, missing_count, removed_at; content_hash index is NOT unique), knowledge_website_scrape_runs (status pending|crawling|completed|completed_with_errors|failed, pages_discovered/processed/indexed/new/changed/unchanged/skipped/duplicate/failed/removed, error_summary); knowledge_documents + source_url, source_type CHECK now manual|upload|website. Background: request writes a pending run and enqueues its id (IWebsiteScrapeQueue, an in-process Channel); WebsiteScrapeWorker (hosted service) runs one crawl at a time in its own DI scope; state is in the DB; on startup runs left crawling are marked failed (“interrupted by restart”) and pending ones re-queued. Engine (WebsiteScrapeProcessor): 1) CRAWL breadth- first, level by level, limited concurrency, politeness delay, robots.txt; 2) ANALYZE (canonical/redirect aliases, site- wide boilerplate removal, text render + SHA-256, duplicate detection); 3) APPLY (unchanged → nothing touched and NO embedding call; changed → same document updated, only ITS chunks rebuilt; new → new document). URL identity (UrlNormalizer, the only place): http/https only, no credentials, lower-case host (IDN→punycode), default ports dropped, fragment removed, dot-segments/// collapsed, index.html|htm|php folded, trailing slash removed (root /), tracking params (utm_*, fbclid, gclid, msclkid, …) removed but other query params KEPT and sorted; www/non-www and http/https default ports = one site, a different explicit port = a different site. Same-site only; external links, images/CSS/JS/fonts/media/archives/XML are never fetched or stored; PDFs/Office files are recorded as skipped (“use Upload Document”). Dedup: canonical link (same-site only, ignored if it points at the home page from a deep page) and redirects make alias pages duplicate; identical text within one website → one owner (previous owner kept, else canonical/shallowest/shortest) — never across clinics. Extraction: AngleSharp (HTML5 parser, no regex on markup); main/[role=main]/article/known content containers else body; drops script/style/ noscript/hidden/aria-hidden/nav/aside/header/dialog/form controls and cookie/consent/popup/menu/breadcrumb/share widgets; keeps headings, paragraphs, lists (“- “), table rows (“a | b”), details/FAQ text, image alt text; footer text kept only for contact-looking lines; blocks repeated on ≥50% of ≥4 pages are stripped everywhere except the start page (its hashes persist per source for partial recrawls). Change detection: ETag/Last-Modified conditional requests (304 → unchanged, stored links keep the crawl going) + content hash. Removed pages: 410 → removed at once; 404 or “no longer linked” → removed after 2 consecutive complete crawls (never when a crawl was cut short by limits, or the start page failed); removed = row kept + document DEACTIVATED (never deleted); a returning page is re-activated, a hand-deactivated document is never re-activated by a crawl. Limits (Knowledge:WebScraping, clamped): MaxPages 100, MaxDepth 3, MaxConcurrency 3, RequestTimeoutSeconds 15, MaxResponseBytes 2 MB, MaxRedirects 5, PolitenessDelayMs 300, MaxRunMinutes 20, MinTextChars 80, MaxTextChars 200k, MaxQueryVariantsPerPath 5, MaxSourcesPerClinic 25; crawl-trap filters (calendar/filter/session params, repeated/deep paths, >4 query params, per-path variant cap). Blog listing/archive pages are excluded from the Knowledge Base (UrlNormalizer.IsListingUrl + a title check in WebsiteScrapeProcessor.Analyze, added after the Retrieval Benchmark showed misses caused by category/tag/author/ pagination pages — teaser text like “482 Views 0 Comments … Read More”, no real answer to anything): a page whose URL has a category/categories/tag/tags/author segment, or a page/<digits> segment (e.g. /blog/page/2/), OR whose <title> contains “Archives” (most blog themes title these pages “… Archives - Site”), is classified skipped (WebsitePageStatus.Skipped) — same as noindex — instead of becoming a document. Important: the check runs AFTER the page is fetched, not before, specifically so its OWN links are still discovered and followed — filtering it out pre-fetch would silently lose any article only ever linked from a listing page (verified live: two articles reachable only via /category/… and /blog/page/2/ remained indexed after this change). A page that was already indexed and later reclassified this way (title changed, or it simply wasn’t caught before) goes through the SAME Outcome.Skipped path as any other skip: its knowledge_documents row (and chunks) is deleted via DropDocumentAsync on that crawl — no separate cleanup step, a normal Rescrape is enough. Verified live against a fake site (category/tag/author/page-N URLs, a title-only case, a normal page whose title was flipped to “…Archives…” mid-test and correctly lost its document on the next crawl) plus 19 UrlNormalizer.IsListingUrl unit checks (segment-exact match, case-insensitive, no false positive on e.g. /my-tag-cloud/ or /pages/about/). SSRF (SsrfGuard): http/https + ports 80/443 only; localhost/.local/.internal/single-label names blocked; DNS answers must ALL be public (private, loopback, link-local incl. 169.254.169.254, CGNAT, multicast, IPv6 ULA/link-local, mapped/6to4/ Teredo/NAT64 forms); checked before every request AND again in the socket connect callback (anti-DNS-rebinding); redirects followed manually and re-validated, leaving the site is refused; no proxy/cookies. Only in the Development environment DevAllowedHosts (exact host:port) bypasses this, for tests. robots.txt is respected (SculptFlowBot group else *, longest match, */$, Crawl-delay ≤5 s); an unreachable/5xx robots.txt stops the crawl. Deactivating a source hides all its documents; activating restores only pages that are in the KB; deleting removes the source, its rows and the documents it created (manual/upload/other sources/other clinics untouched). Not built: JS rendering (JS-only pages are skipped as “no readable text”), sitemap reading, OCR, authenticated sites, batch imports, per-source limits, concurrent crawls. Tested end-to-end against a purpose-built fake site (single page, multi-page, URL variants, alias/duplicate content, unchanged/changed/new/removed recrawls, external/assets, 500/404/timeout, robots/noindex, SSRF forms, tenant isolation, restart recovery).

Dashboard: /KnowledgeBase (list: title, category, Active/Inactive, last updated; Edit / Activate-Deactivate / Delete; “Add Knowledge” and “Settings” buttons), /KnowledgeBase/Edit/{id?} (title, category, content, active), /KnowledgeBase/Settings (4 editable inputs; 4 clearly disabled read-only fields with a “Read-only” badge and explanation; help text that chunk changes apply on next save). Sidebar link “Knowledge Base”. API (KnowledgeController, login required): GET/POST /api/knowledge, GET/PUT/DELETE /api/knowledge/{id}, POST /api/knowledge/{id}/active, GET/PUT /api/knowledge/settings.

Files: entities KnowledgeDocument(+KnowledgeCategory labels)/KnowledgeChunk/KnowledgeSearchSettings; services IEmbeddingService+OpenAiEmbeddingService, KnowledgeChunkingService, KnowledgeService, KnowledgeSearchService, KnowledgeSettingsService, VectorLiteral; Entities/Responses/Knowledge/KnowledgeDocumentResponse.cs; Pages/KnowledgeBase/{Index,Edit,Settings}.

Testing done (with the local fakeembed stub, not a real key): chunking, ranking, top-k ceiling, min-similarity, inactive exclusion, empty results, edit/deactivate/reactivate/delete, cross-clinic isolation, unique constraint, CHECK constraints, provider-down → 503, persisted model/dim really used. Real OpenAI key: now set on Render and confirmed working by the owner. Real similarity scores are distributed differently than the local stub’s — tune minimum_similarity against the Retrieval Benchmark (below) rather than by guess.

Retrieval Benchmark (Knowledge Base → Benchmark, /KnowledgeBase/Benchmark) — pushed (773f562, 0655dff, a98e33c, b41c997, d717f20)

Measures ONLY retrieval (question → query embedding → the existing pgvector search → ranked chunks). It does not call the patient AI agent, does not judge answers, and no LLM scores anything — scoring is deterministic (ids + ranks). The only AI involved is the separate n8n workflow that WRITES the benchmark questions.

12. Procedures management

13. Campaigns / reactivation

Backend model (built earlier, extended): Campaign = orchestration + audience definition; CampaignRecipient = the audience snapshot created at creation time (never re-derived). Every send goes through MessageService.SendCampaignTemplateAsync and writes a normal Message in the lead’s own Conversation — no CampaignMessage table, ever. Batches of 20 (ProcessBatchAsync); delivery/read/failed webhooks roll into the recipient. Templates must be approved (WhatsAppTemplateStatus.Approved).

Audience (single source of truth: ICampaignAudienceService/CampaignAudienceService) — the ONLY place eligibility is computed (never duplicated in JS; UI counts call the API):

Campaign creation UI (/Campaigns/Create, Create.cshtml(.cs), campaign-audience.js, multi-select.js): “Who do you want to reach?” — four clinic-facing cards (no internal enum names shown), mapped onto the 3 backend audience types via two hidden fields (AudienceType, ManualSelection):

  1. Old leads who never booked — default, “Recommended” badge, accent border. Controls: Inactive for 30/60/90 days, Procedure (all/specific, active only), optional Lead source. Big “N leads ready for reactivation” count + Preview leads.
  2. All contactable leads — “N contactable leads” count + Preview; no filters.
  3. Build a custom audience — Procedure + Inactive for by default; Advanced filters ▾ collapsed: lead status, qualification status, lead source, appointment status (searchable checkbox dropdowns with chips — reusable multi-select.js), created/last-contacted date ranges, country, city. “N matching leads” + Preview.
  4. Select leads manually — the original search + checkbox list (audience_type=custom + explicit LeadIds).
    • Counts refresh (350 ms debounce) from the preview API. Status/category values shown with friendly labels via Common/Helpers/FilterLabelHelper.cs (“No-show”, “Needs human review”, …); stored values unchanged. _StatusHelp.cshtml + status-help.js = a “?” modal explaining Lead status, Qualification status, Lead source, Appointment status (also on the Lead edit page).
    • Limitation: body variables ({LeadFullName} etc.) only work with manual selection (they need the lead list up front); audience-based campaigns with variables are rejected with a clear message.
    • Fixed along the way: an invalid nested <form> in the old page broke the submit button; “Send later” crashed (Npgsql needs UTC DateTimeOffset) — now ToUniversalTime().
    • Test-data gotcha: a real send attempt (even one Meta rejects) creates a Conversation for the lead, which makes them count as “prior interest” — clean up test conversations. Meta’s sandbox rejects non-allow-listed numbers (“recipient not in allowed list”).

14. Staff-editable fields & dashboard pages

15. Deployment (Docker / Render / GitHub)

16. Implemented features (chronological, this engagement)

  1. WhatsApp Templates + Campaigns + 24h service-window enforcement
  2. AI tool surface + Clinic Info page
  3. WhatsApp phone registration (PIN) on Embedded Signup
  4. Webhook event routing (Inbox delivery states, Templates, Health) → consolidated raw Meta webhook + parser/processor/handlers; smb_message_echoes, history, smb_app_state_sync
  5. Identity + clinic_users + CurrentClinicContext; all pages/controllers off browser clinicId
  6. DB uniqueness constraints; Meta now calls .NET directly (n8n only gets AI triggers); interactive message normalization
  7. Campaign/reactivation data model (audience types, filters, recipient snapshot, attribution columns)
  8. Campaign creation UI: audience picker (4 choices), advanced filters, previews, help modal
  9. Staff editing of lead/qualification/source/appointment status + list search
  10. Docker + Render deployment; GitHub repo; rename to MySculptFlow; login polish
  11. Knowledge Base (docs, chunks, pgvector embeddings, semantic search, dashboard, AI tool) + per-clinic persisted settings page
  12. Procedures management page with inactive-procedure rules; AI get_procedures active-only
  13. Knowledge Retrieval Benchmark (§11): standalone diagnostic page/API/worker measuring the real retrieval (strict chunk + document metrics, MRR, stale handling, run history with settings snapshots, async n8n generation + Stop generating) — pushed (773f562, 0655dff, a98e33c)
  14. A real Jay Clinic run (20 cases) found 13/20 misses, all content-driven (blog “Archives” listing pages and Arabic pages, not a retrieval bug — see §11); fixed by excluding listing/archive pages from the crawler and boilerplate chunks from benchmark sampling (§11) — pushed (abb4779)
  15. Appointments month calendar replacing the flat /dashboard/appointments list (§14): day-cell summaries, “+N more”, a right-side day drawer, reusing the existing appointment detail page and create/booking service — pushed (662daa7), plus the needs-outcome banner (1831195)
  16. Inbox mode-change dividers (“Handed from AI to staff” / “Returned to AI”, §10) — pushed (e98146e)
  17. Structured availability + AI booking tools: get_available_slots with booking context, schedule_consultation, cancel_consultation, get_my_appointments, structured results, live calendar updates, AiTestController (§24) — pushed
  18. Staff notification bell (§25) — pushed (66a13b4, dc4b967)
  19. Calendar Integrations, one-way Google/Outlook sync via n8n (§26) — pushed (895af7f)
  20. Subscriptions & usage billing + the first automated test project (§30) — branch feature/subscription-billing, not committed

17. Pending work

18. Important constraints / decisions

19. Current API endpoints

Dashboard-facing (login required; clinic from ICurrentClinicContext):

Platform admin (X-Platform-Admin-Key = PlatformAdmin:ApiKey, actor X-Admin-Actor; called by the SculptFlowAdmin portal; §30): billing is everything under /api/platform-admin/billing/* — plans, entitlements, rate cards + rate versions, clinic subscription start/cancel/resume/renew, wallet top-ups/adjustments, usage, refunds, ledger, reconciliation, quote, report, provider billing per connected channel account (GET provider-billing, GET clinics/{id}/channel-accounts, PUT channel-accounts/{id}/provider-billing, POST channel-accounts/{id}/provider-billing/reset).

Trusted server-to-server ([RequireIngestKey] / X-Ingest-Key): POST /api/messages/ingest, POST /api/integrations/whatsapp/templates/events, POST /api/integrations/whatsapp/health/events, the calendar callbacks POST /api/calendar-integrations/connect-callback|sync-callback (§26), and the AI tools under /api/ai/* (§8, incl. POST /api/ai/knowledge/search). Current /api/ai appointment routes (§24): GET appointments/available, GET appointments/upcoming, POST appointments/schedule, POST appointments/cancel, and the deprecated POST appointments/book|reschedule; test-only GET /api/ai/test/codes, POST /api/ai/test/schedule|cancel.

Public (Meta): GET/POST /api/integrations/whatsapp/webhook. Public (Telegram, secret-token protected): POST /api/integrations/telegram/webhook/{connectionId}. Dashboard adds POST /api/channel-integrations/telegram/connect|refresh (disconnect uses the existing …/{channel}/disconnect).

Razor pages (login required except Login/Register/Error): /dashboard, /dashboard/leads, /dashboard/leads/{id}, /dashboard/appointments, /dashboard/appointments/{id}, /inbox, /Procedures, /Procedures/Edit/{id?}, /KnowledgeBase, /KnowledgeBase/Edit/{id?}, /KnowledgeBase/Settings, /KnowledgeBase/Websites/{id}, /KnowledgeBase/Benchmark, /KnowledgeBase/Benchmark/Generations/{id}, /Campaigns, /Campaigns/Create, /Campaigns/{id}, /WhatsApp/Templates, /WhatsApp/Health, /settings/integrations, /settings/calendar-integrations, /settings/clinic-info (General | Availability tabs), /Staff, /settings/billing (only shown while billing is on), /Account/Login|Register|Logout.

20. Current configuration keys (names only — values are secrets or defaults)

Secrets (local: dotnet user-secrets in PlasticSurgery/PlasticSurgery/; Render: env vars with __):

Non-secret (appsettings.json, overridable by env): (Clinic:DefaultSlug was removed — registration no longer uses a default clinic; a leftover env var is harmless); Meta:AppId, Meta:GraphApiVersion (v21.0), Meta:WhatsAppLoginConfigId, Meta:FacebookLoginConfigId; Embeddings:BaseUrl (https://api.openai.com/v1), Embeddings:Model (text-embedding-3-small), Embeddings:Dimensions (1536 — must equal the vector(N) column); Knowledge:MaxUploadBytes (5242880), Knowledge:MaxExtractedChars (250000); Knowledge:WebScraping:* (crawler limits — see §11; DevAllowedHosts is Development-only, leave empty); Knowledge:MinScore (0.30), Knowledge:ChunkMaxChars (1000), Knowledge:ChunkOverlapChars (150), Knowledge:ChunkMinChars (200) — the first three are defaults for a clinic’s first settings row only; ChunkMinChars is system-wide. Billing:Enabled (false), Billing:Currency (USD), Billing:GracePeriodDays (7), Billing:ReservationTimeoutHours (72), Billing:MaintenanceIntervalMinutes (5), Billing:SignupPlanCode (empty), optional Billing:WhatsApp:* pricing rules, optional Billing:ProviderBilling:Defaults ({channel}_{provider} or {channel} → provider billing responsibility) (§30). Render sets PORT itself; the Dockerfile sets ASPNETCORE_ENVIRONMENT/ASPNETCORE_URLS.

21. Migrations (all in Database/schema.sql, applied live to Supabase, chronological)

  1. Service-window columns; whatsapp_templates, campaigns, campaign_recipients; messages template/ campaign links; origin CHECK + 'campaign'
  2. clinics address/operating_hours/consultation_info
  3. channel_integrations.pin
  4. messages status timestamps/failure/metadata; channel_integrations health fields; whatsapp_templates extras + CHECKs dropped; whatsapp_health_events
  5. events real FKs
  6. Identity tables + clinic_users
  7. Unique partial indexes: channel_integrations.phone_number_id, clinic_users(user_id) where is_active
  8. Campaigns + old-lead reactivation model: campaigns + campaign_type, channel (CHECK), audience_type (CHECK), audience_filters jsonb, whatsapp_template_id nullable, status CHECK + paused, index on campaign_type; campaign_recipients + external_message_id, appointment_id FK, skip_reason, failure_code, replied_at, booked_at, error_message → failure_reason, status CHECK + replied, booked, skipped, partial indexes
  9. Knowledge Base: create extension vector; knowledge_documents; knowledge_chunks (vector(1536), no ANN index)
  10. knowledge_search_settings (unique clinic_id, 4 CHECKs, trigger) — applied live and on main

  11. Telegram: channel_integrations + telegram_bot_id, telegram_bot_username, webhook_status, webhook_registered_at; telegram added to the channel CHECKs (channel_integrations, conversations) and telegram_customer to ck_messages_origin; partial unique ux_channel_integrations_telegram_bot_id (connected rows only) and ux_conversations_clinic_channel_thread — applied live and on main
  12. KB document upload: knowledge_documents + source_type (default manual, CHECK manual|upload), original_file_name, mime_type, file_size_bytes — applied live and on main
  13. KB website scraping: knowledge_website_sources, knowledge_website_pages, knowledge_website_scrape_runs (+ indexes/CHECKs/triggers); knowledge_documents + source_url, source_type CHECK adds website — applied live and on main
  14. Retrieval Benchmark: knowledge_retrieval_benchmark_cases (expected doc/chunk ids as plain columns, hash/preview, stale flags, unique (clinic_id, expected_chunk_id, lower(question))), …_runs (metrics + settings snapshot + progress), …_results (per-case ranks/passes/classification, retrieved_json, generation_id snapshot; case FK on delete set null), …_generations (id = generationId; status pending|completed|failed|cancelled, sent chunks, counts, rejected items, raw reply, error) — applied live to Supabase (idempotent block at the end of schema.sql); the Render database is the same Supabase DB, so no separate step; generation_id uuid (+ partial index) added to …_cases in a second small block; ck_kbg_status widened to include cancelled (Stop generating); generation_id uuid (+ partial index) added to …_runs and ck_kbr_case_scope widened to include generation (run-one-generation) — all applied live
  15. Inbox unread tracking: conversations.last_read_at (existing rows backfilled as read) — applied live and on main
  16. Structured availability (§24): clinic_availability_rules, clinic_booking_settings, clinic_availability_exceptions — applied live and on main
  17. Notifications (§25): notifications (type CHECK, clinic/created_at index, partial unread index) — applied live and on main
  18. Calendar Integrations (§26): calendar_integrations, calendar_integration_calendars, appointment_calendar_syncs (+ last_operation column added in a follow-up block) — applied live and on main

  19. Subscriptions & usage billing (§30): btree_gist extension; schema billing with billing.plans, billing.plan_entitlements, billing.subscriptions, billing.accounts (backfilled for every clinic), billing.rate_cards, billing.rates (exclusion constraint incl. provider_billing + immutability trigger), billing.usage_records (charge_status + provider_outcome, final-row guard trigger), billing.ledger_entries (append-only trigger, seq identity), billing.channel_account_settings (provider billing override per connected channel account) — applied to Supabase by Mohammad on 2026-10-06 (after billing merged to main as 4f67100)
  20. Schema split + configuration overrides (2026-10-07, branch refactor/architecture): every table moved out of public into core, identity, crm, scheduling, channels, marketing, knowledge, activity (the top of schema.sql creates the schemas and moves tables still in public with alter table … set schema; data, indexes, FKs and triggers move with them); config.settings (settings by section + key, read through IConfigManager, one typed property per setting, with constant defaults in ConfigDefaults; 56 settings moved there from appsettings and constants (incl. Billing:Currency and Embeddings:BaseUrl/Model/Dimensions/ApiKey; the API key is a masked secret setting and must be entered on the Configuration page, the Embeddings__ApiKey env var no longer applies), seeded by Database/seed-config.sql, so env vars such as Billing__Enabled no longer apply: set them on the admin portal’s Configuration page; see docs/configuration.md); the campaign_recipients column rename is now guarded so the file re-runs cleanly. NOT applied to Supabase yet. To apply: back up, run schema.sql, and deploy the main app and the admin portal together (both map the new schemas; an old build of either breaks the moment the tables move). Tested: old database + seed → new schema.sql twice gives the same structure as a fresh database (pg_dump -s identical).

No migration was needed for Procedures, lead/appointment editing, or the audience UI.

22. Known TODOs

23. Telegram integration (direct Bot API) — IMPLEMENTED and pushed (566ca90); Render env vars set and working per the owner

Telegram is a channel adapter alongside WhatsApp; nothing about the WhatsApp webhook/parser/sender/templates/ health/coexistence was changed (only the shared send path was made channel-routable — see below).

Telegram ──POST──> TelegramWebhookController  /api/integrations/telegram/webhook/{connectionId}   (anonymous)
   connectionId = channel_integrations.id ; row must be channel=telegram AND status=connected else 404
   X-Telegram-Bot-Api-Secret-Token must equal the row's stored secret (constant-time) else 403
   clinic_id comes from the STORED row, never from the payload
   → TelegramWebhookProcessor → TelegramUpdateParser (only place that knows Telegram JSON)
   → ILeadService.GetOrCreateByExternalIdAsync  (lead = "telegram:{chatId}", Phone stays NULL, Source="telegram", SourceDetail="@username")
   → IConversationService.GetOrCreateForLeadAsync(…, externalThreadId = chat.id)
   → IMessageService.IngestAsync (unchanged: persist → conversation fields → SignalR → AI eligibility)
   → if AiEligible (text, mode=ai, not a duplicate) → IAiTriggerNotifier (same payload as WhatsApp, channel="telegram")
Outbound: n8n/dashboard → MessageService → IChannelSender (by conversation.Channel) → WhatsAppChannelSender | TelegramChannelSender → Bot API sendMessage

24. Structured appointment availability (Clinic Info → Availability) — pushed (1ef7168 + follow-ups)

24b. AI rescheduling / duplicate-booking guard — pushed (a6f761b, 535f220)

24c. book_consultation structured result — pushed (387bcc2)

POST /api/ai/appointments/book now returns HTTP 200 for every business outcome with BookConsultationResult {success, code, message, instruction, appointment?, existingAppointment?} (n8n passes a normal result to the AI far more reliably than an error string; the AI kept claiming “booked” after generic error text). Codes: BOOKED (appointment in appointment), EXISTING_UPCOMING_APPOINTMENT (existingAppointment has id/scheduledStart/label), SLOT_UNAVAILABLE, LEAD_NOT_FOUND, INVALID_REQUEST; every failure’s instruction says nothing was booked and what to do next. Malformed bodies still 400, bad key 401. Breaking for the n8n tool description: success is success:true + appointment.id, no longer the bare appointment. Reschedule/cancel still use HTTP 409/404 (not yet converted). Success returns appointment in clinic-local time (scheduledStart like 2026-09-29T16:00:00+03:00, plus label), never the stored UTC value — the AI would otherwise announce the wrong hour. UpcomingAppointmentResponse fields Start/End were renamed ScheduledStart/ScheduledEnd (affects get_my_appointments and existingAppointment); null fields are omitted from the book result.

24d. get_available_slots now carries the patient’s booking context (one call instead of get_my_appointments → get_available_slots) — pushed (5907eb1)

24e. Unified schedule_consultation (replaces book_consultation + reschedule_consultation for the AI) — pushed (5907eb1)

24f. cancel_consultation structured result — pushed (3207a58)

POST /api/ai/appointments/cancel?clinicId=&leadId=[&appointmentId=] now returns HTTP 200 for every business outcome with CancelConsultationResult {success, operation: canceled|none, code, message, instruction?, appointment?, existingUpcomingAppointments?} (previously a raw UTC appointment on success and 404/400/409 error strings on failure — the AI could misreport them). Codes: CANCELED (appointment in clinic-local time), NO_UPCOMING_APPOINTMENT, MULTIPLE_UPCOMING_APPOINTMENTS (list; retry with appointmentId), INVALID_REQUEST (not this patient’s appointment / already attended etc.). Every failure carries an instruction saying nothing was canceled. Implemented by AppointmentService.CancelConsultationAsync wrapping the unchanged CancelAsync (same ownership + status rules). Only success:true means it was canceled. Cancellation still needs explicit patient confirmation (tool description).

24g. appointmentId rules for schedule_consultation and cancel_consultation — pushed (6e33a15)

The AI’s appointment ids go stale (a real case: it re-sent the id of an appointment it had booked and canceled earlier, so a valid reschedule was refused). Rule, from the DB’s CURRENT upcoming appointments: 0 upcoming → schedule creates (any appointmentId ignored); cancel → NO_UPCOMING_APPOINTMENT. exactly 1 → the backend selects it itself and appointmentId is ignored completely (stale/garbage ids can’t matter). 2+ → appointmentId is required and must match one of the current upcoming appointments, otherwise INVALID_REQUEST and nothing is mutated (no id + consent → MULTIPLE_UPCOMING_APPOINTMENTS). Implemented in AppointmentService.ScheduleAsync and CancelConsultationAsync; the deprecated /book and /reschedule endpoints keep their old behavior.

24h. Live calendar updates — pushed (6e33a15)

The Appointments calendar refreshes by itself when an appointment changes (AI booking/reschedule/cancel, staff create, status change, another tab). IInboxNotifier.AppointmentChangedAsync sends AppointmentChanged {appointmentId, change: created|rescheduled|canceled|status_changed} to the clinic’s SignalR group (same /hubs/inbox hub and clinic-scoped groups the Inbox uses — the clinic comes from the logged-in session, never the client). AppointmentService sends it after the change is committed (Book/Reschedule after tx.CommitAsync; CreateAsync split into a notify-free CreateCoreAsync); a notification failure never fails the operation. wwwroot/js/appointments-calendar.js (connectLive) re-fetches the visible month via loadMonth (debounced 300 ms; also on reconnect); the open day drawer re-renders, the New Appointment form is untouched. The database stays the source of truth — a missed event only means the next load is current.

24i. AiTestController — canned fail/success codes for n8n testing (test-only, not for production traffic) — pushed (f672c89)

GET /api/ai/test/codes lists every code; POST /api/ai/test/schedule?code=<CODE> and POST /api/ai/test/cancel?code=<CODE> return the exact ScheduleConsultationResult/ CancelConsultationResult shape (including instruction wording) the real tools return for that code, with no database access at all — clinicId/leadId aren’t even parameters. Point an n8n tool node at these URLs instead of /appointments/schedule or /appointments/cancel to watch the AI agent’s reaction to a specific outcome (e.g. SLOT_UNAVAILABLE, CONFIRMATION_REQUIRED) without having to set up the real appointment state that would otherwise trigger it — then point it back at the real endpoint. Same [RequireIngestKey] auth as the rest of the AI surface. Instruction wording is generated by the new shared Common/Statics/AiResultInstructions.cs (ForSchedule/ForCancel), which AiController’s real schedule/cancel endpoints now also call — extracted so the test controller can never drift out of sync with production wording; verified the real endpoints still behave identically after the refactor. Schedule codes: BOOKED, RESCHEDULED, CONFIRMATION_REQUIRED, MULTIPLE_UPCOMING_APPOINTMENTS, SLOT_UNAVAILABLE, LEAD_NOT_FOUND, INVALID_REQUEST. Cancel codes: CANCELED, NO_UPCOMING_APPOINTMENT, MULTIPLE_UPCOMING_APPOINTMENTS, INVALID_REQUEST. An unknown/missing code → 400 listing the valid ones.

25. Notifications (staff notification bell) — pushed (66a13b4, dc4b967)

26. Calendar Integrations (Google/Outlook, one-way SculptFlow -> external) — v1 pushed (895af7f); v2 (SculptFlow owns OAuth directly) pushed in this commit; n8n’s remaining sync workflow not built yet

27. TikTok Login Kit (account connection only — not a messaging channel yet) — pushed (81a6b29)

================================================== WHAT TIKTOK LOGIN KIT ENABLES IN SCULPTFLOW RIGHT NOW ==================================================

================================================== WHAT IT DOES NOT ENABLE YET ==================================================

================================================== TENANT ISOLATION ================================================== tiktok_integrations has a unique index on clinic_id (one row per clinic, structurally enforced) and every read/ write resolves the clinic from ICurrentClinicContext (dashboard) or the OAuth state’s embedded ClinicId (callback, protected by IDataProtector so it can’t be forged or replayed under a different clinic’s session) — never a clinicId supplied directly by the browser. Verified live with a cross-tenant replay attempt (see Testing above).

================================================== INTEGRATION HEALTH ================================================== Reuses NotificationType.IntegrationUnhealthy directly (no schema change to notifications; Link points at /settings/integrations). Same “notify only on the transition into unhealthy” rule as WhatsApp/Calendar health — a connection that’s already flagged, or one that only ever succeeds, never gets a repeat notification. Discovery happens opportunistically: every time GetAsync runs (i.e. every Settings page load) on a Connected row, it checks the token’s expiry and refreshes proactively; if the refresh token is missing, expired, or TikTok rejects the refresh, the row flips to unhealthy right there. There’s no background job — discovery is tied to someone viewing the page, which is consistent with this being a low-traffic, staff-facing settings screen rather than something else depends on continuously.

================================================== TIKTOK SANDBOX CONFIGURATION NEEDED FROM THE OWNER ==================================================

  1. In the TikTok Developer Portal, your app’s Login Kit Client Key and Client Secret.
  2. The exact redirect URI to register for this app: {App:PublicBaseUrl}/tiktok-oauth/callback (e.g. https://sculptflowapp.onrender.com/tiktok-oauth/callback if that’s the domain App:PublicBaseUrl is set to — same consideration as the Google/Microsoft Calendar redirect URIs already documented in section 26).
  3. Confirm the Sandbox app has user.info.basic scope available/approved (it’s the standard minimal Login Kit scope, should already be on by default for a Sandbox app).
  4. Set TikTok:ClientKey (appsettings.json is fine, not secret) and TikTok:ClientSecret (user-secrets locally / TikTok__ClientSecret on Render) — same pattern already used for GoogleCalendar/MicrosoftCalendar.

================================================== MOVING TO PRODUCTION LATER ==================================================

================================================== WHAT WOULD BE NEEDED FOR TIKTOK DIRECT MESSAGES IN THE UNIFIED INBOX LATER ==================================================

28. “Continue with Google” sign-in / sign-up (SculptFlow’s own login) — built, tested, NOT pushed (awaiting explicit “push”)

29. WhatsApp through Infobip (MVP provider) — built, tested locally, pushed on branch feature/infobip-whatsapp-provider (not merged to main)

Why: SculptFlow can’t yet become a Meta Tech Provider, so the first client’s WhatsApp runs through SculptFlow’s own single Infobip account. Later SculptFlow switches back to Meta direct (the existing Embedded Signup path), which is why the provider is a seam and not a rewrite. Owner decisions (2026-10-04): one Infobip account for all clinics, one sender number per clinic, global switch (not per clinic), credentials in env vars, media later for the whole system, OK to lose Meta-only features (health push, coexistence, in-app signup) on Infobip.

Shape:

To go live: run the new schema.sql block on Supabase; Render env WhatsApp__Provider=infobip, Infobip__BaseUrl, Infobip__ApiKey, and App__PublicBaseUrl (needed for notifyUrl); register the clinic’s number as a WhatsApp sender in the Infobip portal; connect it in Settings; paste the card’s webhook URL as the sender’s inbound forwarding URL in Infobip.

TODOs: media download/upload; health via Infobip’s GET /whatsapp/1/senders/quality; HMAC webhook signing instead of the URL token; leads keyed by BSUID when Infobip sends no phone number (today logged as inbound_without_phone); TikTok messaging via Infobip (POST /tiktok/1/messages) behind a similar seam.

White-label rule (owner, 2026-10-05): clinics must never learn that WhatsApp runs through Infobip. Nothing a clinic can see may name Infobip: staff-facing errors, campaign/delivery failure reasons, the settings page (HTML, form and handler names), API JSON (Provider/ProviderSenderId are [JsonIgnore]), message metadata (media URL replaced by mediaId) and the webhook URL (/api/integrations/whatsapp/connections/{id}/events). Infobip details belong only in server logs, config and code. Keep it that way in any follow-up (templates, media, TikTok).

Templates through Infobip (2026-10-05, same branch): Business/Contracts/Providers/WhatsApp/IWhatsAppTemplateProvider.cs is the template-review seam, picked by the same WhatsApp:Provider switch. MetaWhatsAppTemplateProvider holds the old Graph code unchanged; InfobipWhatsAppTemplateProvider submits to /whatsapp/2/senders/{sender}/templates (adds body examples from VariablesJson or “Sample n”, maps buttons) and syncs with GET. Status updates arrive on POST /api/integrations/whatsapp/account/events?token={Infobip__WebhookToken} (Infobip sends template events per account, not per sender), matched to the clinic by template id. New column whatsapp_templates.provider (null = meta): an approval only holds on the account it was reviewed on, so sending, Sync and the campaign template list only use templates of the active provider, and an old one gets “create it again”. Switching back to Meta: set WhatsApp__Provider=meta; Meta templates (provider null) work exactly as before, clinics reconnect with Facebook sign-in (an Infobip row shows as not connected on the Meta card), and templates approved through Infobip must be recreated. Infobip portal: subscribe the template-update event to the account URL.

30. Subscriptions & usage billing — built and tested on branch feature/subscription-billing (2026-10-05, provider billing responsibility 2026-10-06), NOT committed

What it is: an internal module (PlasticSurgery/Billing/, guide docs/billing.md, HLD docs/system-design.html §2.6) that separates the subscription (plan: price, period, entitlements, monetary included credit) from usage (communication charged per billable event, priced from versioned rate cards, paid from included credit first and then a prepaid wallet). Mohammad’s brief called the product “the new Omni”; the design deliberately avoids the enterprise Omni’s bundle / service / feature subscription hierarchy, per-channel quotas, stored procedures and billing microservices.

Off by default: Billing:Enabled=false → nothing is rated or charged, every clinic is entitled to everything, the worker idles, the Billing sidebar link is hidden. Production behaviour is unchanged until it is switched on.

Shape:

Tests: new PlasticSurgery.Tests (xUnit, in the solution). 86 tests: pure rules + database tests (wallet, concurrency, idempotency, crash-before-commit, rating hierarchy/specificity/versioning, subscriptions/renewal/grace/expiry, entitlements, WhatsApp end to end through the real MessageService with a fake provider, stale reservations, append-only ledger, every provider billing mode, two WhatsApp accounts with different arrangements, usage fee on a customer-paid account, absorbed cost, arrangement changes not re-pricing history, report split). DB tests need SCULPTFLOW_TEST_DB = a throwaway local PostgreSQL (remote hosts refused); without it they’re skipped. Run on this machine against a local PG 18 cluster (initdb in the session scratchpad, port 55433, trust auth): all pass, repeatedly. Every money test ends by checking the ledger chain and reconciliation. Also exercised end to end by running the app on a scratch DB (Staging env, no secrets): admin API (cards, rates, plans, top-ups, plan change, quotes, report), signup plan, Billing page.

Go-live (owner’s call): apply the §21 #19 block to Supabase → set PlatformAdmin__ApiKey (+ the same as MainApp__PlatformAdminApiKey on the admin portal) → create default rate card + rates and plans → set customer_direct on every WhatsApp account whose clinic pays Meta itself (all WhatsApp accounts default to platform_funded) → give every clinic a plan (+ top-ups) → optionally Billing__SignupPlanCode → Billing__Enabled=true.

Known gaps / postponed: no payment gateway (top-ups are admin actions), invoices, tax, postpaid (extension point = a credit limit in the spendable formula), multi-currency, reseller billing, proration, partial refunds, recurring number/seat fees; a delivery reported after its reservation timed out is not charged (logged); +1 numbers all resolve to US for pricing; pre-existing campaign double-send-on-crash gap is billed per real send.