Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .claude/memory/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@ _Append only. Never delete entries._

---

## 2026-03-21 — revamp/phase-3: Superadmin role design
**Decision**: Added `superadmin` as a 4th valid role in Clerk `publicMetadata`. Superadmin bypasses all `proxy.ts` role-based routing. `requireAgencyAuth()` / `requireCreatorAuth()` / `requireBrandAuth()` return fixed test IDs (`test_agency_001` etc.) instead of `forbidden()` when role is `superadmin`. Perspective cookie (`active_perspective`) controls which portal UI renders and which home route `RoleSwitcher` navigates to. `RoleSwitcher` renders `null` for non-superadmin users — invisible to real users.
**Reason**: Single dev/QA account for testing all 3 role perspectives without logging in/out. Never visible to real agency/creator/brand users.

## 2026-03-21 — revamp/phase-3: lib/auth.ts replaces lib/auth-helpers.ts
**Decision**: `auth-helpers.ts` (hardcoded test IDs) deleted. `lib/auth.ts` is the canonical auth module for all API routes. All routes use the `requireXAuth()` pattern: returns `{ ok: true, userId }` or `{ ok: false, response }`. Caller returns `authResult.response` immediately on failure.
**Reason**: Phase 2 deliberately deferred real auth; Phase 3 activates it. The pattern is consistent and type-safe — no exceptions thrown, no Response thrown, just a discriminated union.

## 2026-03-21 — revamp/phase-3: Email sends are always async via Trigger.dev
**Decision**: Never `await` email sends in API route hot paths. Always use `void sendEmailJob.trigger(...)` (fire-and-forget). Creator email recipient uses `${creatorClerkId}@placeholder.dev` for MVP (Creator model has no `email` field per REQ-M2-001).
**Reason**: Email delivery latency must not block API responses. Placeholder email is acceptable for MVP since Resend will bounce silently; real email lookup via Clerk API is a post-MVP improvement.

---

## 2026-03-20 — revamp/phase-2: Hardcoded auth IDs (no Clerk)
**Decision**: Phase 2 uses hardcoded `test_agency_001`, `test_brand_001`, `test_creator_001` ClerkIDs in `lib/auth-helpers.ts`. No Clerk imports anywhere in Phase 2 code.
**Reason**: Eliminates auth service dependency during backend integration sprint; allows full API + DB testing without live Clerk session. All functions are tagged `// TODO(phase3): replace with real Clerk auth()`.
Expand Down Expand Up @@ -278,3 +292,17 @@ Why: Found during P1-2/P1-3 implementation. All interactive components updated a
### Zod v4 form validation
Decision: For forms with number inputs, use plain react-hook-form register() instead of zod coerce.number() to avoid type mismatch with @hookform/resolvers in Zod v4.
Why: z.coerce.number() resolver type inference changed in Zod v4, causing TypeScript errors.

## 2026-03-21 — Creator placeholder email for all email triggers
**Decision**: All email sends to a creator use `${creatorClerkId}@placeholder.dev` as the recipient address. All email sends to an agency use `${agencyClerkId}@placeholder.dev`.
**Reason**: The Creator and Agency models have no `email` field in the MVP schema. Placeholder addresses ensure the trigger.dev job fires and can be tested end-to-end without a real email column. Real addresses require a schema migration (database agent concern).
**When to revisit**: Add `Creator.email` and `Agency.email` fields in a future migration, then update all `@placeholder.dev` references in route handlers.

---

## 2026-03-21 — partnerships/[id]/route.ts: creator auth, not agency auth
**Decision**: `PATCH /partnerships/[id]` uses `requireCreatorAuth()`. The creator looks up their own `Creator` record by `clerkId`, then verifies the partnership request belongs to them via `{ id, creatorId: creator.id }`.
**Reason**: Partnership accept/decline is a creator action, not an agency action. The original phase-2 stub used `agencyClerkId` to scope the lookup (incorrect — agencies create requests, creators respond to them). Corrected in phase-3 migration.
**Alternatives considered**: Keeping agency auth and adding a separate creator endpoint (rejected — unnecessary duplication; PATCH on the same resource by the responding party is RESTful).

---
155 changes: 155 additions & 0 deletions .claude/memory/iterations.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,46 @@ _Append only. One entry per session or PR. Never delete._

