πŸ›οΈ Architecture & Design

πŸ“‹ Summary

Midshift is a single web application (a "modular monolith") built on Next.js and hosted on Vercel, with Supabase providing the database, logins and file storage. Each client gets its own copy: its own Vercel project and its own Supabase project, so client data is never mixed, but every upgrade, database change and configuration step must be repeated once per client by hand. The code is organised sensibly into pages, server request handlers and a shared library of business rules, and the business rules that matter most (deck parsing, file-name matching, link building) sit in well-tested, self-contained modules. The main structural weaknesses are inconsistency in where data is written (some screens write straight to the database from the browser, bypassing the server-side rules other screens go through), multi-step changes that are not wrapped in database transactions, a handful of very dense request handlers, and a large in-repository test double (src/app/qa/store.ts) that must be kept in lock-step with every API route by hand. At today's scale (one agency team per deployment) performance is adequate; several queries load whole tables without limits and will degrade, and in places silently truncate, as data grows.

🎯 Rating

🟑 ●●●○○ 3 / 5 β€” Amber. A coherent, conventional architecture with clear module boundaries and unusually well-documented intent, held back by inconsistent write paths, no transactional integrity for multi-row changes, per-client manual operations, and several unbounded queries that will not scale gracefully.

πŸ” Findings

1. Architectural style and boundaries

