Shared rules (no commits, token saving, model choice): see the workspace root ..\CLAUDE.md.
docs/system-design.html must always match the code. Whenever a change adds, removes or alters a feature (pages/JS, API
routes or auth, service rules, SignalR events, workers, integrations/OAuth, n8n contracts, Database/schema.sql, config
keys), update it in the same change: section 12 maps change kinds to sections; update “Last verified against code” in the
header; add a change-log row in section 12; note any drift you find in section 11. Write from the code, not from
PROJECT_HANDOFF.md (can be stale). Keep it plain HTML + CSS, no build step, no external scripts.
UI: follow docs/UI_GUIDE.md for every screen; keep it up to date.
The admin portal (..\SculptFlowAdmin) uses the same layout. Namespace = folder path (PlasticSurgery.Business.Services.Leads).
| Folder | What goes there |
|---|---|
Controllers/Admin |
Platform-admin APIs (/api/platform-admin/{domain}, [RequirePlatformAdminKey]), called by the admin portal |
Controllers/Client |
Clinic dashboard APIs (signed-in staff); base DashboardApiController |
Controllers/Integrations |
Webhooks, n8n ingest and AI tool endpoints, OAuth callbacks |
Controllers/Filters |
Auth/filter attributes (RequireIngestKey, RequirePlatformAdminKey, ApiErrors) |
Pages/ |
Razor Pages (PageModels follow the controller rules) |
Hubs/ |
SignalR hubs |
Middleware/ |
ASP.NET Core middleware (RequestTimingMiddleware: per-route API timings) |
Business/Services/<Feature> |
What controllers and PageModels call |
Business/Engines/<Feature> |
Backend/background logic: webhook processing, parsers, billing rules, chunking, scoring, scraping |
Business/Managers |
Cross-cutting helpers with state/dependencies: current clinic, event log, live notifications |
Business/Providers/<Area> |
One adapter per external provider behind a shared interface |
Business/HttpClients/<Provider> |
Typed clients for external APIs |
Business/Jobs |
Hosted background workers and queues |
Business/Mappers |
Static entity → response mapping |
Business/Contracts/<same path> |
Interface of each class above, mirrored path |
Entities/Models |
EF table classes |
Entities/Requests/<Feature>, Entities/Responses/<Feature>, Entities/Dtos/<Feature> |
Input, output, other data shapes |
Common/Enums, Common/Statics, Common/Configs, Common/Helpers (static only), Common/Exceptions, Common/Converters, Common/Extensions |
Enums/string vocabularies, constants/cache keys/event names/SQL names, options classes, helpers, exceptions, JSON converters, DI extensions |
Persistence/Contexts |
DbContexts |
Persistence/Repositories/<Feature>, Persistence/Contracts/<Feature> |
Repositories + interfaces; IUnitOfWork/UnitOfWork at root |
Persistence/Helpers |
EF/Postgres helpers (DbErrors); nothing outside Persistence references EF Core or Npgsql |
Rules:
Type.Part.cs.GetByPhoneAsync), no generic
Repository<T>, never hand out IQueryable. Return entities (tracked if the caller will change them, ...ReadOnlyAsync
otherwise) or Dtos for read-only projections. Repositories never call SaveChanges; set-based statements
(ExecuteUpdate/ExecuteDelete, raw SQL, row locks) are fine when the method name says so.IUnitOfWork (SaveChangesAsync, TrySaveChangesAsync, BeginTransactionAsync,
DiscardChanges). One DbContext per request. Unique violation → DuplicateRecordException, exclusion violation →
OverlappingRecordException; never catch EF/Npgsql types in Business.IBillingUnitOfWorkFactory.Create() → IBillingUnitOfWork (own context + billing
repositories). Balances change only via Business/Engines/Billing/BillingLedger.PostAsync. See docs/billing.md.Business/Mappers/<Feature> (static).Contracts path and is registered against it in DI (repositories in Common/Extensions/PersistenceModule).[ApiErrors] maps exceptions (ArgumentException 400,
KeyNotFoundException 404, duplicate/overlap 409, InvalidOperationException 422, provider failures 502/503, body
{ "error": message }). Put it on new JSON API controllers (DashboardApiController has it), never on provider
webhooks (they must return 500 so the provider retries). Catch in a controller only when that action’s response must
differ, with a comment saying why.Every table lives in its area’s Postgres schema, never public (only set_updated_at() and extensions).
| Schema | Tables |
|---|---|
core |
clinics, procedures |
identity |
identity_users/_claims/_logins/_tokens, clinic_users |
crm |
leads, conversations, messages, inbox_settings |
scheduling |
appointments, procedure_bookings, availability rules/exceptions, clinic_booking_settings, calendar integrations and syncs |
channels |
channel_integrations, whatsapp_templates, whatsapp_health_events, tiktok_integrations |
marketing |
campaigns, campaign_recipients |
knowledge |
every knowledge_* table |
activity |
events, notifications, api_route_stats (API timings), app_logs (error log), message_trace_steps (message trace) |
billing |
plans, rate cards, subscriptions, accounts, usage, ledger, channel account settings |
config |
settings (configuration overrides) |
admin |
the admin portal’s own tables (SculptFlowAdmin repo) |
auth, storage, realtime,
extensions, graphql, vault: Supabase reserves them).Database/schema.sql, every ToTable, and raw SQL are schema-qualified (crm.leads). Changes stay re-runnable (guard
renames/constraint adds in do $$ blocks)./api/platform-admin), so a table change
only needs the main app’s code, plus the platform-admin API shapes if a portal page shows that data.Full detail and the declared-settings table: docs/configuration.md.
IConfigManager, one typed
property per setting named {Section}{Key}. Never add a private const or an appsettings key for a tunable value.ConfigDefinition in Common/Statics/ConfigDefaults and add it to
ConfigDefaults.All; (2) add the typed property to IConfigManager + ConfigManager; (3) read it through the property
each time it’s needed (don’t cache in a field); (4) add it to the table in docs/configuration.md and a row to
Database/seed-config.sql (insert ... on conflict (section, key) do nothing). ConfigSettingsTests fails if a
declared setting lacks a property or seed row.Embeddings/ApiKey), declared IsSecret: true. Every other
secret stays in user-secrets / environment variables, never in tracked files or appsettings.appsettings.json / Common/Configs options hold only remaining secrets (via user-secrets/env), per-environment values
(public URL, provider base URLs/client ids, connection strings) and structured rules (Billing:WhatsApp,
Billing:ProviderBilling).ICacheManager.GetOrCreateAsync(key, ttl, factory) only (never a static dictionary or IMemoryCache
directly). It sits on ICacheAdapter: MemoryCacheAdapter today; a Redis adapter registered in Program.cs replaces
it without other changes.Common/Statics/CacheKeys as area:thing:id, so an area can be dropped by prefix....ReadOnlyAsync reads: clinics, inbox settings, clinic memberships,
procedures, availability rules/settings/exceptions, calendar connections, channel connections, TikTok, WhatsApp
templates, rating cards, rates, channel account billing. Code that will change a row loads it tracked (uncached).
Their lifetimes are settings, one per CacheArea (Cache:*Minutes), passed as an area to CacheReads, never as a
fixed TimeSpan.Persistence/Helpers/CacheInvalidationInterceptor removes the keys
CacheKeys.EvictionsFor(row) names for every added/changed/deleted row after the save, and again when its transaction
commits or rolls back. A new cached table = keys in CacheKeys + a case in EvictionsFor. Set-based writes
(ExecuteUpdate/ExecuteDelete, raw SQL) bypass it, so their repository method removes the keys itself.CacheKeys comment naming who clears it.ConfigManager’s own snapshot (read synchronously everywhere); cache clear-all reloads it./api/platform-admin/cache (list, get, remove key/prefix, clear all) and the portal’s Cache page.The SculptFlowAdmin portal reads and writes everything about clinics through /api/platform-admin/{domain}; it has no
access to the main tables. Cross-clinic reads live in Persistence/Repositories/PlatformAdmin (+ Entities/Dtos/PlatformAdmin,
Business/Mappers/PlatformAdmin); Business/Services/PlatformAdmin delegates every write to the service a clinic uses
(so rules, provider calls and cache clearing happen once) and answers PlatformAdminChange { clinicId } for the portal’s
audit. Details never carry secrets. A new portal feature = endpoint here + client/service/page in the portal, with the
portal’s copy of the request/response shapes kept in step.
Do not read PROJECT_HANDOFF.md unless Mohammad asks (old Omni → new app migration notes, not being implemented).
Others: README.md (setup), docs/billing.md, docs/configuration.md, docs/system-design.html.