---

## 2026-03-21 — revamp/phase-3: Auth + Superadmin + Email + Polish
**Type**: Feature
**Branch**: revamp/phase-3

### What changed
- **Real Clerk auth wired**: Replaced all `lib/auth-helpers.ts` calls (`getAgencyClerkId` etc.) with `requireAgencyAuth()` / `requireCreatorAuth()` / `requireBrandAuth()` in 7 remaining API routes. `lib/auth-helpers.ts` deleted. `lib/auth.ts` is now canonical.
- **Superadmin perspective switcher**: `RoleSwitcher.tsx` rebuilt as Clerk-based, superadmin-only (renders null for real users). Reads/writes `active_perspective` cookie instead of localStorage. Lazy state init avoids setState-in-effect.
- **ClerkProvider**: Added to `app/layout.tsx`; `RoleProvider` and `lib/role-context.tsx` deleted.
- **Header.tsx**: Now uses `useUser()` from Clerk; shows real user name and role-derived title.
- **Auth pages**: `/login`, `/signup`, `/signup/{agency,creator,brand}`, `/signup/complete` all built.
- **Email**: 8 remaining React Email templates created (`changes-requested`, `content-approved`, `payment-received`, `deadline-warning`, `partnership-request`, `partnership-accepted`, `partnership-declined`, `new-brief`). Fire-and-forget email triggers wired to 10 API events via Trigger.dev `sendEmailJob`.
- **Upload rate limiting**: `uploadRateLimit` applied to `POST /api/v1/deals/[id]/submissions`.
- **Polish**: Empty states on all 5 agency list pages; `loading.tsx` + `error.tsx` on all 3 authenticated route groups.
- **Smoke tests**: `e2e/smoke.spec.ts` (3 tests) + `e2e/helpers/auth.ts` perspective helpers.
- **CI/CD**: Clerk env vars added to `ci.yml` + `prod.yml` quality job.

### Quality gates
- `npm run typecheck` → 0 errors
- `npm run lint` → 0 errors (14 warnings, all pre-existing)
- `npm run test` → 119/119 passing
- `npm run build` → ✓ successful (all routes compile)

---

## 2026-03-21 — revamp/phase-3: DevOps & Smoke Tests
**Type**: DevOps / CI
**Branch**: revamp/phase-3

### What changed
- **`.github/workflows/ci.yml`**: Added `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY` from GitHub secrets to the `ci` job `env:` block so the Next.js build can resolve Clerk imports during type-check and build steps.
- **`.github/workflows/prod.yml`**: Same two Clerk env vars added to the `quality` job `env:` block for parity on every push to master.
- **`e2e/helpers/auth.ts`** (new): Cookie-based perspective helpers (`setAgencyPerspective`, `setCreatorPerspective`, `setBrandPerspective`) for superadmin bypass in Playwright tests.
- **`e2e/smoke.spec.ts`** (new): 3 API-layer smoke tests — full deal lifecycle route shape, brief submission endpoint shape, public discovery routes. Tests accept 401 on protected routes in CI (correct secure behavior) and assert `{ data, error }` response contract.
- **`playwright.config.ts`**: No changes needed — `testDir: './e2e'` already set; `baseURL` driven by `PLAYWRIGHT_BASE_URL` env var (defaults to Vercel deployment URL).

### Rationale
Phase 3 introduced real Clerk auth. CI was missing Clerk keys, causing build failures when Clerk modules initialise during the Next.js build. Full Playwright sign-in flow tests are deferred to M8 (require Clerk test mode); API-layer smoke tests cover critical path contracts without email-verification dependency.

---

## 2026-03-20 — revamp/phase-2: Backend Integration
**Type**: Feature
**Branch**: revamp/phase-2
Expand Down Expand Up @@ -492,3 +532,118 @@ Public:
- Removed now-empty `app/(brand)/briefs/` directories

**Result**: `/brand/briefs/new` serves the SubmitBriefForm correctly under the brand layout; no conflict with agency `/briefs/*` routes.

---

## 2026-03-20 — revamp/phase-3: proxy.ts and lib/auth.ts
**Type**: DevOps / Infrastructure
**Branch**: revamp/phase-3

