π§ Product & Domain Overview
π§ Product & Domain Overview
π Summary
Midshift is a web application that runs the production side of retail advertising campaigns: it takes a campaign from brief and media plan through copywriting, artwork, video edits, tracking links and approvals to a final "handover" grid that a client, media buyer or archive team can take away. Its central idea is that every advert ("ad unit") has one agreed name, and that name is used to join the copywriter's Word document, the designer's exported files and the app's own records, so files and copy land in the right place without manual sorting. The code shows a clear, opinionated product with a well-defined audience (an in-house retail marketing team plus its agencies and suppliers) and several genuinely distinctive features, notably a forgiving copy-deck parser and a shared filename-to-advert matcher. It is built to be installed once per client (one database and one hosting project each), not as a shared multi-customer service, and it has no translation layer: the interface is English only. Market position, pricing, customers and revenue cannot be judged from the repository.
π― Rating
π’ βββββ 4 / 5 β Good (Green). Purpose, domain model and user journeys are coherent and consistently expressed in code, schema, tests and UI copy; the score is held back from 5 by scope sprawl (two navigation shells, orphaned and removed features still present on disk, four campaign "kinds") and small schema/type mismatches in the role model.
π Findings
1. Purpose and problems solved
| Problem | How the product addresses it | Evidence |
|---|---|---|
| Copy arrives in free-form Word documents and is re-typed into ad tools | .docx/text "copy deck" import that auto-detects structured and free-form layouts, creates or updates ad units, carousel cards and Uber "Journey Ad" states, and shows a word-level diff before committing | [Observed] src/lib/copy-deck-parser.ts:824 (parseCopyDeck, 828 lines); src/app/api/campaigns/[id]/import-copy/route.ts; src/lib/word-diff.ts; baseline regression test src/lib/copy-deck-parser.baseline.test.ts:14-16 (22 units, 0 warnings) |
| Supplier and designer files arrive with arbitrary names | One filenameβunit matcher shared by every upload path, plus an "Asset names" manifest so suppliers name files correctly in advance | [Observed] src/lib/asset-match.ts (374 lines, 98% line coverage per shared facts); asset-manifest-modal.tsx referenced in CLAUDE.md |
| Approvals go stale silently when copy changes | Approved copy that changes (by edit or re-import) is reset to pending_review and a new "copy round" opens | [Observed] src/lib/approval-events.ts; documented behaviour of PATCH /api/campaigns/[id]/ad-units/[unitId] in docs/API.md:89 |
| Tracking (UTM) links built inconsistently | Account-level UTM template with tokens; one builder buildUtm | [Observed] src/lib/utm-template.ts:133 (100% coverage) |
| "Who is waiting on whom" is unclear across agency and client | Derived per-person worklist ("For you"), notifications, overdue sweep cron, briefs-waiting queue | [Observed] src/lib/for-you.ts (291 lines), /api/for-you, /api/cron/overdue, /api/briefs/waiting |
| Large video files and Photoshop files are awkward to review | Browser-side poster-frame capture for video and PSDβPNG flattening; only the derived image is uploaded | [Observed] src/lib/video-poster.ts, src/lib/psd-png.ts (ag-psd dependency) |
| Proof of custody at archive | Final grid, share link, "DAM ingest" confirmation and a retention purge that waits for both archive and ingest | [Observed] src/lib/retention.ts, /api/campaigns/[id]/dam-archive, /api/cron/purge-review |
The README's own framing is "the record that makes a sign-off mean something" and "nothing is stored as 'ready', 'approved' or 'live' that the artefacts don't prove" ([Observed] README.md:3-12). ARCHITECTURE.md states the invariant "Status is derived, never stored" and points to src/lib/campaign-derive.ts, src/lib/final-load.ts and src/lib/for-you.ts ([Observed] ARCHITECTURE.md:25-33). Those files exist and implement derivation on read.
2. Business domain and target users
Domain [Observed]: retail marketing campaign production across paid social, display, programmatic, ride-hailing and out-of-home. The channel enum is meta, tiktok, google, pinterest, triplelift, uber, ooh (supabase/baseline.sql:60). Campaigns have a kind of season | concept | rollout | ooh (supabase/baseline.sql:2091): a season groups jobs, a concept job holds the big idea and master video, rollouts adapt it to channels, and OOH jobs manage billboard/LED sites.
Geography [Inferred]: deployment and compliance documents analyse South African data-protection law (POPIA) and Vercel's Cape Town region (docs/NEW_DEPLOYMENT.md:110-127; design/COMPLIANCE.md:12-14), indicating a South African launch market. Not confirmable from code.
Users and roles [Observed]:
| Layer | Values | Source |
|---|---|---|
| Global tier (TypeScript) | admin | editor | reviewer | src/types/database.ts:3 |
| Global tier (database enum) | designer, copywriter, brand_manager, media_planner, account_manager, admin, super_admin, member, editor β no reviewer | supabase/baseline.sql:46-49 |
| Per-campaign tier | viewer | editor | admin | supabase/baseline.sql:104 |
| Discipline (what part of the job is yours) | brand manager, project manager, traffic, copywriter, proofreader, art director, designer, video producer, media buyer | src/lib/discipline.ts:19-22 |
| External, no account | Supplier (token upload page /upload/[token]), client / archive partner (token share page /preview/[token]) | src/app/upload/[token]/, src/app/preview/[token]/ |
The role model is coherent in intent (a tier says whether you may write; a discipline says which part), but the global tier is inconsistent between code and schema: the app accepts reviewer as a valid role in the invite and user-edit routes (src/app/api/admin/invite/route.ts:7, src/app/api/admin/users/[id]/route.ts:10) and gates several screens on it (e.g. units-page.tsx:172), but no migration adds reviewer to the user_role enum (searched all alter type ... add value statements). [Inferred] Assigning the reviewer role would be rejected by Postgres, so the "reviewer" experience the UI codes for is likely unreachable. The legacy enum values left by migrations 031β034 are also still present.
3. Differentiators evident from the implementation
| Differentiator | Assessment | Evidence |
|---|---|---|
| Copy-deck parser for messy real-world Word decks (section banners, bare-name units, colon headers, carousel card markers, placeholder stripping, audience cleaning) | Most distinctive IP; well tested (85% line coverage; baseline fixture e2e/fixtures/aw26-deck.txt) | [Observed] src/lib/copy-deck-parser.ts; rules documented in CLAUDE.md "The copy-deck parser" |
| Naming convention as join key + shared asset matcher + expected-filename manifest | Simple idea, consistently enforced in every upload path | [Observed] src/lib/asset-match.ts; buildName in src/lib/plan-change.ts; docs/NAMING_CONVENTION.txt |
| Rule-based "Ask" command bar (navigate to a section, open a unit, "what needs me"), with browser voice input; records only the class of question, never the sentence | Privacy-conscious and cheap to run; not an AI model β no LLM provider is called | [Observed] src/lib/ask-intent.ts:1-40; src/app/api/ask/route.ts; voice via Web Speech API at src/app/(app)/workspace/ask-sheet.tsx:130-131,159 |
| White-labelling: organisation name, logo and website are data set by an admin; product name from one env var; a "scrub" gate blocks client-identifying strings from the repository | Real and enforced in CI | [Observed] src/lib/branding.ts:14-19,168; src/lib/config.ts:13; scripts/scrub.js; CI step in .github/workflows/ci.yml |
| Discipline-scoped field permissions (a copywriter may change copy but not the link; per-field refusal) | Unusual granularity for this category | [Observed] canEditArea / canApprove in src/lib/api-auth.ts:174,193; 23 API route files use them |
| Retention tied to custody (files kept until both archived and confirmed ingested into the client's DAM, then 30 days) | Thoughtful governance feature | [Observed] src/lib/retention.ts; ARCHITECTURE.md Β§9 |
| Browser-side media processing (video poster frames, PSD flattening, artwork fit/render) | Avoids server compute and upload size limits | [Observed] src/lib/video-poster.ts, src/lib/psd-png.ts, src/lib/artwork/ |
Competitive differentiation versus established tools (e.g. creative-ops, DAM or ad-ops platforms) is [Unavailable] β requires market analysis and customer interviews.
4. Strategic objectives implied by the code
| Objective | What the code implies | Tag |
|---|---|---|
| Single-tenant, per-client deployments | No tenant/organisation key on any table (searched supabase/baseline.sql for tenant, org_id, organisation_id: only a comment at line 1729). Organisation identity is a singleton in settings (org_name, org_logo_path). ARCHITECTURE.md Β§2 and docs/NEW_DEPLOYMENT.md Β§1 state "one deployment per client β¦ never a fork". | [Observed] |
| Multi-tenancy (SaaS) | Not supported. Converting would require a tenant column on ~44 tables, RLS rewrite and per-tenant settings/branding. | [Inferred] |
| White-labelling | Achieved (see above). Retired env vars (NEXT_PUBLIC_CLIENT_NAME etc.) are no longer read (grep returns no hits in src). | [Observed] |
| Internationalisation | None. No i18n library in package.json; UI strings are hard-coded English throughout components; <html lang="en"> (src/app/layout.tsx:52). Date/number formatting mixes locales: toLocaleDateString('en-GB') (debrief-doc.tsx:55), toLocaleString('en-US') (build-view.tsx:128), with a comment in src/lib/utils.ts:33 recording a past en-AU formatter. No currency handling found. | [Observed] |
| Scale readiness | Designed for one organisation's team per deployment. Only one .range( pagination call and 16 .limit( calls in non-harness code; most lists load whole sets. Adequate for a single brand's campaigns; unproven for large archives. | [Observed] counts; capacity [Unavailable] β no load data |
| Cost-light runtime | No AI provider, no error tracker, no analytics; Vercel + Supabase + Resend only (README.md Architecture; ARCHITECTURE.md Β§2). | [Observed] |
| Product direction | "Product direction β¦ recorded outside this repository" (docs/README.md:31-33). Next build target named as cropping/export on the ad unit's Artwork segment (CLAUDE.md Parked decisions). | [Unavailable] beyond these notes |
5. Core user journeys
Navigation: signed-in users are redirected from / to the workspace shell (/workspace); the older "classic" dashboard survives at /?classic=1 and old /campaign/[id]/β¦ routes 308-redirect into the workspace (next.config.ts:23-33).
| # | Journey | Who | Screens (routes) | Key API routes |
|---|---|---|---|---|
| 1 | Sign in / accept invite / reset password | All | /login, /invite/[token], /accept-invite, /auth/reset-password | /api/invite/[token]/accept, /api/auth/forgot-password |
| 2 | Set up the organisation (name, logo, business units, campaign types, ad-format library, UTM template, users) | Admin | /workspace/admin, /workspace/admin/activity | /api/admin/* (organisation, business-units, categories, campaign-types, ad-formats, utm-template, invite, users) |
| 3 | Create a season / concept / rollout / OOH job | Admin, brand manager | "What is this?" sheet in src/app/(app)/workspace/ws-new-campaign.tsx:359; /workspace/campaigns | /api/campaigns/[id]/jobs, /api/campaigns/[id]/duplicate |
| 4 | Write and submit a brief; traffic/PM picks it up | Brand manager β traffic/PM | /workspace/campaign/[id]/brief, Home (/workspace) "Briefs waiting" | /api/campaigns/[id]/brief, /api/briefs/waiting, /api/briefs/waiting/[versionId]/take |
| 5 | Build the media plan, which creates named ad units | Traffic / media buyer | /workspace/campaign/[id]/plan, /dates | /api/campaigns/[id]/plan, /api/campaigns/[id]/dates |
| 6 | Concept & toolkit (big idea, shoot references, logos) | Art director, designer | /workspace/campaign/[id]/concept, /toolkit | /api/campaigns/[id]/files, /files/upload-url |
| 7 | Import copy deck, edit copy per unit/card, comment, approve | Copywriter, proofreader, brand manager | /workspace/campaign/[id]/ads (grid, tiles, unit panel) | /api/campaigns/[id]/import-copy, /ad-units/[unitId], /frames, /approve, /cap, /api/ad-units/[id]/comments |
| 8 | Drop artwork; files auto-match to units | Designer | /workspace/campaign/[id]/ads | /api/campaigns/[id]/upload-previews, /upload-asset, /render-push |
| 9 | Video edits: versioned review, timecoded comments, supplier upload via link | Video producer, supplier (no login) | /workspace/campaign/[id]/production, /upload/[token] | /api/campaigns/[id]/review/*, /upload-links, /api/upload/[token]/*, /api/review/* |
| 10 | Build tracking links | Media buyer | /workspace/campaign/[id]/utms | /api/admin/utm-template, ad-unit PATCH |
| 11 | OOH site plan | Traffic | /workspace/campaign/[id]/plan on an ooh job | /api/campaigns/[id]/sites, /sites/[unitId], /sites/suggest |
| 12 | Final grid, share link, archive and DAM-ingest confirmation | Traffic, client/archive partner (token) | /workspace/campaign/[id]/handover, /preview/[token] | /api/campaigns/[id]/share-link, /dam-archive, /api/preview/[token]/custody |
| 13 | Season wrap-up and carry recommendations forward | Brand manager | /workspace/campaign/[id]/wrapup (+ season strategy) | /api/campaigns/[id]/wrapup, /questions, /media-strategy |
| 14 | Oversight: job history, notifications, feedback, Ask | All / admin | /workspace/campaign/[id]/history, /workspace/notifications, /workspace/help, Ask sheet | /api/notifications, /api/feedback, /api/track, /api/ask, /api/for-you |
The help page's section titles mirror this flow exactly ("How a job flows", "One name for everything", "The media plan makes the ad units", "The copy deck", "Files match themselves", "Video edits and the supplier link", "Who does what", "UTMs and the Final grid", "Out-of-home") ([Observed] src/app/(app)/workspace/help/help-view.tsx), which is good evidence that the product's mental model is shared between code and user-facing copy.
6. Coherence and scope observations
| Observation | Tag |
|---|---|
Two shells co-exist: the "classic" /campaign/[id]/β¦ pages (now redirected) and the workspace. Pages for removed features (checklist, build, assets, members under src/app/(app)/campaign/[id]/) and e2e/checklist.spec.ts remain on disk, described in CLAUDE.md as "unreachable and to be deleted". | [Observed] |
An orphaned brief/review/sign-off pipeline (briefs, review_rounds, /timeline, /debrief) is kept "deliberately as groundwork". | [Observed] CLAUDE.md Parked decisions; src/app/timeline/ exists |
Features cut or moved out of the repository: Studio (to a separate repository on 2026-09-19), Present wall, Figma plugin, session replay. Their schema remnants (studio_crits, studio_crit_comments, campaign_share_links.audience_id, is_creative_approver) stay. | [Observed] CLAUDE.md; supabase/baseline.sql table list |
High rate of product change: CLAUDE.md carries 61 dated entries between August and 2026-09-28, many recording reversals. Indicates an evolving, owner-led product rather than a settled one. | [Observed] `grep -oE '20[0-9]{2}-β¦' CLAUDE.md |
Fixture "story" (src/app/qa/fixtures.ts:4-27) describes one live campaign with 14 ad units across seven channels in every state; it is a clear, invented, executable description of the domain. | [Observed] |
7. Market positioning and commercial facts
| Item | Status |
|---|---|
| Customers, contracts, revenue, pricing | [Unavailable] β requires commercial records |
| Number of live deployments | [Unavailable]; docs refer to "the live deployment" and "the trial deployment" in the singular (CLAUDE.md, ARCHITECTURE.md:97), suggesting [Inferred] one live client |
| Team size | [Inferred] one developer: "the repo has one developer" (ARCHITECTURE.md Β§8); git history squashed to a single commit (3789df6) so not verifiable |
| Ownership | Proprietary, "Matthew Prins, trading as Midshift" (LICENSE) |
π§Ύ Evidence
- Files read:
README.md,ARCHITECTURE.md(Β§1β3, 5, 8β9),CLAUDE.md,docs/NEW_DEPLOYMENT.md,docs/README.md,src/app/qa/fixtures.ts:1-30,src/lib/ask-intent.ts:1-40,src/lib/discipline.ts:19-32,src/lib/config.ts,src/lib/branding.ts,src/app/(app)/workspace/ask-sheet.tsx,src/app/(app)/workspace/ws-new-campaign.tsx,next.config.ts:23-33,supabase/baseline.sql(enums, tables). find src/app -name page.tsx| grep -v '/qa/'β 39 product pages (including the root/and the unreachable classic pages); the/qa/*` harness adds 24 more.find src/app/api -name route.tsβ 83 route files.grep -rln "i18n\|next-intl\|useTranslation" srcandpackage.jsonβ no i18n library.grep -rhoE "toLocale[A-Za-z]*\('[^']*'" srcβen-USΓ1,en-GBΓ1,en-AUΓ1 (comment).grep -n "create type user_role" -A3 supabase/baseline.sqlβ enum withoutreviewer;grep -rn "add value" supabase/migrationsβ no migration adds it.grep -oiE 'create table β¦' supabase/baseline.sql | sort -u | wc -lβ 44 tables.grep -rn "\.range(" src | grep -v qa/ | wc -lβ 1;.limit(β 16.- Shared facts from the brief: test coverage figures, cloc, single commit.
β οΈ Risks
| # | Risk | Severity | Effort |
|---|---|---|---|
| R1 | Single-tenant architecture limits a SaaS growth thesis: every new client is a new Vercel + Supabase project, manual SQL migrations per database, and per-deployment configuration. Operational cost grows linearly with clients. | π High (for a scale/SaaS thesis); Low (for a services/managed-deployment thesis) | L to convert |
| R2 | No internationalisation: English-only hard-coded strings and mixed locale formatting; entering non-English markets requires an extraction pass over ~465 source files. | π‘ Medium | L |
| R3 | Role model mismatch: reviewer is a valid role in TypeScript and API validation but not in the database enum; the reviewer experience is likely unreachable and an invite with that role likely fails. | π‘ Medium | S |
| R4 | Scope sprawl and residue: unreachable pages, orphaned tables and parked pipelines increase onboarding cost and the chance of building on dead paths. | π‘ Medium | M |
| R5 | Product knowledge held outside the repository (product direction, open decisions on an external "comment ledger", data-protection posture "available on request"). | π‘ Medium | S to M (obtain and file) |
| R6 | Concentration on one market's assumptions (South African POPIA framing, channel set incl. Uber Journey Ads and TripleLift) β [Inferred]; portability to other markets unverified. | βͺ Low | M |
β Recommendations
| # | Recommendation | Priority | Effort |
|---|---|---|---|
| 1 | Decide the commercial model (managed per-client deployments vs multi-tenant SaaS) before valuation; if SaaS, scope tenant-key retrofit and automated migrations as a funded workstream. | π High | S (decision) / L (build) |
| 2 | Fix the reviewer role: either add it to user_role via migration (and fold into baseline) or remove it from the type, validators and UI. Add a test that compares TypeScript unions with database enums. | π High | S |
| 3 | Delete the unreachable classic pages and specs already marked for deletion; document the orphaned tables as a schema-cleanup migration. | π‘ Medium | SβM |
| 4 | If non-English markets are in scope, introduce an i18n framework and a single date/number formatting helper; start with formatting (already partly centralised in src/lib/utils.ts). | π‘ Medium | L |
| 5 | Obtain and bring into the data room the externally held product direction, open-decision ledger and compliance posture. | π‘ Medium | S |
| 6 | Validate market positioning and customer evidence through commercial due diligence (customer calls, contract review). | π‘ Medium | M |