LayerLocationWhat lives thereSize (measured)Evidence
Edge / request proxysrc/proxy.tsSupabase session-cookie refresh on every non-static request; in QA builds, rewrites every address onto the harness37 lines[Observed] src/proxy.ts:5-8, src/lib/supabase/middleware.ts:7-62
Server-rendered pages (RSC)src/app/(app)/…, src/app/preview/[token], src/app/upload/[token]Pages that query Supabase directly on the server and pass data to client components29 page.tsx/layout.tsx files query the DB directly[Observed] grep -rlE "\.from\('" src/app --include=page.tsx --include=layout.tsx β†’ 29
API route handlerssrc/app/api/**/route.tsMutations, token-gated public operations, cron jobs83 files, 7,872 lines, 125 handlers[Observed] wc -l; BRIEF facts
Domain librarysrc/lib/*.tsPure business rules (parser, matcher, UTM, retention, disciplines, derivations) plus data loaders~100 modules; 67.1% line coverage[Observed] ls src/lib; BRIEF facts
UI componentssrc/components/, co-located *.tsxClient components; 16 client components also query or write Supabase directly24 files import the browser client[Observed] grep results below
Data platformsupabase/baseline.sql + 92 migrations45 tables, 95 RLS policies, 9 SQL functions4,674 SQL lines[Observed] grep -c "create table"/"create policy"/"create or replace function" supabase/baseline.sql
QA harnesssrc/app/qa/**An in-memory imitation of every API route and of Supabase's REST, Storage and Auth surfaces, used by the Playwright journey suite5,553 lines (store 3,349; dispatcher 456)[Observed] wc -l

The style is a modular monolith deployed as serverless functions: one codebase, one build, one deployment unit per client; no internal services, queues or event bus. [Observed] There is no separate service layer or repository layer between route handlers and Supabase: handlers and pages call supabase.from(...) directly (the five most-queried tables are campaigns Γ—103, ad_units Γ—97, users Γ—90, unit_files Γ—43, campaign_members Γ—28 call sites). [Observed] grep -rho "\.from('[a-z_]*')" src | sort | uniq -c. This is idiomatic for Next.js + Supabase and keeps code direct, but it means database shape is spread across ~170 files (see Module 13).

The domain library is the strongest part of the design: rules that are easy to get wrong are isolated and pure β€” copy-deck-parser.ts (85% covered), asset-match.ts (98%), utm-template.ts (100%), retention.ts, discipline.ts, placement.ts β€” and are explicitly declared as the "one rule" that every caller reads. [Observed] BRIEF coverage facts; ARCHITECTURE.md Β§1 invariants, verified against imports for asset-match (used by upload-previews, review files, supplier register and render-push).

2. Architecture diagram

3. Data model

45 tables in supabase/baseline.sql [Observed]. The core is campaigns β†’ ad_units β†’ (ad_unit_frames, unit_files, unit_comments, approvals); everything is scoped by campaign_id with on delete cascade, and access is governed by campaign_members through RLS. The diagram shows the 20 most important entities and their foreign keys (verified line by line from create table/references clauses; later-added FKs at baseline.sql:2092, :2115, :2172).

Design observations:

  • Derived, not stored, status. Campaign board status, the "final" file and each person's worklist are computed on read (src/lib/campaign-derive.ts, final-load.ts, for-you.ts). [Observed] This removes a class of stale-status bugs but moves cost to read time (see Β§6).
  • Name as join key. ad_units.ad_name is the join between copy decks, uploaded file names and records (src/lib/asset-match.ts). [Observed] A business-meaningful natural key used for matching is fragile by nature; the code compensates with careful normalisation and an exact-match-first rule.
  • Schema residue. Tables with no readers are kept deliberately (studio_crits, studio_crit_comments, app_errors, briefs/review_comments pipeline, checklist_items). [Observed] CLAUDE.md "Parked decisions"; ARCHITECTURE.md Β§10. Roughly a fifth of the schema is parked or orphaned, which adds cognitive load for a new team. [Inferred]

4. Pattern adherence and violations

Stated pattern (CLAUDE.md "Conventions"): writes RLS might reject go through an API route with getUser + canEditCampaign, then the service-role client. Measured adherence:

Write pathCountExampleAssessment
Route: user client authorises, service client writes67 of 83 route files import createServiceClientsrc/app/api/campaigns/[id]/ad-units/[unitId]/route.ts:31-73 (getUser β†’ canEditCampaign β†’ disciplineOf β†’ service)Follows pattern. [Observed]
Route: writes through the user client (RLS enforces)13 route filessrc/app/api/tasks/[id]/route.ts, src/app/api/admin/ad-formats/[id]/route.ts, src/app/api/campaigns/[id]/sections/route.ts; import-copy/route.ts:344,381,392 also writes ad_units/ad_unit_frames with the user clientAcceptable where RLS is correct, but a second mental model. [Observed]
Browser writes directly to Postgres via the browser client16 client components query; at least 8 perform inserts/updatesutms/utm-generator.tsx:155-175 updates ad_units UTM fields; ads/ad-units-grid.tsx:202,221,279; ads/ad-unit-panel.tsx:480; new-campaign-modal.tsx:107-200 and workspace/ws-new-campaign.tsx:159 create campaignsViolates the pattern. These writes bypass the server-side discipline gate (canEditArea) and the copy_status reset logic that the ad-unit PATCH route enforces; only the coarser RLS predicate (can_edit_ad_unit) applies. [Observed]
Token-gated public routes (service client, token check)5 routes (upload/[token]/*, preview/[token]/custody, review upload)src/lib/review-auth.ts resolveReviewCallerCoherent. [Observed]
Gate helpers50 route files call canEdit*/canAccess*/canApprove/canReview/isStaffsrc/lib/api-auth.ts:44-209Centralised, good. Every route without getUser is either token-gated, cron-gated, dev-only (NODE_ENV 404), or delegates to a helper (card-writes.ts cardContext). [Observed]