### What changed
- Wrote `proxy.ts` at repo root — Clerk middleware replacing any prior middleware file. Implements public route bypass, unauthenticated redirect to `/login`, no-role redirect to `/signup/complete`, superadmin bypass, and ordered role guards (brand check before agency check to prevent `/brand/briefs/new` being caught by `/briefs(.*)` agency matcher).
- Wrote `lib/auth.ts` — four async auth guard helpers (`requireAgencyAuth`, `requireCreatorAuth`, `requireBrandAuth`, `requireAnyAuth`). Each returns `AuthResult`; `AuthFail.response` typed as `Response` (satisfied by `NextResponse` from `lib/api-response.ts`). `superadmin` role substitutes seeded test Clerk IDs for each role.

### Why
Phase 3 infrastructure baseline — middleware and server-side auth guards required before any new API routes or protected pages can be wired up safely.

---

## 2026-03-20 — revamp/phase-3: Auth Pages
**Type**: Feature
**Branch**: revamp/phase-3

### What changed
- Created `app/(public)/login/page.tsx` — renders Clerk `<SignIn>` with `routing="hash"`
- Created `app/(public)/signup/page.tsx` — role picker with 3 shadcn Cards (Agency, Creator, Brand Manager) linking to role-specific signup routes
- Created `app/(public)/signup/agency/page.tsx` — Clerk `<SignUp>` with `unsafeMetadata={{ role: 'agency' }}`
- Created `app/(public)/signup/creator/page.tsx` — Clerk `<SignUp>` with `unsafeMetadata={{ role: 'creator' }}`
- Created `app/(public)/signup/brand/page.tsx` — Clerk `<SignUp>` with `unsafeMetadata={{ role: 'brand_manager' }}`
- Created `app/(public)/signup/complete/page.tsx` — `'use client'` page; reads `unsafeMetadata.role`, calls `/api/v1/auth/set-role`, reloads session, then redirects to the role home route (`/dashboard`, `/creator/deals`, or `/brand/briefs/new`)
- Created `app/api/v1/auth/set-role/route.ts` — POST endpoint; validates role enum via Zod, applies `authRateLimit`, calls `clerkClient().users.updateUserMetadata` to write `publicMetadata.role`

### Why
Phase 3 auth shell: users can now register under a role, have that role persisted in Clerk public metadata, and be redirected to the correct role-scoped area of the app.

### Notes
- `lib/rate-limit.ts` (exporting `authRateLimit`) is a backend dependency to be created separately
- `set-role` route is in `app/api/v1/` — backend agent owns it; frontend agent wrote it per task spec

---

## 2026-03-21 — revamp/phase-3: Frontend Clerk Integration & UX Improvements
**Type**: Frontend
**Branch**: revamp/phase-3

### What changed
- `app/layout.tsx`: Replaced `<RoleProvider>` with `<ClerkProvider>` from `@clerk/nextjs`. Moved `<Toaster>` inside `<body>` as sibling to `{children}` (ClerkProvider wraps the html element now).
- `components/layout/Header.tsx`: Removed `useRole()` from `lib/role-context`. Now uses `useUser()` from `@clerk/nextjs`. Derives role title from `user?.publicMetadata?.role` with fallback to `'agency'`. Shows computed initials from `user?.fullName ?? user?.firstName` instead of hardcoded "AG".
- `components/layout/RoleSwitcher.tsx`: Full replacement. Now reads `active_perspective` cookie (not localStorage). Only renders for `superadmin` role. Uses `useUser()` from Clerk. Writes cookie on perspective change and navigates to role home.
- `components/layout/Sidebar.tsx`: Removed `import type { Role } from '@/lib/role-context'`. Inlined `type Role = 'agency' | 'creator' | 'brand_manager'` to break the dependency.
- `lib/role-context.tsx`: Deleted. No longer needed — Clerk is the source of truth for auth/role.
- `components/__tests__/foundation.test.tsx`: Removed the `RoleProvider` describe block that imported from `lib/role-context`.
- `app/(agency)/dashboard/page.tsx`: Added empty state when `deals.length === 0`.
- `app/(agency)/deals/page.tsx`: Added empty state when `deals.length === 0`.
- `app/(agency)/roster/page.tsx`: Added empty state when `creators.length === 0`.
- `app/(agency)/brands/page.tsx`: Added empty state when `brands.length === 0`.
- `app/(agency)/briefs/page.tsx`: Added empty state when `briefs.length === 0`.
- `app/(agency)/loading.tsx`: Created — Skeleton loading fallback for agency route group.
- `app/(creator)/loading.tsx`: Created — Skeleton loading fallback for creator route group.
- `app/(brand)/loading.tsx`: Created — Skeleton loading fallback for brand route group.
- `app/(agency)/error.tsx`: Created — Error boundary for agency route group.
- `app/(creator)/error.tsx`: Created — Error boundary for creator route group.
- `app/(brand)/error.tsx`: Created — Error boundary for brand route group.
- `components/ui/skeleton.tsx`: Created — shadcn Skeleton component (was missing from components/ui/).

