ποΈ Architecture & Design
ποΈ 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
| Layer | Location | What lives there | Size (measured) | Evidence |
|---|---|---|---|---|
| Edge / request proxy | src/proxy.ts | Supabase session-cookie refresh on every non-static request; in QA builds, rewrites every address onto the harness | 37 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 components | 29 page.tsx/layout.tsx files query the DB directly | [Observed] grep -rlE "\.from\('" src/app --include=page.tsx --include=layout.tsx β 29 |
| API route handlers | src/app/api/**/route.ts | Mutations, token-gated public operations, cron jobs | 83 files, 7,872 lines, 125 handlers | [Observed] wc -l; BRIEF facts |
| Domain library | src/lib/*.ts | Pure business rules (parser, matcher, UTM, retention, disciplines, derivations) plus data loaders | ~100 modules; 67.1% line coverage | [Observed] ls src/lib; BRIEF facts |
| UI components | src/components/, co-located *.tsx | Client components; 16 client components also query or write Supabase directly | 24 files import the browser client | [Observed] grep results below |
| Data platform | supabase/baseline.sql + 92 migrations | 45 tables, 95 RLS policies, 9 SQL functions | 4,674 SQL lines | [Observed] grep -c "create table"/"create policy"/"create or replace function" supabase/baseline.sql |
| QA harness | src/app/qa/** | An in-memory imitation of every API route and of Supabase's REST, Storage and Auth surfaces, used by the Playwright journey suite | 5,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_nameis 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_commentspipeline,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 path | Count | Example | Assessment |
|---|---|---|---|
| Route: user client authorises, service client writes | 67 of 83 route files import createServiceClient | src/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 files | src/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 client | Acceptable where RLS is correct, but a second mental model. [Observed] |
| Browser writes directly to Postgres via the browser client | 16 client components query; at least 8 perform inserts/updates | utms/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 campaigns | Violates 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 resolveReviewCaller | Coherent. [Observed] |
| Gate helpers | 50 route files call canEdit*/canAccess*/canApprove/canReview/isStaff | src/lib/api-auth.ts:44-209 | Centralised, 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 restoringcopy_statusatroute.ts:196-199;card-writes.tsWrites.onewith 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_atcheck; two people editing the same unit last-write-wins. [Observed] noupdated_at/version predicate insrc/app/api/campaigns/[id]/ad-units/[unitId]/route.ts. Optimistic UI on the client rolls back on server error (convention inCLAUDE.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 intosrc/lib, which is why API code is 0.9% unit-covered whilesrc/libis 67%. - Worklist mega-function.
src/lib/for-you.tsforYouItems(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-50destructuredataand ignoreerror; 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
| Aspect | Implementation | Evidence |
|---|---|---|
| Request handling | Stateless Vercel serverless functions; no maxDuration or runtime override anywhere, so platform defaults apply | [Observed] grep -rn "maxDuration|runtime =" src/app β none outside QA |
| Background work | None in-process. Fire-and-forget is forbidden by convention; logActivity is awaited before responding | [Observed] src/lib/activity.ts header |
| Scheduled jobs | Two 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 transfer | Browser β 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 |
| Exception | POST /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 state | Optimistic UI with rollback; no realtime subscriptions | [Observed] no .channel(/realtime usage found |
| Concurrency control | None (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
| # | Path | Issue | Scale trigger | Evidence |
|---|---|---|---|---|
| P1 | Home worklist forYouItems | β₯7 sequential queries; for an admin, loads every ad unit of every live campaign plus comments, frames, files and tasks, unpaginated | Tens of live campaigns Γ hundreds of units | [Observed] src/lib/for-you.ts:100-170 |
| P2 | Supabase default 1,000-row cap | Several 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 |
| P3 | Purge cron, archived branch | unit_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 night | Archived 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) |
| P4 | Final grid / handover signed URLs | 3 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 |
| P5 | Bulk image drop | Per file, sequentially: storage upload, 1-year signed URL, frame lookup, frame write, unit write | Dozens of files per drop | [Observed] upload-previews/route.ts:87-189 |
| P6 | Large .in() lists | .in('campaign_id', [...all live ids]) / .in('id', toDelete) are sent in the URL; long lists approach URL-length limits | Hundredsβthousands of ids | [Inferred] cron/overdue/route.ts:48, purge-review/route.ts:144 |
| P7 | Handover zip in browser memory | Peak β 2Γ pack size in RAM; acknowledged in code | Packs of several GB | [Observed] handover-zip.ts:16-19 |
| P8 | Function-to-DB latency | Pages make several sequential queries; docs require Vercel functions to be co-located with the Supabase region | Mis-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.mdMigrations note;baseline.sql:1613. - Across tenants: see Β§9 β scale-out in customers is linear operational effort, not code.
8. Coupling and cohesion
| Coupling | Strength | Evidence |
|---|---|---|
| App β Supabase | Very 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 β data | Medium. 16 client components query/write Postgres directly; table/column names leak into the UI layer | [Observed] |
| Domain library cohesion | Good. Single-purpose modules with "one rule" ownership and co-located tests | [Observed] |
| Classic vs workspace shells | Medium. 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").
| Implication | Detail |
|---|---|
| Isolation (positive) | Strong data isolation; a breach or RLS mistake is confined to one client; per-client region choice |
| Migrations | Applied 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 order | CLAUDE.md records production outages caused by code selecting a column before its migration ran (063) |
| Configuration | Eight 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 |
| Upgrades | Each client must be redeployed and migrated separately; no fleet tooling in the repo |
| Cost | Fixed platform cost per client (Vercel project + Supabase project, Pro plan needed for >50 MB uploads) |
| Observability | None 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; plusgrep -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 (67route.ts);find src/app/api -name route.ts | wc -lβ 83.- Loop: route files without
createServiceClientthat 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; largestimport-copy436,review/files/[fileId]314,render-push260, 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 insrc/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.mdheadings. - Complexity, coverage and duplication figures: BRIEF measured facts (lizard, vitest coverage, jscpd).
β οΈ Risks
| ID | Risk | Severity | Effort to fix |
|---|---|---|---|
| A1 | Browser-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 | π High | M |
| A2 | No 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 | π High | M |
| A3 | Per-client deployments with manual, unledgered migrations; schema drift between clients and code-before-migration outages (already happened once) | π High | M |
| A4 | Unbounded queries silently truncated at 1,000 rows (worklist, purge archived branch, others); wrong results rather than errors as data grows | π‘ Medium | M |
| A5 | QA harness duplicates all 125 handlers; behaviour drift makes journey tests pass for the wrong reason and doubles the cost of every API change | π‘ Medium | L |
| A6 | QA route handlers (/qa/api/*, /qa/state, /qa/reset) are not gated in production builds | π‘ Medium | S |
| A7 | Signed-URL fan-out (3 Storage calls per file per page view, unbounded concurrency) and sequential per-file upload processing | π‘ Medium | S |
| A8 | Last-write-wins on concurrent edits of the same ad unit; silent overwrite of colleagues' changes | π‘ Medium | M |
| A9 | Dense, under-tested route handlers (CCN 44β66; API coverage 0.9%) concentrate business logic where it is hardest to test | π‘ Medium | M |
| A10 | Very 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 |
| A11 | Docs/code drift (one vs two crons; parked/orphaned tables) increases onboarding error rate | βͺ Low | S |
β Recommendations
| Priority | Recommendation | Effort |
|---|---|---|
| P1 | Route 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 columns | M |
| P1 | Move 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 atomically | M |
| P1 | Add a NEXT_PUBLIC_QA guard (or exclude src/app/qa from production builds) for every QA route handler | S |
| P2 | Introduce 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 migration | M |
| P2 | Paginate 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 reads | M |
| P2 | Replace per-file createSignedUrl with batched createSignedUrls; cap concurrency | S |
| P2 | Add optimistic concurrency (updated_at predicate, 409 on mismatch) to the ad-unit and brief PATCH routes | M |
| P3 | Extract rule logic from the densest handlers into tested src/lib modules; target API coverage alongside | M |
| P3 | Reduce harness duplication: run journeys against a local Supabase (Docker) with the real routes, keeping the store only for UI-only states | L |
| P3 | Introduce a thin data-access layer for the most-used tables (campaigns, ad_units, unit_files) to localise schema knowledge | L |
| P4 | Reconcile ARCHITECTURE.md with code (cron count, dependency notes) and delete parked dead modules already marked for deletion | S |