Other concrete examples:

  • Non-atomic multi-step writes. There is exactly one RPC call in the app (service.rpc('log_share_link_access'), src/app/preview/[token]/page.tsx:51); every multi-row change is a sequence of separate PostgREST calls. Campaign creation in the browser inserts the campaign, then sections, then phases, then audiences (new-campaign-modal.tsx:107-200) β€” a failure part-way leaves a half-built campaign. The copy import deletes a unit's frames then re-inserts them (import-copy/route.ts:381-392); a failed insert leaves units without cards (the code reports it, but cannot roll back). The codebase compensates with hand-written "undo" writes (e.g. ad-unit PATCH restoring copy_status at route.ts:196-199; card-writes.ts Writes.one with an undo closure). [Observed] This is compensating-transaction logic in application code, which is correct only as long as every path remembers it.
  • No optimistic concurrency control. The ad-unit PATCH reads current state and writes without a version/updated_at check; two people editing the same unit last-write-wins. [Observed] no updated_at/version predicate in src/app/api/campaigns/[id]/ad-units/[unitId]/route.ts. Optimistic UI on the client rolls back on server error (convention in CLAUDE.md), but cannot detect a concurrent overwrite. [Inferred]
  • Dense handlers. The ad-unit PATCH is only 245 lines but has cyclomatic complexity 66 and up to ~12 sequential database round-trips per request (auth Γ—3–4, campaign kind, concept job, current row, frames, template, update, frame updates, undo, two activity writes). [Observed] BRIEF lizard facts; route.ts:31-227. Similar density: brief PATCH (CCN 64), review file PATCH (59), render-push (53), jobs POST (52), cron purge (44). Business rules have leaked into handlers rather than into src/lib, which is why API code is 0.9% unit-covered while src/lib is 67%.
  • Worklist mega-function. src/lib/for-you.ts forYouItems (CCN 109) runs β‰₯7 sequential queries (for-you.ts:101-170), mixing user-client and service-client reads, and computes every person's Home list from raw rows each request.
  • Errors from reads are often discarded. e.g. for-you.ts:119-127, cron/overdue/route.ts:44-50 destructure data and ignore error; a failing query yields "nothing to do" rather than an error. The write side is guarded by a test (src/lib/write-results.test.ts), the read side is not. [Observed]

Positive patterns worth recording: a schema-vs-code column test (src/lib/schema-columns.test.ts), a write-result test, a harness/route contract check (scripts/harness-contract.js), consistent error/permission helpers, and very thorough "why" comments (12,361 comment lines in src). [Observed]

5. Concurrency and execution model