### Pre-existing issues (not introduced by this session)
- `app/api/v1/briefs/route.ts` and `app/api/v1/partnerships/*.ts` import missing email templates (`emails/new-brief`, `emails/partnership-request`, `emails/partnership-accepted`, `emails/partnership-declined`). These are backend-owned files outside frontend scope.

### Why
Phase 3 frontend: migrate from mock `RoleProvider` to real Clerk auth, add route-group loading/error boundaries, add empty states for all agency list pages.

## 2026-03-21 — revamp/phase-3: Backend — Auth migration, email templates, email wiring
**Type**: Backend integration
**Branch**: revamp/phase-3

### What changed

**Task 1 — Auth migration (eliminated lib/auth-helpers.ts)**
- `app/api/v1/roster/route.ts`: POST handler now calls `requireAgencyAuth()` (was `getAgencyClerkId()`).
- `app/api/v1/briefs/route.ts`: GET uses `requireAgencyAuth()`; POST uses `requireBrandAuth()`. Both imported from `@/lib/auth`. Old `auth-helpers` import removed.
- `app/api/v1/briefs/[id]/route.ts`: GET and PATCH both use `requireAgencyAuth()`. Old `auth-helpers` import removed.
- `app/api/v1/brands/[id]/route.ts`: GET uses `requireAgencyAuth()` (scopes deal listing to agency); PATCH now also calls `requireAgencyAuth()` (previously had no auth guard on PATCH). Old `auth-helpers` import removed.
- `app/api/v1/deals/[id]/submissions/route.ts`: POST uses `requireCreatorAuth()` (was `getCreatorClerkId()`). Old `auth-helpers` import removed.
- `app/api/v1/partnerships/route.ts`: POST uses `requireAgencyAuth()`. Old `auth-helpers` import removed.
- `app/api/v1/partnerships/[id]/route.ts`: PATCH uses `requireCreatorAuth()` — creator responds to their own partnership request; lookup changed from `{ id, agencyClerkId }` to `{ id, creatorId: creator.id }` to match ownership model. Old `auth-helpers` import removed.
- `lib/auth-helpers.ts`: **Deleted**.

**Task 2 — 8 new email templates (emails/)**
- `emails/changes-requested.tsx`: Props `{ dealTitle, creatorName, feedback? }`. Renders optional feedback block.
- `emails/content-approved.tsx`: Props `{ dealTitle, creatorName }`.
- `emails/payment-received.tsx`: Props `{ dealTitle, creatorName, amount? }`.
- `emails/deadline-warning.tsx`: Props `{ dealTitle, deadline }`.
- `emails/partnership-request.tsx`: Props `{ creatorName, agencyName? }`.
- `emails/partnership-accepted.tsx`: Props `{ creatorName }`.
- `emails/partnership-declined.tsx`: Props `{ creatorName }`.
- `emails/new-brief.tsx`: Props `{ brandName, campaignName }`.
All templates use `@react-email/components` pattern matching existing templates.

**Task 3 — Upload rate limiting**
- `app/api/v1/deals/[id]/submissions/route.ts` POST: `uploadRateLimit.limit(creatorClerkId)` called after auth, returns 429 on exceed.