AspectImplementationEvidence
Request handlingStateless Vercel serverless functions; no maxDuration or runtime override anywhere, so platform defaults apply[Observed] grep -rn "maxDuration|runtime =" src/app β†’ none outside QA
Background workNone in-process. Fire-and-forget is forbidden by convention; logActivity is awaited before responding[Observed] src/lib/activity.ts header
Scheduled jobsTwo Vercel crons, both fail-closed on CRON_SECRET: purge-review (03:00) and overdue (07:00)[Observed] vercel.json; src/app/api/cron/*/route.ts:28-31
Large file transferBrowser ↔ Storage via signed URLs; the handover zip is built in the browser with fflate (stored, not deflated)[Observed] src/lib/handover-zip.ts:1-70
ExceptionPOST /api/campaigns/[id]/upload-previews buffers multipart files in the function; the client batches to 3.5 MB to stay under Vercel's ~4.5 MB cap[Observed] campaign-drop-zone.tsx:60; next.config.ts sets proxyClientMaxBodySize: '200mb' which does not lift the platform limit [Inferred]
Client stateOptimistic UI with rollback; no realtime subscriptions[Observed] no .channel(/realtime usage found
Concurrency controlNone (last write wins); idempotency handled ad hoc ("one statement: a retry after partial failure must not duplicate rows" β€” upload/[token]/register/route.ts:81)[Observed]

The ARCHITECTURE.md topology section still describes one cron (ARCHITECTURE.md Β§2 table), while vercel.json defines two. [Observed] Docs/code drift.

6. Performance-sensitive paths and bottleneck candidates

#PathIssueScale triggerEvidence
P1Home worklist forYouItemsβ‰₯7 sequential queries; for an admin, loads every ad unit of every live campaign plus comments, frames, files and tasks, unpaginatedTens of live campaigns Γ— hundreds of units[Observed] src/lib/for-you.ts:100-170
P2Supabase default 1,000-row capSeveral selects have no .range(); PostgREST silently returns the first 1,000 rows. Only 17 .range/.limit calls exist outside the harness>1,000 rows in one result[Observed] grep -rn "\.range(|\.limit(" src β†’ 17
P3Purge cron, archived branchunit_files ... .in('campaign_id', archived ids) is unpaged, contradicting the route's own warning about the 1,000-row cap; excess files are simply left for later runs. The first branch pages the whole unit_files table every nightArchived campaigns with >1,000 files; total file rows[Observed] src/app/api/cron/purge-review/route.ts:56-66 (paged full scan), :85-88 (unpaged)
P4Final grid / handover signed URLs3 individual createSignedUrl calls per final file, all in one unbounded Promise.all (no batch createSignedUrls, no concurrency limit)A 200-unit job = ~600 Storage API calls per page view[Observed] src/lib/final-load.ts:51-57; same shape src/lib/concept.ts:39-46
P5Bulk image dropPer file, sequentially: storage upload, 1-year signed URL, frame lookup, frame write, unit writeDozens of files per drop[Observed] upload-previews/route.ts:87-189
P6Large .in() lists.in('campaign_id', [...all live ids]) / .in('id', toDelete) are sent in the URL; long lists approach URL-length limitsHundreds–thousands of ids[Inferred] cron/overdue/route.ts:48, purge-review/route.ts:144
P7Handover zip in browser memoryPeak β‰ˆ 2Γ— pack size in RAM; acknowledged in codePacks of several GB[Observed] handover-zip.ts:16-19
P8Function-to-DB latencyPages make several sequential queries; docs require Vercel functions to be co-located with the Supabase regionMis-placed region[Observed] ARCHITECTURE.md Β§2 "Placement rule"

Bounded reads that are done well: activity feeds are limited (history/page.tsx:25 .limit(500), admin/page.tsx:55 .limit(300)), the import-copy updates run in bounded concurrent chunks (import-copy/route.ts:325-328, 8 at a time), and storage removals are chunked by 100 (purge-review/route.ts:134). [Observed]

Real-world latency, throughput and database size: Not measurable from repository β€” requires access to Vercel function metrics and the Supabase project(s). [Unavailable]

7. Scalability characteristics

  • Per tenant: sized for one agency team. Read paths assume the whole working set fits in one response (P1, P2); this is fine for tens of campaigns and will degrade, and in places truncate silently, past ~1,000 rows per query. [Inferred]
  • Horizontal: stateless functions scale automatically on Vercel; the binding constraints are Postgres connection/PostgREST throughput and Storage API call volume (P4), both governed by the Supabase plan. [Inferred]
  • Storage: review uploads >50 MB need the Supabase Pro plan; bucket cap 250 MB per file (migration 050) must be matched by a dashboard setting. [Observed] CLAUDE.md Migrations note; baseline.sql:1613.
  • Across tenants: see Β§9 β€” scale-out in customers is linear operational effort, not code.

8. Coupling and cohesion

CouplingStrengthEvidence
App ↔ SupabaseVery high. 168 of 465 src files (36%) import a Supabase client or package; 114 import the server client, 98 the service client, 24 the browser client; 23 use Storage directly. No repository/adapter layer[Observed] grep counts (see Evidence)
Routes ↔ QA harness🟠 High, by design. Every route (125 handlers) is re-implemented in src/app/qa/store.ts (3,349 lines) and listed in src/app/qa/api/[...path]/route.ts; the harness also imitates PostgREST, Storage and Auth. scripts/harness-contract.js catches a missing or invented route but not divergent behaviour; ARCHITECTURE.md Β§10 records three historic cases where the store kept an auth gate the routes had already changed, so tests passed "for the wrong reason". Every behaviour change costs two implementations[Observed]
UI ↔ dataMedium. 16 client components query/write Postgres directly; table/column names leak into the UI layer[Observed]
Domain library cohesionGood. Single-purpose modules with "one rule" ownership and co-located tests[Observed]
Classic vs workspace shellsMedium. Workspace composes classic page modules (ads/units-page.tsx, review/review-page.tsx); classic routes 308-redirect (next.config.ts:22-36). Dead classic modules (checklist, build) still in tree, flagged for deletion[Observed] CLAUDE.md Parked decisions

QA harness ships in production builds. The /qa/* pages are gated by notFound() in src/app/qa/layout.tsx:11, but layouts do not apply to route handlers. src/app/qa/api/[...path]/route.ts, src/app/qa/state/route.ts, src/app/qa/reset/route.ts and src/app/qa/demo/*/route.ts contain no NEXT_PUBLIC_QA check, and the production proxy does not block /qa. [Observed] They operate only on an in-memory fixture store (no production data or credentials), so the impact is exposure of invented fixture data and unneeded attack surface, but the code comment "this file never ships: the QA routes 404 unless NEXT_PUBLIC_QA=1" (src/app/qa/store.ts:157-158) is not true of the route handlers. [Inferred] that they answer on a production deployment; confirming requires a request against a live deployment.

9. Deployment model: single-tenant, one deployment per client

[Observed] ARCHITECTURE.md Β§2; docs/NEW_DEPLOYMENT.md Β§1 ("One codebase, many deployments β€” never a fork").

ImplicationDetail
Isolation (positive)Strong data isolation; a breach or RLS mistake is confined to one client; per-client region choice
MigrationsApplied by hand in the SQL editor or scripts/ops.sh sql, per deployment; no migration runner, no per-deployment ledger ("which migrations each deployment has run is prose in CLAUDE.md" β€” ARCHITECTURE.md Β§10). Several migrations are recorded as written-but-not-run (065, 088, 091). Drift between deployments is likely as client count grows [Observed]/[Inferred]
Code depends on migration orderCLAUDE.md records production outages caused by code selecting a column before its migration ran (063)
ConfigurationEight mandatory env vars per deployment plus CRON_SECRET; missing CRON_SECRET silently disables retention and overdue alerts; missing OWNER_EMAIL/GHOST_EMAIL silently disables owner protection
UpgradesEach client must be redeployed and migrated separately; no fleet tooling in the repo
CostFixed platform cost per client (Vercel project + Supabase project, Pro plan needed for >50 MB uploads)
ObservabilityNone per deployment beyond platform logs: no error tracker, uptime probe or alerting (ARCHITECTURE.md Β§9)

The model is a reasonable choice for a handful of enterprise clients but becomes the dominant operating cost beyond roughly 5–10 deployments without a migration runner and fleet-level deployment tooling. [Inferred]

🧾 Evidence

Commands run (from /home/user/midshift-audit):

  • grep -n "create table" supabase/baseline.sql β†’ 45 tables; grep -c "create policy" β†’ 95; grep -c "create or replace function" β†’ 9.
  • FK extraction per table via awk '/create table if not exists <t> \(/,/^\);/' + grep references; plus grep -nE "add column.*references" supabase/baseline.sql (lines 2092, 2110, 2115, 2172).
  • grep -rlE "@/lib/supabase/|@supabase/" src | grep -v ^src/lib/supabase | wc -l β†’ 168; per module: client 24, server 114, service 98, middleware 1.
  • grep -rl createServiceClient src/app/api | wc -l β†’ 68 (67 route.ts); find src/app/api -name route.ts | wc -l β†’ 83.
  • Loop: route files without createServiceClient that call insert/update/delete/upsert β†’ 13 files (listed in Β§4).
  • Loop: browser-client files with write counts β†’ 24 files, e.g. new-campaign-modal.tsx writes=5, ad-units-grid.tsx writes=5, utm-generator.tsx writes=2.
  • grep -rn "\.rpc(" src β†’ 1 call (src/app/preview/[token]/page.tsx:51).
  • grep -rn "\.range(\|\.limit(" src/app src/lib | grep -v src/app/qa | wc -l β†’ 17.
  • find src/app/api -name route.ts | xargs wc -l | sort -rn β†’ total 7,872; largest import-copy 436, review/files/[fileId] 314, render-push 260, ad-unit [unitId] 245.
  • wc -l src/app/qa/store.ts β†’ 3,349; find src/app/qa -name "*.ts*" | xargs wc -l β†’ 5,553.
  • grep -n "NEXT_PUBLIC_QA" src/app/qa/api/[...path]/route.ts src/app/qa/state/route.ts src/app/qa/reset/route.ts β†’ no matches (gate only in src/app/qa/layout.tsx:11).
  • Files read: src/proxy.ts, src/lib/supabase/{client,server,service,middleware,env}.ts, src/lib/api-auth.ts, src/lib/for-you.ts:95-175, src/lib/final-load.ts, src/lib/handover-zip.ts, src/app/api/cron/purge-review/route.ts, src/app/api/cron/overdue/route.ts, src/app/api/campaigns/[id]/ad-units/[unitId]/route.ts, src/app/api/campaigns/[id]/import-copy/route.ts (query lines), src/app/api/campaigns/[id]/upload-previews/route.ts (query lines), src/app/(app)/campaign/[id]/campaign-drop-zone.tsx:57-130, next.config.ts, vercel.json, ARCHITECTURE.md Β§Β§1–3, 9–11, docs/NEW_DEPLOYMENT.md headings.
  • Complexity, coverage and duplication figures: BRIEF measured facts (lizard, vitest coverage, jscpd).