**Task 4 — Email triggers wired (all fire-and-forget)**
- `deals/[id]/route.ts` PATCH: triggers `deal-assigned` when `creatorId` set; triggers `contract-available` when `contractStatus === 'SENT'`.
- `deals/[id]/submissions/route.ts` POST: triggers `content-submitted` to agency placeholder.
- `deals/[id]/submissions/[sid]/route.ts` PATCH: triggers `content-approved` on APPROVED; triggers `changes-requested` on CHANGES_REQUESTED.
- `deals/[id]/stage/route.ts` POST: triggers `payment-received` when stage advances to `PAYMENT_PENDING`.
- `partnerships/route.ts` POST: triggers `partnership-request` to creator placeholder.
- `partnerships/[id]/route.ts` PATCH: triggers `partnership-accepted` or `partnership-declined` to agency placeholder.
- `briefs/route.ts` POST: triggers `new-brief` to agency placeholder.

### Bug fixed during implementation
- `deals/[id]/route.ts`: contractStatus comparison was `=== 'Sent'` (from task description example) but Prisma enum is `'SENT'`. Corrected to `=== 'SENT'` — confirmed via `prisma/schema.prisma` and `lib/validations/deal.ts`.

### Pre-existing TS errors (not introduced by this session)
- `app/(public)/signup/page.tsx`: `asChild` prop on Button (frontend agent domain).
- `components/layout/RoleSwitcher.tsx`: Select `onValueChange` null type (frontend agent domain).

---
6 changes: 3 additions & 3 deletions .claude/memory/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ _Last updated: 2026-03-20_
- Phase 1 complete: 2026-03-20 — all 18 routes with mock data, no DB, no Clerk

## Current state
- Status: **Phase 2 Backend Integration complete — ready for PR review**
- Branch: revamp/phase-2 (off revamp/phase-1)
- Active milestone: Phase 2Backend Integration ✅ complete
- Status: **Phase 3 Auth + Superadmin + Polish complete — ready for PR review**
- Branch: revamp/phase-3 (off master)
- Active milestone: Phase 3Auth + Superadmin ✅ complete

## Pre-M3 fixes in progress — 2 PRs open
- **PR #8** `fix/pre-m3-proxy-loop` — proxy.ts redirect loop guard (stale JWT)
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ jobs:
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_URL_UNPOOLED: ${{ secrets.CI_DATABASE_URL_UNPOOLED }}
NEXT_PUBLIC_APP_URL: http://localhost:3001
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}

steps:
- name: Checkout
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ jobs:
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_URL_UNPOOLED: ${{ secrets.CI_DATABASE_URL_UNPOOLED }}
NEXT_PUBLIC_APP_URL: http://localhost:3001
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}

steps:
- name: Checkout
Expand Down
8 changes: 8 additions & 0 deletions app/(agency)/brands/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ export default async function BrandsPage() {
const { data: brands } = await brandsRes.json()
const { data: deals } = await dealsRes.json()

if ((brands ?? []).length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<p className="text-muted-foreground text-sm">No brands added yet.</p>
</div>
)
}

return (
<div className="p-6">
<BrandsTable initialBrands={brands ?? []} deals={deals ?? []} />
Expand Down
8 changes: 8 additions & 0 deletions app/(agency)/briefs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ export default async function BriefsPage() {
const res = await fetch(apiUrl('/api/v1/briefs'), { cache: 'no-store' })
const { data: briefs } = await res.json()

if ((briefs ?? []).length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<p className="text-muted-foreground text-sm">No briefs in your inbox.</p>
</div>
)
}

return (
<div className="p-6">
<h1 className="text-2xl font-bold tracking-tight mb-6">Brief Inbox</h1>
Expand Down
10 changes: 10 additions & 0 deletions app/(agency)/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import Link from 'next/link'
import { apiUrl } from '@/lib/api'
import { KanbanBoard } from '@/components/kanban/KanbanBoard'

export default async function DashboardPage() {
const res = await fetch(apiUrl('/api/v1/deals'), { cache: 'no-store' })
const { data: deals } = await res.json()

if ((deals ?? []).length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<p className="text-muted-foreground text-sm">No deals yet. Create your first deal.</p>
<Link href="/deals/new" className="mt-4 text-sm underline text-foreground">Create deal</Link>
</div>
)
}

return <KanbanBoard initialDeals={deals ?? []} />
}
Loading
Loading