⚠️ Risks

IDRiskSeverityEffort to fix
A1Browser-side direct writes to ad_units/campaigns bypass server-side discipline gates and approval-reset rules; business rules enforced in one write path are absent in another🟠 HighM
A2No database transactions for multi-row changes (campaign creation, copy import frames, card/cover writes); partial failures leave inconsistent records; correctness relies on hand-written undo logic🟠 HighM
A3Per-client deployments with manual, unledgered migrations; schema drift between clients and code-before-migration outages (already happened once)🟠 HighM
A4Unbounded queries silently truncated at 1,000 rows (worklist, purge archived branch, others); wrong results rather than errors as data grows🟑 MediumM
A5QA harness duplicates all 125 handlers; behaviour drift makes journey tests pass for the wrong reason and doubles the cost of every API change🟑 MediumL
A6QA route handlers (/qa/api/*, /qa/state, /qa/reset) are not gated in production builds🟑 MediumS
A7Signed-URL fan-out (3 Storage calls per file per page view, unbounded concurrency) and sequential per-file upload processing🟑 MediumS
A8Last-write-wins on concurrent edits of the same ad unit; silent overwrite of colleagues' changes🟑 MediumM
A9Dense, under-tested route handlers (CCN 44–66; API coverage 0.9%) concentrate business logic where it is hardest to test🟑 MediumM
A10Very high coupling to Supabase SDK across 168 files; no data-access abstraction makes a platform change or major SDK upgrade costlyβšͺ Low (Supabase is a sound choice)L
A11Docs/code drift (one vs two crons; parked/orphaned tables) increases onboarding error rateβšͺ LowS

βœ… Recommendations

PriorityRecommendationEffort
P1Route all browser-side writes (utm-generator, ad-units-grid, ad-unit-panel, both new-campaign modals) through API routes that apply the same discipline and approval rules; tighten RLS so direct table writes from the browser are refused for those columnsM
P1Move multi-row operations (campaign creation, copy import commit, frame/cover writes, plan confirm) into Postgres functions called via rpc() so they commit or roll back atomicallyM
P1Add a NEXT_PUBLIC_QA guard (or exclude src/app/qa from production builds) for every QA route handlerS
P2Introduce a migration runner and a per-deployment migration ledger table; script fleet-wide deploy + migrate; add a startup schema check that refuses to serve on a missing migrationM
P2Paginate or aggregate in SQL every read that can exceed 1,000 rows (start with for-you.ts, purge cron archived branch); read and surface error on readsM
P2Replace per-file createSignedUrl with batched createSignedUrls; cap concurrencyS
P2Add optimistic concurrency (updated_at predicate, 409 on mismatch) to the ad-unit and brief PATCH routesM
P3Extract rule logic from the densest handlers into tested src/lib modules; target API coverage alongsideM
P3Reduce harness duplication: run journeys against a local Supabase (Docker) with the real routes, keeping the store only for UI-only statesL
P3Introduce a thin data-access layer for the most-used tables (campaigns, ad_units, unit_files) to localise schema knowledgeL
P4Reconcile ARCHITECTURE.md with code (cron count, dependency notes) and delete parked dead modules already marked for deletionS