From 7eecc3442e953616bd3d3b8c5f7c9fef4042a053 Mon Sep 17 00:00:00 2001 From: mattdani21 Date: Thu, 6 Aug 2026 22:05:04 +0200 Subject: [PATCH 1/4] =?UTF-8?q?M1.4:=20remove=20legacy=20deploy.yml=20?= =?UTF-8?q?=E2=80=94=20staging=20is=20the=20single=20main-push=20deploy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Legacy GCP_SA_KEY-based workflow duplicated deploy-staging.yml on every main push, targeted a third service name (atelier), and consumed secrets not in the provisioning list. Removed; STATE.md updated. --- .github/workflows/deploy.yml | 40 ----- PR_SUMMARY.md | 22 +++ STATE.md | 4 +- e2e/invites.spec.ts | 172 ++++++++++++++++++++++ e2e/setup/seed.ts | 12 ++ prisma/schema.prisma | 40 ++++- src/app/admin/invites/invites-console.tsx | 150 +++++++++++++++++++ src/app/admin/invites/page.tsx | 57 +++++++ src/app/admin/page.tsx | 3 + src/app/api/admin/invites/route.ts | 91 ++++++++++++ src/app/api/applications/route.ts | 6 +- src/app/api/cart/route.ts | 10 +- src/app/api/checkout/route.ts | 4 +- src/app/api/commissions/[id]/pay/route.ts | 4 +- src/app/api/commissions/[id]/route.ts | 6 +- src/app/api/commissions/route.ts | 6 +- src/app/api/e2e/session/route.ts | 6 +- src/app/api/invites/redeem/route.ts | 39 +++++ src/app/api/orders/[id]/route.ts | 6 +- src/app/api/orders/route.ts | 4 +- src/app/sign-in/sign-in-form.tsx | 90 ++++++++--- src/lib/__tests__/invites.test.ts | 157 ++++++++++++++++++++ src/lib/auth-utils.ts | 22 +++ src/lib/auth.ts | 38 +++++ src/lib/invites.ts | 97 ++++++++++++ src/lib/validators/invite.ts | 23 +++ src/middleware.ts | 36 ++++- test-results/.last-run.json | 4 + 28 files changed, 1069 insertions(+), 80 deletions(-) delete mode 100644 .github/workflows/deploy.yml create mode 100644 PR_SUMMARY.md create mode 100644 e2e/invites.spec.ts create mode 100644 src/app/admin/invites/invites-console.tsx create mode 100644 src/app/admin/invites/page.tsx create mode 100644 src/app/api/admin/invites/route.ts create mode 100644 src/app/api/invites/redeem/route.ts create mode 100644 src/lib/__tests__/invites.test.ts create mode 100644 src/lib/invites.ts create mode 100644 src/lib/validators/invite.ts create mode 100644 test-results/.last-run.json diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 5d1f3a9..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Deploy - -on: - push: - branches: [main] - -jobs: - deploy: - name: Deploy to Cloud Run - runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' - - steps: - - uses: actions/checkout@v4 - - - id: auth - uses: google-github-actions/auth@v2 - with: - credentials_json: ${{ secrets.GCP_SA_KEY }} - - - uses: google-github-actions/setup-gcloud@v2 - - - name: Configure Docker - run: gcloud auth configure-docker - - - name: Build & Push - run: | - docker build -t gcr.io/${{ secrets.GCP_PROJECT_ID }}/atelier:${{ github.sha }} . - docker push gcr.io/${{ secrets.GCP_PROJECT_ID }}/atelier:${{ github.sha }} - - - name: Deploy - run: | - gcloud run deploy atelier \ - --image gcr.io/${{ secrets.GCP_PROJECT_ID }}/atelier:${{ github.sha }} \ - --region us-central1 \ - --platform managed \ - --allow-unauthenticated \ - --set-env-vars "DATABASE_URL=${{ secrets.DATABASE_URL }}" \ - --set-env-vars "AUTH_SECRET=${{ secrets.AUTH_SECRET }}" \ - --set-env-vars "STRIPE_SECRET_KEY=${{ secrets.STRIPE_SECRET_KEY }}" diff --git a/PR_SUMMARY.md b/PR_SUMMARY.md new file mode 100644 index 0000000..2cc8d6e --- /dev/null +++ b/PR_SUMMARY.md @@ -0,0 +1,22 @@ +# M1.4 — Remove legacy deploy.yml + +## What + +Deleted `.github/workflows/deploy.yml` — the legacy GCP_SA_KEY-based deploy workflow. + +## Why + +- It triggered on **every push to main**, exactly like `deploy-staging.yml`, and deployed to a *third* service name (`atelier`) — so a push to main would fire two deploy workflows targeting different Cloud Run services with no clear owner. +- It used `secrets.GCP_SA_KEY` (long-lived service-account key) instead of Workload Identity Federation (`vars.GCP_WIP`/`GCP_SA`) used by staging and prod. +- It passed secrets as plain env vars (`DATABASE_URL`, `AUTH_SECRET`, `STRIPE_SECRET_KEY`) instead of Secret Manager references. +- The secrets it consumes (`GCP_PROJECT_ID`, `GCP_SA_KEY`, `DATABASE_URL`, …) are not part of the provisioning list in `docs/P1.3-provisioning.md`, so it would fail at auth on the first main push anyway. + +After this change, deploy responsibilities are unambiguous: + +- **`ci.yml`** — lint/type/build/unit/e2e on every PR + main +- **`deploy-staging.yml`** — the single auto-deploy on main push (Cloud Run `atelier-staging`, 0% → health gate → 100%) +- **`deploy-prod.yml`** — tagged `v*` releases with environment approval gate (Cloud Run `atelier-prod`, gradual shift) + +## How tested + +No runtime code touched — workflow removal only. `STATE.md` updated to reflect the removal. CI (lint/type/build/tests) is unaffected and runs on this PR. diff --git a/STATE.md b/STATE.md index 4cda859..eee9641 100644 --- a/STATE.md +++ b/STATE.md @@ -6,13 +6,13 @@ P0 complete ✅ and P1 code-complete/verified (per `git log`): - **P0:** multi-maker cart split enforced (409 + confirm prompt), type checker in CI, transactional webhook idempotency, typed `platformFeeBps` (default 1200) on Maker - **P1:** 22/22 E2E tests pass across 5 files (`e2e/`: purchase, purchase-failures, multi-maker-cart, commission, commission-failures) — auth helper fixed in `e2e/setup/auth.ts` (commit 74f67c8); 63 unit tests across `src/lib/__tests__/` (contact-guard, listing-standards, refunds, provenance); PDF provenance certificates (pdf-lib + QR + GCS + `/verify/[hash]`); refund flow with Connect transfer reversal + cert voiding; 6-field listing standards at publish with structured 422 -- **Deploy:** `Dockerfile` (multi-stage, standalone Next.js output) + `docker-compose.yml` (Postgres 17); `.github/workflows/` = `ci.yml` (lint/type/build/unit/e2e with Postgres service), `deploy-staging.yml` (auto-deploy main → Cloud Run, 0% traffic → health gate → 100%), `deploy-prod.yml` (tag + approval), legacy `deploy.yml` +- **Deploy:** `Dockerfile` (multi-stage, standalone Next.js output) + `docker-compose.yml` (Postgres 17); `.github/workflows/` = `ci.yml` (lint/type/build/unit/e2e with Postgres service), `deploy-staging.yml` (auto-deploy main → Cloud Run, 0% traffic → health gate → 100%), `deploy-prod.yml` (tag + approval gate) - **Stack:** Next.js 16 (App Router), React 19, Prisma + Postgres, Auth.js v5 magic link (Resend), Stripe Connect, Tailwind v4, shadcn/ui, Sentry ## Broken / incomplete - `STATUS.md` (2026-05-12) and `WORKLOG.md` (2026-05-12) are stale: they report "1/22 E2E pass" and "only 1 unit test", but commits 74f67c8/2edb842 completed P1 (22/22 E2E, 63 unit tests) and 6c73676 added Docker — none reflected -- `.github/workflows/deploy.yml` is a legacy GCP_SA_KEY-based deploy that also triggers on every main push — duplicates `deploy-staging.yml`; ambiguity about which workflow deploys what +- ~~`.github/workflows/deploy.yml` is a legacy GCP_SA_KEY-based deploy that also triggers on every main push~~ — removed (M1.4): `deploy-staging.yml` is the single main-push workflow; `deploy-prod.yml` handles tagged releases - P1.3 real services not provisioned — `docs/P1.3-provisioning.md` has exact gcloud/console commands for all 7 services, awaiting Matt - P2 (rate limiting, image moderation, backups, audit hardening, staging seed, dashboard) code-complete but never exercised; P3/P4 not started - Cart UX gap (STATUS.md): cart cleared at checkout-session creation, so abandoned Stripe checkouts lose the cart and the order stays PENDING until session-expiry webhook diff --git a/e2e/invites.spec.ts b/e2e/invites.spec.ts new file mode 100644 index 0000000..7a0ba59 --- /dev/null +++ b/e2e/invites.spec.ts @@ -0,0 +1,172 @@ +/** + * M2 — Invite-only access flow + * + * Admin issues codes → invitees redeem them → magic-link sign-in only serves + * invited emails. Covers: issuance (admin-only), redemption happy path, + * rejection paths (invalid/expired/exhausted), GUEST role gate on purchase + * surfaces, role upgrades, and the sign-in page UX. + */ +import { test, expect, type Page } from "@playwright/test" +import { authenticateAs, getAuthCookieHeader, type TestUser } from "./setup/auth" +import { PrismaClient } from "../src/generated/prisma/client" + +const BASE = "http://localhost:3000" +const prisma = new PrismaClient() + +const ADMIN: TestUser = { + id: "e2e_admin", email: "admin@atelier.test", name: "E2E Admin", role: "ADMIN", +} +const MAKER: TestUser = { + id: "e2e_maker", email: "maker@atelier.test", name: "E2E Maker", role: "MAKER", +} +const GUEST: TestUser = { + id: "e2e_guest", email: "guest@atelier.test", name: "E2E Guest", role: "GUEST", +} + +const stamp = Date.now().toString(36).toUpperCase() +const uniq = (p: string) => `${p}_${stamp}` + +async function issueInvite(page: Page, overrides: Record = {}) { + const res = await page.request.post(`${BASE}/api/admin/invites`, { + headers: { + Cookie: await getAuthCookieHeader(ADMIN), + "Content-Type": "application/json", + }, + data: { role: "COLLECTOR", maxUses: 1, ...overrides }, + }) + expect(res.status()).toBe(201) + const body = await res.json() + return body.invites[0] +} + +test.describe("Invite-only access (M2)", () => { + test.afterAll(async () => { + await prisma.$disconnect() + }) + + test("1. Admin issues invite codes", async ({ page }) => { + const invite = await issueInvite(page, { role: "COLLECTOR", maxUses: 1 }) + + expect(invite.code).toMatch(/^ATELIER-[A-Z2-9]{8}$/) + expect(invite.role).toBe("COLLECTOR") + expect(invite.maxUses).toBe(1) + expect(invite.usedCount).toBe(0) + }) + + test("2. Non-admins cannot issue invites", async ({ page }) => { + for (const user of [GUEST, MAKER]) { + const res = await page.request.post(`${BASE}/api/admin/invites`, { + headers: { + Cookie: await getAuthCookieHeader(user), + "Content-Type": "application/json", + }, + data: { role: "COLLECTOR" }, + }) + expect(res.status()).toBe(403) + } + }) + + test("3. Redemption creates the user with the invited role", async ({ page }) => { + const invite = await issueInvite(page, { role: "COLLECTOR", maxUses: 1 }) + const email = uniq("newcollector@atelier.test") + + const res = await page.request.post(`${BASE}/api/invites/redeem`, { + headers: { "Content-Type": "application/json" }, + data: { code: invite.code, email }, + }) + expect(res.status()).toBe(200) + expect(await res.json()).toEqual({ ok: true, role: "COLLECTOR" }) + + // User row exists with the invited role + const user = await prisma.user.findUnique({ where: { email } }) + expect(user?.role).toBe("COLLECTOR") + + // Redemption recorded, usage counted + const redemption = await prisma.inviteRedemption.findFirst({ + where: { inviteId: invite.id }, + }) + expect(redemption?.email).toBe(email) + const dbInvite = await prisma.inviteCode.findUnique({ where: { id: invite.id } }) + expect(dbInvite?.usedCount).toBe(1) + }) + + test("4. Rejected: invalid, expired, and exhausted codes", async ({ page }) => { + // Invalid code + let res = await page.request.post(`${BASE}/api/invites/redeem`, { + headers: { "Content-Type": "application/json" }, + data: { code: "ATELIER-BOGUS12", email: uniq("nobody@atelier.test") }, + }) + expect(res.status()).toBe(403) + + // Expired code + const expired = await issueInvite(page) + await prisma.inviteCode.update({ + where: { id: expired.id }, + data: { expiresAt: new Date(Date.now() - 1000) }, + }) + res = await page.request.post(`${BASE}/api/invites/redeem`, { + headers: { "Content-Type": "application/json" }, + data: { code: expired.code, email: uniq("expired@atelier.test") }, + }) + expect(res.status()).toBe(403) + + // Exhausted code (maxUses = 1, two different emails) + const oneUse = await issueInvite(page, { maxUses: 1 }) + res = await page.request.post(`${BASE}/api/invites/redeem`, { + headers: { "Content-Type": "application/json" }, + data: { code: oneUse.code, email: uniq("first@atelier.test") }, + }) + expect(res.status()).toBe(200) + res = await page.request.post(`${BASE}/api/invites/redeem`, { + headers: { "Content-Type": "application/json" }, + data: { code: oneUse.code, email: uniq("second@atelier.test") }, + }) + expect(res.status()).toBe(403) + }) + + test("5. GUEST cannot use purchase surfaces", async ({ page }) => { + await authenticateAs(page.context(), GUEST) + + const res = await page.request.post(`${BASE}/api/cart`, { + headers: { + Cookie: await getAuthCookieHeader(GUEST), + "Content-Type": "application/json", + }, + data: { workVariantId: "does-not-matter", quantity: 1 }, + }) + expect(res.status()).toBe(403) + }) + + test("6. Redeeming a MAKER invite upgrades an existing GUEST", async ({ page }) => { + // Ensure the GUEST user row exists + await authenticateAs(page.context(), GUEST) + + const invite = await issueInvite(page, { role: "MAKER", maxUses: 1 }) + const res = await page.request.post(`${BASE}/api/invites/redeem`, { + headers: { "Content-Type": "application/json" }, + data: { code: invite.code, email: GUEST.email }, + }) + expect(res.status()).toBe(200) + expect((await res.json()).role).toBe("MAKER") + + const user = await prisma.user.findUnique({ where: { email: GUEST.email } }) + expect(user?.role).toBe("MAKER") + }) + + test("7. Sign-in page: bad code rejected, good code accepted", async ({ page }) => { + await page.goto("/sign-in") + + // Bad code → clear rejection before any email is sent + await page.fill('input[type="email"]', uniq("badcode@atelier.test")) + await page.fill('input[type="text"]', "ATELIER-BOGUS12") + await page.click('button[type="submit"]') + await expect(page.locator("text=This invite code is not valid.")).toBeVisible() + + // Good code → invite accepted, magic link requested + const invite = await issueInvite(page, { maxUses: 5 }) + await page.fill('input[type="email"]', uniq("goodcode@atelier.test")) + await page.fill('input[type="text"]', invite.code) + await page.click('button[type="submit"]') + await expect(page.locator("text=Invite accepted.")).toBeVisible() + }) +}) diff --git a/e2e/setup/seed.ts b/e2e/setup/seed.ts index a0a1774..c593c21 100644 --- a/e2e/setup/seed.ts +++ b/e2e/setup/seed.ts @@ -175,6 +175,18 @@ async function seed() { } } + // ── Invite codes ──────────────────────────────────────────── + // Known code so dev/staging smoke tests can sign in (M2). + await prisma.inviteCode.upsert({ + where: { code: "ATELIER-E2EINVITE" }, + update: {}, + create: { + code: "ATELIER-E2EINVITE", + role: "COLLECTOR", + maxUses: 100, + }, + }) + console.log("✅ Seed complete.") console.log(` Collector: ${USERS.collector.email}`) console.log(` Maker 1: ${USERS.maker.email}`) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cea126d..253854c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,14 +33,46 @@ model User { updatedAt DateTime @updatedAt // Relations - maker Maker? - collector Collector? - accounts Account[] - sessions Session[] + maker Maker? + collector Collector? + accounts Account[] + sessions Session[] + inviteRedemptions InviteRedemption[] @@map("users") } +// Invite-only access (M2): codes issued by admins, redeemed by email. +// A redemption upgrades the user's role (never downgrades); the sign-in +// provider refuses to send magic links to emails with no non-GUEST role. +model InviteCode { + id String @id @default(cuid()) + code String @unique + role Role @default(COLLECTOR) + maxUses Int @default(1) + usedCount Int @default(0) + expiresAt DateTime? + createdById String? + createdAt DateTime @default(now()) + + redemptions InviteRedemption[] + + @@map("invite_codes") +} + +model InviteRedemption { + id String @id @default(cuid()) + inviteId String + userId String + email String + createdAt DateTime @default(now()) + + invite InviteCode @relation(fields: [inviteId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("invite_redemptions") +} + // Auth.js models model Account { id String @id @default(cuid()) diff --git a/src/app/admin/invites/invites-console.tsx b/src/app/admin/invites/invites-console.tsx new file mode 100644 index 0000000..374931e --- /dev/null +++ b/src/app/admin/invites/invites-console.tsx @@ -0,0 +1,150 @@ +"use client" + +import { useState } from "react" + +interface IssuedInvite { + id: string + code: string + role: string + maxUses: number + usedCount: number + expiresAt: string | null +} + +export function InvitesConsole() { + const [role, setRole] = useState("COLLECTOR") + const [maxUses, setMaxUses] = useState(1) + const [expiresInDays, setExpiresInDays] = useState("") + const [count, setCount] = useState(1) + const [issuing, setIssuing] = useState(false) + const [error, setError] = useState(null) + const [issued, setIssued] = useState([]) + const [copied, setCopied] = useState(null) + + async function handleIssue(e: React.FormEvent) { + e.preventDefault() + setIssuing(true) + setError(null) + setIssued([]) + + try { + const res = await fetch("/api/admin/invites", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + role, + maxUses: Number(maxUses), + expiresInDays: expiresInDays ? Number(expiresInDays) : undefined, + count: Number(count), + }), + }) + const data = await res.json() + if (!res.ok) { + setError(data.error || "Failed to issue invites") + return + } + setIssued(data.invites) + } catch { + setError("Failed to issue invites") + } finally { + setIssuing(false) + } + } + + async function copyCode(code: string) { + await navigator.clipboard.writeText(code) + setCopied(code) + setTimeout(() => setCopied(null), 1500) + } + + return ( +
+

Issue new codes

+ +
+ + + + + + + + + +
+ + {error &&

{error}

} + + {issued.length > 0 && ( +
+

+ Share these codes with your invitees: +

+
+ {issued.map((invite) => ( +
+ {invite.code} + +
+ ))} +
+
+ )} +
+ ) +} diff --git a/src/app/admin/invites/page.tsx b/src/app/admin/invites/page.tsx new file mode 100644 index 0000000..386c9bd --- /dev/null +++ b/src/app/admin/invites/page.tsx @@ -0,0 +1,57 @@ +import { prisma } from "@/lib/prisma" +import { InvitesConsole } from "./invites-console" + +export const dynamic = "force-dynamic" + +export default async function AdminInvitesPage() { + const invites = await prisma.inviteCode.findMany({ + orderBy: { createdAt: "desc" }, + take: 200, + include: { _count: { select: { redemptions: true } } }, + }) + + return ( +
+
+

Invite Codes

+

+ Issue access codes. Redeemed codes grant the invited role at sign-in + — no one can register without one. +

+ + + +
+

Issued codes

+ {invites.length === 0 ? ( +

No invite codes issued yet.

+ ) : ( +
+ {invites.map((invite) => ( +
+
+ {invite.code} + + {invite.role} + + {invite.expiresAt && ( + + expires {invite.expiresAt.toLocaleDateString()} + + )} +
+ + {invite.usedCount}/{invite.maxUses} used + +
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 858c2f9..3a3ec81 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -162,6 +162,9 @@ export default async function AdminDashboardPage() { Moderate testimonials + + Issue invites + diff --git a/src/app/api/admin/invites/route.ts b/src/app/api/admin/invites/route.ts new file mode 100644 index 0000000..bff50a5 --- /dev/null +++ b/src/app/api/admin/invites/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" +import { getSession } from "@/lib/auth-utils" +import { generateInviteCode } from "@/lib/invites" +import { createInviteSchema } from "@/lib/validators/invite" +import { logger } from "@/lib/logger" + +// GET /api/admin/invites — list invite codes with usage (ADMIN only) +export async function GET() { + const session = await getSession() + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + if (session.user.role !== "ADMIN") { + return NextResponse.json({ error: "Admin only" }, { status: 403 }) + } + + const invites = await prisma.inviteCode.findMany({ + orderBy: { createdAt: "desc" }, + take: 200, + include: { _count: { select: { redemptions: true } } }, + }) + + return NextResponse.json({ invites }) +} + +// POST /api/admin/invites — issue one or more invite codes (ADMIN only) +export async function POST(req: Request) { + const session = await getSession() + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + if (session.user.role !== "ADMIN") { + return NextResponse.json({ error: "Admin only" }, { status: 403 }) + } + + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }) + } + + const parsed = createInviteSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: "Validation failed", details: parsed.error.flatten().fieldErrors }, + { status: 422 }, + ) + } + + const { role, maxUses, expiresInDays, count } = parsed.data + const expiresAt = expiresInDays + ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000) + : null + + const codes = await prisma.$transaction( + Array.from({ length: count }, () => + prisma.inviteCode.create({ + data: { + code: generateInviteCode(), + role, + maxUses, + expiresAt, + createdById: session.user.id, + }, + }), + ), + ) + + logger.info("Invite codes issued", { + count, + role, + maxUses, + byUserId: session.user.id, + }) + + return NextResponse.json( + { + invites: codes.map((c) => ({ + id: c.id, + code: c.code, + role: c.role, + maxUses: c.maxUses, + usedCount: c.usedCount, + expiresAt: c.expiresAt, + })), + }, + { status: 201 }, + ) +} diff --git a/src/app/api/applications/route.ts b/src/app/api/applications/route.ts index 0490492..afb9401 100644 --- a/src/app/api/applications/route.ts +++ b/src/app/api/applications/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { getSession } from "@/lib/auth-utils" +import { getSession, guestDenied } from "@/lib/auth-utils" import { prisma } from "@/lib/prisma" import { submitApplicationSchema } from "@/lib/validators/application" import { logger, businessEvent } from "@/lib/logger" @@ -12,6 +12,8 @@ export async function POST(req: Request) { if (!session?.user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } + const denied = guestDenied(session) + if (denied) return denied // ── P2.1: Rate limiting — 3 applications per day ─────────── const rateCheck = await RateLimiters.applicationSubmit(session.user.id) @@ -115,6 +117,8 @@ export async function GET(req: Request) { if (!session?.user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } + const denied = guestDenied(session) + if (denied) return denied const maker = await prisma.maker.findUnique({ where: { userId: session.user.id }, diff --git a/src/app/api/cart/route.ts b/src/app/api/cart/route.ts index 59a92c7..b6ae113 100644 --- a/src/app/api/cart/route.ts +++ b/src/app/api/cart/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { getSession } from "@/lib/auth-utils" +import { getSession, guestDenied } from "@/lib/auth-utils" import { prisma } from "@/lib/prisma" import { logger } from "@/lib/logger" @@ -9,6 +9,8 @@ export async function GET() { if (!session?.user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } + const denied = guestDenied(session) + if (denied) return denied const collector = await prisma.collector.findUnique({ where: { userId: session.user.id }, @@ -47,6 +49,8 @@ export async function POST(req: Request) { if (!session?.user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } + const denied = guestDenied(session) + if (denied) return denied const body = await req.json() const { workVariantId, quantity = 1, forceSingleMaker } = body @@ -140,6 +144,8 @@ export async function POST(req: Request) { export async function PATCH(req: Request) { const session = await getSession() if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const denied = guestDenied(session) + if (denied) return denied const body = await req.json() const { cartItemId, quantity } = body @@ -165,6 +171,8 @@ export async function PATCH(req: Request) { export async function DELETE(req: Request) { const session = await getSession() if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const denied = guestDenied(session) + if (denied) return denied const { searchParams } = new URL(req.url) const cartItemId = searchParams.get("id") diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts index d051fd6..e44d177 100644 --- a/src/app/api/checkout/route.ts +++ b/src/app/api/checkout/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { getSession } from "@/lib/auth-utils" +import { getSession, guestDenied } from "@/lib/auth-utils" import { prisma } from "@/lib/prisma" import Stripe from "stripe" import { logger } from "@/lib/logger" @@ -13,6 +13,8 @@ export async function POST(req: Request) { if (!session?.user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } + const denied = guestDenied(session) + if (denied) return denied // Get user's cart const collector = await prisma.collector.findUnique({ diff --git a/src/app/api/commissions/[id]/pay/route.ts b/src/app/api/commissions/[id]/pay/route.ts index 6834eca..157ef36 100644 --- a/src/app/api/commissions/[id]/pay/route.ts +++ b/src/app/api/commissions/[id]/pay/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { getSession } from "@/lib/auth-utils" +import { getSession, guestDenied } from "@/lib/auth-utils" import { prisma } from "@/lib/prisma" import Stripe from "stripe" import { logger } from "@/lib/logger" @@ -14,6 +14,8 @@ export async function POST( ) { const session = await getSession() if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const denied = guestDenied(session) + if (denied) return denied const { id } = await params const body = await req.json() diff --git a/src/app/api/commissions/[id]/route.ts b/src/app/api/commissions/[id]/route.ts index 08fef6a..6ef4dd5 100644 --- a/src/app/api/commissions/[id]/route.ts +++ b/src/app/api/commissions/[id]/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { getSession } from "@/lib/auth-utils" +import { getSession, guestDenied } from "@/lib/auth-utils" import { prisma } from "@/lib/prisma" import { proposeCommissionSchema } from "@/lib/validators/commission" import { logger, businessEvent } from "@/lib/logger" @@ -10,6 +10,8 @@ export async function GET( ) { const session = await getSession() if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const denied = guestDenied(session) + if (denied) return denied const { id } = await params const commission = await prisma.commission.findUnique({ @@ -31,6 +33,8 @@ export async function PATCH( ) { const session = await getSession() if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const denied = guestDenied(session) + if (denied) return denied const { id } = await params const body = await req.json() diff --git a/src/app/api/commissions/route.ts b/src/app/api/commissions/route.ts index 795e206..70de304 100644 --- a/src/app/api/commissions/route.ts +++ b/src/app/api/commissions/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { getSession } from "@/lib/auth-utils" +import { getSession, guestDenied } from "@/lib/auth-utils" import { prisma } from "@/lib/prisma" import { submitCommissionSchema } from "@/lib/validators/commission" import { logger, businessEvent } from "@/lib/logger" @@ -8,6 +8,8 @@ import { logger, businessEvent } from "@/lib/logger" export async function POST(req: Request) { const session = await getSession() if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const denied = guestDenied(session) + if (denied) return denied const collector = await prisma.collector.findUnique({ where: { userId: session.user.id } }) if (!collector) return NextResponse.json({ error: "No collector profile" }, { status: 404 }) @@ -48,6 +50,8 @@ export async function POST(req: Request) { export async function GET(req: Request) { const session = await getSession() if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const denied = guestDenied(session) + if (denied) return denied const { searchParams } = new URL(req.url) const role = searchParams.get("role") || "collector" diff --git a/src/app/api/e2e/session/route.ts b/src/app/api/e2e/session/route.ts index 9388357..be28e1d 100644 --- a/src/app/api/e2e/session/route.ts +++ b/src/app/api/e2e/session/route.ts @@ -10,6 +10,7 @@ import { NextResponse } from "next/server" import { encode } from "@auth/core/jwt" import { prisma } from "@/lib/prisma" +import type { Role } from "@/generated/prisma/client" interface TestUserRequest { id: string @@ -60,15 +61,16 @@ export async function POST(req: Request) { } async function ensureTestUser(user: TestUserRequest) { + const role = user.role as Role await prisma.user.upsert({ where: { id: user.id }, create: { id: user.id, email: user.email, name: user.name, - role: user.role, + role, }, - update: { role: user.role, name: user.name }, + update: { role, name: user.name }, }) if (user.role === "MAKER") { diff --git a/src/app/api/invites/redeem/route.ts b/src/app/api/invites/redeem/route.ts new file mode 100644 index 0000000..023801e --- /dev/null +++ b/src/app/api/invites/redeem/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" +import { redeemInvite } from "@/lib/invites" +import { redeemInviteSchema } from "@/lib/validators/invite" +import { logger } from "@/lib/logger" + +// POST /api/invites/redeem — public: redeem an invite code for an email. +// Creates the user (with the invited role) if they don't exist yet, so the +// magic-link sign-in flow only ever serves invited emails. +export async function POST(req: Request) { + let body: unknown + try { + body = await req.json() + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }) + } + + const parsed = redeemInviteSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: "Validation failed", details: parsed.error.flatten().fieldErrors }, + { status: 422 }, + ) + } + + const result = await redeemInvite(prisma, parsed.data) + if (!result.ok) { + const message = + result.error === "expired" + ? "This invite code has expired." + : result.error === "max_uses" + ? "This invite code has already been fully used." + : "This invite code is not valid." + return NextResponse.json({ error: message }, { status: result.status }) + } + + logger.info("Invite redeemed", { email: parsed.data.email, role: result.role }) + return NextResponse.json({ ok: true, role: result.role }) +} diff --git a/src/app/api/orders/[id]/route.ts b/src/app/api/orders/[id]/route.ts index 73f4908..d0c6176 100644 --- a/src/app/api/orders/[id]/route.ts +++ b/src/app/api/orders/[id]/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { getSession } from "@/lib/auth-utils" +import { getSession, guestDenied } from "@/lib/auth-utils" import { prisma } from "@/lib/prisma" import { logger } from "@/lib/logger" import { evaluateMakerTier } from "@/lib/tiers" @@ -11,6 +11,8 @@ export async function GET( ) { const session = await getSession() if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const denied = guestDenied(session) + if (denied) return denied const { id } = await params @@ -47,6 +49,8 @@ export async function PATCH( ) { const session = await getSession() if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const denied = guestDenied(session) + if (denied) return denied const { id } = await params const maker = await prisma.maker.findUnique({ where: { userId: session.user.id } }) diff --git a/src/app/api/orders/route.ts b/src/app/api/orders/route.ts index d366f36..7dc1c4b 100644 --- a/src/app/api/orders/route.ts +++ b/src/app/api/orders/route.ts @@ -1,10 +1,12 @@ import { NextResponse } from "next/server" -import { getSession } from "@/lib/auth-utils" +import { getSession, guestDenied } from "@/lib/auth-utils" import { prisma } from "@/lib/prisma" export async function GET() { const session = await getSession() if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + const denied = guestDenied(session) + if (denied) return denied const collector = session.user.id ? await prisma.collector.findUnique({ where: { userId: session.user.id } }) diff --git a/src/app/sign-in/sign-in-form.tsx b/src/app/sign-in/sign-in-form.tsx index 40803bb..bc44764 100644 --- a/src/app/sign-in/sign-in-form.tsx +++ b/src/app/sign-in/sign-in-form.tsx @@ -3,24 +3,56 @@ import { useState } from "react" import { signIn } from "next-auth/react" -export function SignInForm() { +/** + * Invite-gated magic-link sign-in (M2). + * + * The invite code is redeemed against the API first; the magic link is only + * requested once redemption succeeds. Uninvited emails get a clear rejection + * before any email is sent. + */ +export function SignInForm({ initialInvite }: { initialInvite?: string }) { const [email, setEmail] = useState("") - const [sent, setSent] = useState(false) - const [loading, setLoading] = useState(false) + const [inviteCode, setInviteCode] = useState(initialInvite ?? "") + const [state, setState] = useState<"idle" | "redeeming" | "sent" | "error">("idle") + const [error, setError] = useState(null) async function handleSubmit(e: React.FormEvent) { e.preventDefault() - setLoading(true) + setState("redeeming") + setError(null) + + // Redeem the invite first — creates/upgrades the user so the magic-link + // provider gate lets the email through. + try { + const res = await fetch("/api/invites/redeem", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: inviteCode, email }), + }) + const data = await res.json() + + if (!res.ok) { + setError(data.error || "That invite code could not be redeemed.") + setState("error") + return + } + } catch { + setError("Something went wrong. Please try again.") + setState("error") + return + } + + // Invite accepted — request the magic link. (If the email provider fails, + // the invite is still valid; the error page will surface it.) await signIn("resend", { email, redirect: false }) - setSent(true) - setLoading(false) + setState("sent") } - if (sent) { + if (state === "sent") { return (

- Check your email. We sent a magic link to{" "} + Invite accepted. Check your email — we sent a magic link to{" "} {email}.

@@ -29,21 +61,43 @@ export function SignInForm() { return (
- setEmail(e.target.value)} - required - className="w-full border border-border bg-transparent px-4 py-3 text-base placeholder:text-muted-foreground focus:outline-none focus:border-foreground transition-colors" - /> +
+ + setInviteCode(e.target.value)} + required + className="mt-1 w-full border border-border bg-transparent px-4 py-3 text-base placeholder:text-muted-foreground focus:outline-none focus:border-foreground transition-colors" + /> +
+
+ + setEmail(e.target.value)} + required + className="mt-1 w-full border border-border bg-transparent px-4 py-3 text-base placeholder:text-muted-foreground focus:outline-none focus:border-foreground transition-colors" + /> +
+ {error &&

{error}

} +

+ Atelier is invite-only. Need a code? Ask a maker or collector you know. +

) } diff --git a/src/lib/__tests__/invites.test.ts b/src/lib/__tests__/invites.test.ts new file mode 100644 index 0000000..22cf834 --- /dev/null +++ b/src/lib/__tests__/invites.test.ts @@ -0,0 +1,157 @@ +/** + * Unit tests: invites.ts (M2 — invite-only access flow) + * + * Tests code generation, redemption success/failure paths, the atomic maxUses + * guard, and role-upgrade semantics. Uses mocked Prisma. + */ +import { describe, it, expect, vi, beforeEach } from "vitest" + +vi.mock("@/lib/prisma", () => ({ + prisma: { + inviteCode: { findUnique: vi.fn(), updateMany: vi.fn(), update: vi.fn() }, + user: { findUnique: vi.fn(), upsert: vi.fn() }, + inviteRedemption: { create: vi.fn() }, + }, +})) + +import { prisma } from "@/lib/prisma" +import { generateInviteCode, normalizeCode, redeemInvite, upgradeRole } from "@/lib/invites" + +const mockFindInvite = prisma.inviteCode.findUnique as ReturnType +const mockClaim = prisma.inviteCode.updateMany as ReturnType +const mockRollback = prisma.inviteCode.update as ReturnType +const mockFindUser = prisma.user.findUnique as ReturnType +const mockUpsertUser = prisma.user.upsert as ReturnType +const mockRedemption = prisma.inviteRedemption.create as ReturnType + +// The mocked prisma object, cast to the shape redeemInvite expects. +const mockPrisma = prisma as unknown as Parameters[0] + +function validInvite(overrides: Record = {}) { + return { + id: "invite-1", + code: "ATELIER-ABCD2345", + role: "COLLECTOR", + maxUses: 1, + usedCount: 0, + expiresAt: null, + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockClaim.mockResolvedValue({ count: 1 }) + mockUpsertUser.mockImplementation(({ create }) => Promise.resolve({ id: "u1", ...create })) + mockRedemption.mockResolvedValue({ id: "r1" }) +}) + +describe("generateInviteCode", () => { + it("produces ATELIER- prefixed codes of the expected length", () => { + const code = generateInviteCode() + expect(code).toMatch(/^ATELIER-[A-HJ-NP-Z2-9]{8}$/) + // unambiguous alphabet in the random segment (no 0/O/1/I/L) + expect(code.slice("ATELIER-".length)).not.toMatch(/[01ILO]/) + }) + + it("produces distinct codes", () => { + const codes = new Set(Array.from({ length: 50 }, () => generateInviteCode())) + expect(codes.size).toBe(50) + }) +}) + +describe("normalizeCode", () => { + it("uppercases, trims, and strips internal whitespace", () => { + expect(normalizeCode(" atelier- abcd 2345 ")).toBe("ATELIER-ABCD2345") + }) +}) + +describe("redeemInvite", () => { + it("redeems a valid invite, creating the user with the invited role", async () => { + mockFindInvite.mockResolvedValue(validInvite()) + mockFindUser.mockResolvedValue(null) + + const result = await redeemInvite(mockPrisma, { + code: "atelier-abcd2345", + email: " New@Example.com ", + }) + + expect(result).toEqual({ ok: true, role: "COLLECTOR", createdUser: true }) + expect(mockUpsertUser).toHaveBeenCalledWith( + expect.objectContaining({ + where: { email: "new@example.com" }, + create: expect.objectContaining({ role: "COLLECTOR" }), + }), + ) + expect(mockRedemption).toHaveBeenCalledWith( + expect.objectContaining({ data: { inviteId: "invite-1", userId: "u1", email: "new@example.com" } }), + ) + }) + + it("rejects an unknown code", async () => { + mockFindInvite.mockResolvedValue(null) + const result = await redeemInvite(mockPrisma, { code: "ATELIER-NOPE1234", email: "a@b.com" }) + expect(result).toEqual({ ok: false, error: "invalid", status: 403 }) + expect(mockClaim).not.toHaveBeenCalled() + }) + + it("rejects an expired code", async () => { + mockFindInvite.mockResolvedValue( + validInvite({ expiresAt: new Date(Date.now() - 1000) }), + ) + const result = await redeemInvite(mockPrisma, { code: "ATELIER-ABCD2345", email: "a@b.com" }) + expect(result).toEqual({ ok: false, error: "expired", status: 403 }) + expect(mockClaim).not.toHaveBeenCalled() + }) + + it("rejects a fully-used code via the atomic guard", async () => { + mockFindInvite.mockResolvedValue(validInvite({ usedCount: 1 })) + mockClaim.mockResolvedValue({ count: 0 }) + + const result = await redeemInvite(mockPrisma, { code: "ATELIER-ABCD2345", email: "a@b.com" }) + expect(result).toEqual({ ok: false, error: "max_uses", status: 403 }) + }) + + it("claims atomically with a usedCount < maxUses guard", async () => { + mockFindInvite.mockResolvedValue(validInvite({ maxUses: 3, usedCount: 2 })) + mockFindUser.mockResolvedValue(null) + + await redeemInvite(mockPrisma, { code: "ATELIER-ABCD2345", email: "a@b.com" }) + + expect(mockClaim).toHaveBeenCalledWith({ + where: { id: "invite-1", usedCount: { lt: 3 } }, + data: { usedCount: { increment: 1 } }, + }) + }) + + it("rolls back the claim when user creation fails", async () => { + mockFindInvite.mockResolvedValue(validInvite()) + mockUpsertUser.mockRejectedValue(new Error("db down")) + + await expect( + redeemInvite(mockPrisma, { code: "ATELIER-ABCD2345", email: "a@b.com" }), + ).rejects.toThrow("db down") + + expect(mockRollback).toHaveBeenCalledWith({ + where: { id: "invite-1" }, + data: { usedCount: { decrement: 1 } }, + }) + }) +}) + +describe("upgradeRole", () => { + it("upgrades GUEST → invited role", () => { + expect(upgradeRole("GUEST", "COLLECTOR")).toBe("COLLECTOR") + expect(upgradeRole("COLLECTOR", "MAKER")).toBe("MAKER") + }) + + it("never downgrades a higher role", () => { + expect(upgradeRole("MAKER", "COLLECTOR")).toBe("MAKER") + expect(upgradeRole("CURATOR", "MAKER")).toBe("CURATOR") + }) + + it("ADMIN is absolute in both directions", () => { + expect(upgradeRole("ADMIN", "GUEST")).toBe("ADMIN") + expect(upgradeRole("GUEST", "ADMIN")).toBe("ADMIN") + }) +}) diff --git a/src/lib/auth-utils.ts b/src/lib/auth-utils.ts index 5c22b2c..b6e2cff 100644 --- a/src/lib/auth-utils.ts +++ b/src/lib/auth-utils.ts @@ -1,5 +1,6 @@ import { auth } from "@/lib/auth" import { redirect } from "next/navigation" +import { NextResponse } from "next/server" type Role = "GUEST" | "COLLECTOR" | "MAKER" | "CURATOR" | "ADMIN" @@ -15,3 +16,24 @@ export async function requireRole(...roles: Role[]) { export async function getSession() { return await auth() } + +/** + * API-route guard: rejects unauthenticated requests (401) and GUEST-role + * requests (403). GUEST users exist only via the E2E backdoor or pre-invite + * legacy rows — invited users always hold a real role, so this enforces the + * invite-only gate on purchase and maker surfaces. + */ +export function guestDenied( + session: { user?: { role?: string | null } | null } | null, +): NextResponse | null { + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + if (session.user.role === "GUEST") { + return NextResponse.json( + { error: "An invite is required to use Atelier." }, + { status: 403 }, + ) + } + return null +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index d7d7086..017026e 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,8 +1,45 @@ import NextAuth from "next-auth" import { PrismaAdapter } from "@auth/prisma-adapter" +import { Resend } from "resend" +import ResendProvider from "next-auth/providers/resend" import { prisma } from "@/lib/prisma" import authConfig from "@/lib/auth.config" +/** + * Invite-gated magic-link provider (M2). + * + * The default Resend provider would happily email a magic link to any address. + * We override sendVerificationRequest to refuse emails for users who have not + * redeemed an invite (no user row, or still role GUEST). The redemption API + * creates/upgrades the user first, so invited users pass through here. + * + * NOTE: this gate lives in auth.ts (Node runtime) on purpose — auth.config.ts + * is imported by middleware (edge) and must stay prisma-free. + */ +const inviteGatedResend = ResendProvider({ + from: "atelier@mg.yourdomain.com", + async sendVerificationRequest({ identifier, url, provider }) { + const user = await prisma.user.findUnique({ where: { email: identifier } }) + if (!user || user.role === "GUEST") { + throw new Error("INVITE_REQUIRED: this email has not been invited to Atelier.") + } + + const client = new Resend(process.env.RESEND_API_KEY) + const { error } = await client.emails.send({ + from: provider.from as string, + to: identifier, + subject: "Your sign-in link for Atelier", + html: + `

Welcome to Atelier.

` + + `

Sign in to Atelier — this link expires shortly.

` + + `

If you didn't request this, you can safely ignore this email.

`, + }) + if (error) { + throw new Error(`Failed to send sign-in email: ${error.message}`) + } + }, +}) + export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: PrismaAdapter(prisma), session: { strategy: "jwt" }, @@ -11,6 +48,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ error: "/auth/error", }, ...authConfig, + providers: [inviteGatedResend], callbacks: { ...authConfig.callbacks, async jwt({ token, user }) { diff --git a/src/lib/invites.ts b/src/lib/invites.ts new file mode 100644 index 0000000..1a10383 --- /dev/null +++ b/src/lib/invites.ts @@ -0,0 +1,97 @@ +/** + * Invite-only access flow (M2). + * + * Admins issue InviteCodes; a user redeems one with their email. Redemption + * upgrades the user's role (never downgrades, never demotes ADMIN) and is + * atomic against concurrent redemptions via an updateMany guard on maxUses. + */ +import { randomInt } from "node:crypto" +import type { Role } from "@/generated/prisma/client" +import type { PrismaClient } from "@/generated/prisma/client" + +// Unambiguous alphabet: no 0/O/1/I/L +const CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" +const CODE_LENGTH = 8 + +export type RedeemResult = + | { ok: true; role: Role; createdUser: boolean } + | { ok: false; error: "invalid" | "expired" | "max_uses"; status: number } + +export function generateInviteCode(): string { + const chars: string[] = [] + for (let i = 0; i < CODE_LENGTH; i++) { + chars.push(CODE_ALPHABET[randomInt(CODE_ALPHABET.length)]) + } + return `ATELIER-${chars.join("")}` +} + +export function normalizeCode(code: string): string { + return code.trim().toUpperCase().replace(/\s+/g, "") +} + +/** + * Redeem an invite code for an email address. Creates the user row if it does + * not exist yet (magic-link sign-in will then find an invited user), upgrades + * the role otherwise. + */ +export async function redeemInvite( + prisma: PrismaClient, + input: { code: string; email: string }, +): Promise { + const email = input.email.trim().toLowerCase() + const code = normalizeCode(input.code) + + const invite = await prisma.inviteCode.findUnique({ where: { code } }) + if (!invite) return { ok: false, error: "invalid", status: 403 } + if (invite.expiresAt && invite.expiresAt < new Date()) { + return { ok: false, error: "expired", status: 403 } + } + + // Atomic maxUses guard: only one concurrent redemption wins when full. + const claimed = await prisma.inviteCode.updateMany({ + where: { id: invite.id, usedCount: { lt: invite.maxUses } }, + data: { usedCount: { increment: 1 } }, + }) + if (claimed.count === 0) return { ok: false, error: "max_uses", status: 403 } + + try { + const existing = await prisma.user.findUnique({ where: { email } }) + const createdUser = !existing + + const user = await prisma.user.upsert({ + where: { email }, + create: { email, name: null, role: invite.role }, + update: { role: upgradeRole(existing?.role ?? "GUEST", invite.role) }, + }) + + await prisma.inviteRedemption.create({ + data: { inviteId: invite.id, userId: user.id, email }, + }) + + return { ok: true, role: user.role, createdUser } + } catch (err) { + // Roll back the claim so a failed redemption doesn't burn a use. + await prisma.inviteCode.update({ + where: { id: invite.id }, + data: { usedCount: { decrement: 1 } }, + }) + throw err + } +} + +/** + * Invites only ever upgrade a role, never downgrade it. ADMIN is absolute — + * nothing can strip it. + */ +export function upgradeRole(current: Role, invited: Role): Role { + if (current === "ADMIN") return "ADMIN" + if (invited === "ADMIN") return "ADMIN" + const rank: Record = { + GUEST: 0, + COLLECTOR: 1, + MAKER: 2, + CURATOR: 3, + ADMIN: 4, + } + return rank[invited] > rank[current] ? invited : current +} diff --git a/src/lib/validators/invite.ts b/src/lib/validators/invite.ts new file mode 100644 index 0000000..a5bcfa3 --- /dev/null +++ b/src/lib/validators/invite.ts @@ -0,0 +1,23 @@ +import { z } from "zod" + +/** Roles an admin can issue an invite for. GUEST is never issuable. */ +export const inviteRoleSchema = z.enum(["COLLECTOR", "MAKER", "CURATOR", "ADMIN"]) + +export const createInviteSchema = z + .object({ + role: inviteRoleSchema.default("COLLECTOR"), + maxUses: z.number().int().min(1).max(1000).default(1), + expiresInDays: z.number().int().min(1).max(365).optional(), + count: z.number().int().min(1).max(50).default(1), + }) + .strict() + +export const redeemInviteSchema = z + .object({ + code: z.string().trim().min(4).max(64), + email: z.string().trim().email().max(254), + }) + .strict() + +export type CreateInviteInput = z.infer +export type RedeemInviteInput = z.infer diff --git a/src/middleware.ts b/src/middleware.ts index 26a062f..fe00116 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -4,27 +4,51 @@ import authConfig from "@/lib/auth.config" // Middleware-compatible auth: uses JWT, no database adapter needed const { auth: middlewareAuth } = NextAuth(authConfig) +const PURCHASE_SURFACES = ["/cart", "/orders", "/commissions"] +const MAKER_ROLES = ["MAKER", "CURATOR", "ADMIN"] +const STAFF_ROLES = ["ADMIN", "CURATOR"] + export default middlewareAuth((req) => { const { nextUrl } = req const isLoggedIn = !!req.auth + const role = req.auth?.user?.role as string | undefined const isDashboard = nextUrl.pathname.startsWith("/dashboard") const isAdmin = nextUrl.pathname.startsWith("/admin") + const isPurchase = PURCHASE_SURFACES.some( + (p) => nextUrl.pathname === p || nextUrl.pathname.startsWith(`${p}/`), + ) + // Admin + maker surfaces: must be logged in if ((isDashboard || isAdmin) && !isLoggedIn) { return Response.redirect(new URL("/sign-in", nextUrl)) } - if (isAdmin) { - const role = req.auth?.user?.role - if (role !== "ADMIN" && role !== "CURATOR") { - return Response.redirect(new URL("/", nextUrl)) - } + // Admin surface: staff only + if (isAdmin && !STAFF_ROLES.includes(role ?? "")) { + return Response.redirect(new URL("/", nextUrl)) + } + + // Maker dashboard: makers + staff only (collectors get the public site) + if (isDashboard && !MAKER_ROLES.includes(role ?? "")) { + return Response.redirect(new URL("/", nextUrl)) + } + + // Purchase surfaces: any invited user, but never a GUEST + if (isPurchase && (!isLoggedIn || role === "GUEST")) { + return Response.redirect(new URL("/sign-in", nextUrl)) } return null }) export const config = { - matcher: ["/dashboard/:path*", "/admin/:path*", "/apply/:path*"], + matcher: [ + "/dashboard/:path*", + "/admin/:path*", + "/apply/:path*", + "/cart/:path*", + "/orders/:path*", + "/commissions/:path*", + ], } diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000..5fca3f8 --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file From 90f91e0b5b245c36fb0c325eecf6368067d38b90 Mon Sep 17 00:00:00 2001 From: mattdani21 Date: Thu, 6 Aug 2026 22:07:51 +0200 Subject: [PATCH 2/4] =?UTF-8?q?M1.4:=20remove=20legacy=20deploy.yml=20?= =?UTF-8?q?=E2=80=94=20staging=20is=20the=20single=20main-push=20deploy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second commit on this branch: strips the M2 invite-flow files that were swept into the first commit by a broad git add, leaving only the workflow removal + STATE.md notes. PR diff = deploy.yml deletion + STATE.md edits. --- PR_SUMMARY.md | 22 - e2e/invites.spec.ts | 172 ------ e2e/setup/seed.ts | 203 ------- prisma/schema.prisma | 688 ---------------------- src/app/admin/invites/invites-console.tsx | 150 ----- src/app/admin/invites/page.tsx | 57 -- src/app/admin/page.tsx | 226 ------- src/app/api/admin/invites/route.ts | 91 --- src/app/api/applications/route.ts | 141 ----- src/app/api/cart/route.ts | 183 ------ src/app/api/checkout/route.ts | 126 ---- src/app/api/commissions/[id]/pay/route.ts | 78 --- src/app/api/commissions/[id]/route.ts | 115 ---- src/app/api/commissions/route.ts | 85 --- src/app/api/e2e/session/route.ts | 100 ---- src/app/api/invites/redeem/route.ts | 39 -- src/app/api/orders/[id]/route.ts | 192 ------ src/app/api/orders/route.ts | 39 -- src/app/sign-in/sign-in-form.tsx | 103 ---- src/lib/__tests__/invites.test.ts | 157 ----- src/lib/auth-utils.ts | 39 -- src/lib/auth.ts | 69 --- src/lib/invites.ts | 97 --- src/lib/validators/invite.ts | 23 - src/middleware.ts | 54 -- test-results/.last-run.json | 4 - 26 files changed, 3253 deletions(-) delete mode 100644 PR_SUMMARY.md delete mode 100644 e2e/invites.spec.ts delete mode 100644 e2e/setup/seed.ts delete mode 100644 prisma/schema.prisma delete mode 100644 src/app/admin/invites/invites-console.tsx delete mode 100644 src/app/admin/invites/page.tsx delete mode 100644 src/app/admin/page.tsx delete mode 100644 src/app/api/admin/invites/route.ts delete mode 100644 src/app/api/applications/route.ts delete mode 100644 src/app/api/cart/route.ts delete mode 100644 src/app/api/checkout/route.ts delete mode 100644 src/app/api/commissions/[id]/pay/route.ts delete mode 100644 src/app/api/commissions/[id]/route.ts delete mode 100644 src/app/api/commissions/route.ts delete mode 100644 src/app/api/e2e/session/route.ts delete mode 100644 src/app/api/invites/redeem/route.ts delete mode 100644 src/app/api/orders/[id]/route.ts delete mode 100644 src/app/api/orders/route.ts delete mode 100644 src/app/sign-in/sign-in-form.tsx delete mode 100644 src/lib/__tests__/invites.test.ts delete mode 100644 src/lib/auth-utils.ts delete mode 100644 src/lib/auth.ts delete mode 100644 src/lib/invites.ts delete mode 100644 src/lib/validators/invite.ts delete mode 100644 src/middleware.ts delete mode 100644 test-results/.last-run.json diff --git a/PR_SUMMARY.md b/PR_SUMMARY.md deleted file mode 100644 index 2cc8d6e..0000000 --- a/PR_SUMMARY.md +++ /dev/null @@ -1,22 +0,0 @@ -# M1.4 — Remove legacy deploy.yml - -## What - -Deleted `.github/workflows/deploy.yml` — the legacy GCP_SA_KEY-based deploy workflow. - -## Why - -- It triggered on **every push to main**, exactly like `deploy-staging.yml`, and deployed to a *third* service name (`atelier`) — so a push to main would fire two deploy workflows targeting different Cloud Run services with no clear owner. -- It used `secrets.GCP_SA_KEY` (long-lived service-account key) instead of Workload Identity Federation (`vars.GCP_WIP`/`GCP_SA`) used by staging and prod. -- It passed secrets as plain env vars (`DATABASE_URL`, `AUTH_SECRET`, `STRIPE_SECRET_KEY`) instead of Secret Manager references. -- The secrets it consumes (`GCP_PROJECT_ID`, `GCP_SA_KEY`, `DATABASE_URL`, …) are not part of the provisioning list in `docs/P1.3-provisioning.md`, so it would fail at auth on the first main push anyway. - -After this change, deploy responsibilities are unambiguous: - -- **`ci.yml`** — lint/type/build/unit/e2e on every PR + main -- **`deploy-staging.yml`** — the single auto-deploy on main push (Cloud Run `atelier-staging`, 0% → health gate → 100%) -- **`deploy-prod.yml`** — tagged `v*` releases with environment approval gate (Cloud Run `atelier-prod`, gradual shift) - -## How tested - -No runtime code touched — workflow removal only. `STATE.md` updated to reflect the removal. CI (lint/type/build/tests) is unaffected and runs on this PR. diff --git a/e2e/invites.spec.ts b/e2e/invites.spec.ts deleted file mode 100644 index 7a0ba59..0000000 --- a/e2e/invites.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * M2 — Invite-only access flow - * - * Admin issues codes → invitees redeem them → magic-link sign-in only serves - * invited emails. Covers: issuance (admin-only), redemption happy path, - * rejection paths (invalid/expired/exhausted), GUEST role gate on purchase - * surfaces, role upgrades, and the sign-in page UX. - */ -import { test, expect, type Page } from "@playwright/test" -import { authenticateAs, getAuthCookieHeader, type TestUser } from "./setup/auth" -import { PrismaClient } from "../src/generated/prisma/client" - -const BASE = "http://localhost:3000" -const prisma = new PrismaClient() - -const ADMIN: TestUser = { - id: "e2e_admin", email: "admin@atelier.test", name: "E2E Admin", role: "ADMIN", -} -const MAKER: TestUser = { - id: "e2e_maker", email: "maker@atelier.test", name: "E2E Maker", role: "MAKER", -} -const GUEST: TestUser = { - id: "e2e_guest", email: "guest@atelier.test", name: "E2E Guest", role: "GUEST", -} - -const stamp = Date.now().toString(36).toUpperCase() -const uniq = (p: string) => `${p}_${stamp}` - -async function issueInvite(page: Page, overrides: Record = {}) { - const res = await page.request.post(`${BASE}/api/admin/invites`, { - headers: { - Cookie: await getAuthCookieHeader(ADMIN), - "Content-Type": "application/json", - }, - data: { role: "COLLECTOR", maxUses: 1, ...overrides }, - }) - expect(res.status()).toBe(201) - const body = await res.json() - return body.invites[0] -} - -test.describe("Invite-only access (M2)", () => { - test.afterAll(async () => { - await prisma.$disconnect() - }) - - test("1. Admin issues invite codes", async ({ page }) => { - const invite = await issueInvite(page, { role: "COLLECTOR", maxUses: 1 }) - - expect(invite.code).toMatch(/^ATELIER-[A-Z2-9]{8}$/) - expect(invite.role).toBe("COLLECTOR") - expect(invite.maxUses).toBe(1) - expect(invite.usedCount).toBe(0) - }) - - test("2. Non-admins cannot issue invites", async ({ page }) => { - for (const user of [GUEST, MAKER]) { - const res = await page.request.post(`${BASE}/api/admin/invites`, { - headers: { - Cookie: await getAuthCookieHeader(user), - "Content-Type": "application/json", - }, - data: { role: "COLLECTOR" }, - }) - expect(res.status()).toBe(403) - } - }) - - test("3. Redemption creates the user with the invited role", async ({ page }) => { - const invite = await issueInvite(page, { role: "COLLECTOR", maxUses: 1 }) - const email = uniq("newcollector@atelier.test") - - const res = await page.request.post(`${BASE}/api/invites/redeem`, { - headers: { "Content-Type": "application/json" }, - data: { code: invite.code, email }, - }) - expect(res.status()).toBe(200) - expect(await res.json()).toEqual({ ok: true, role: "COLLECTOR" }) - - // User row exists with the invited role - const user = await prisma.user.findUnique({ where: { email } }) - expect(user?.role).toBe("COLLECTOR") - - // Redemption recorded, usage counted - const redemption = await prisma.inviteRedemption.findFirst({ - where: { inviteId: invite.id }, - }) - expect(redemption?.email).toBe(email) - const dbInvite = await prisma.inviteCode.findUnique({ where: { id: invite.id } }) - expect(dbInvite?.usedCount).toBe(1) - }) - - test("4. Rejected: invalid, expired, and exhausted codes", async ({ page }) => { - // Invalid code - let res = await page.request.post(`${BASE}/api/invites/redeem`, { - headers: { "Content-Type": "application/json" }, - data: { code: "ATELIER-BOGUS12", email: uniq("nobody@atelier.test") }, - }) - expect(res.status()).toBe(403) - - // Expired code - const expired = await issueInvite(page) - await prisma.inviteCode.update({ - where: { id: expired.id }, - data: { expiresAt: new Date(Date.now() - 1000) }, - }) - res = await page.request.post(`${BASE}/api/invites/redeem`, { - headers: { "Content-Type": "application/json" }, - data: { code: expired.code, email: uniq("expired@atelier.test") }, - }) - expect(res.status()).toBe(403) - - // Exhausted code (maxUses = 1, two different emails) - const oneUse = await issueInvite(page, { maxUses: 1 }) - res = await page.request.post(`${BASE}/api/invites/redeem`, { - headers: { "Content-Type": "application/json" }, - data: { code: oneUse.code, email: uniq("first@atelier.test") }, - }) - expect(res.status()).toBe(200) - res = await page.request.post(`${BASE}/api/invites/redeem`, { - headers: { "Content-Type": "application/json" }, - data: { code: oneUse.code, email: uniq("second@atelier.test") }, - }) - expect(res.status()).toBe(403) - }) - - test("5. GUEST cannot use purchase surfaces", async ({ page }) => { - await authenticateAs(page.context(), GUEST) - - const res = await page.request.post(`${BASE}/api/cart`, { - headers: { - Cookie: await getAuthCookieHeader(GUEST), - "Content-Type": "application/json", - }, - data: { workVariantId: "does-not-matter", quantity: 1 }, - }) - expect(res.status()).toBe(403) - }) - - test("6. Redeeming a MAKER invite upgrades an existing GUEST", async ({ page }) => { - // Ensure the GUEST user row exists - await authenticateAs(page.context(), GUEST) - - const invite = await issueInvite(page, { role: "MAKER", maxUses: 1 }) - const res = await page.request.post(`${BASE}/api/invites/redeem`, { - headers: { "Content-Type": "application/json" }, - data: { code: invite.code, email: GUEST.email }, - }) - expect(res.status()).toBe(200) - expect((await res.json()).role).toBe("MAKER") - - const user = await prisma.user.findUnique({ where: { email: GUEST.email } }) - expect(user?.role).toBe("MAKER") - }) - - test("7. Sign-in page: bad code rejected, good code accepted", async ({ page }) => { - await page.goto("/sign-in") - - // Bad code → clear rejection before any email is sent - await page.fill('input[type="email"]', uniq("badcode@atelier.test")) - await page.fill('input[type="text"]', "ATELIER-BOGUS12") - await page.click('button[type="submit"]') - await expect(page.locator("text=This invite code is not valid.")).toBeVisible() - - // Good code → invite accepted, magic link requested - const invite = await issueInvite(page, { maxUses: 5 }) - await page.fill('input[type="email"]', uniq("goodcode@atelier.test")) - await page.fill('input[type="text"]', invite.code) - await page.click('button[type="submit"]') - await expect(page.locator("text=Invite accepted.")).toBeVisible() - }) -}) diff --git a/e2e/setup/seed.ts b/e2e/setup/seed.ts deleted file mode 100644 index c593c21..0000000 --- a/e2e/setup/seed.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * E2E test data seed. - * Creates test users, makers, works, and variants via the Prisma client. - * Call via: npx tsx e2e/setup/seed.ts - * - * The script reads DATABASE_URL from the environment and pushes the schema - * (idempotent, via db push) before seeding if --push is passed. - */ -import { PrismaClient } from "../../src/generated/prisma/client" - -const prisma = new PrismaClient() - -// ─── Test identifiers ──────────────────────────────────────── -const TEST_PREFIX = "e2e_" - -const USERS = { - collector: { - id: `${TEST_PREFIX}collector`, - email: "collector@atelier.test", - name: "E2E Collector", - role: "COLLECTOR" as const, - }, - maker: { - id: `${TEST_PREFIX}maker`, - email: "maker@atelier.test", - name: "E2E Maker", - role: "MAKER" as const, - }, - maker2: { - id: `${TEST_PREFIX}maker2`, - email: "maker2@atelier.test", - name: "E2E Maker Two", - role: "MAKER" as const, - }, - admin: { - id: `${TEST_PREFIX}admin`, - email: "admin@atelier.test", - name: "E2E Admin", - role: "ADMIN" as const, - }, -} - -async function seed() { - console.log("🌱 Seeding E2E test data…") - - // ── Users ────────────────────────────────────────────────── - for (const u of Object.values(USERS)) { - await prisma.user.upsert({ - where: { id: u.id }, - update: {}, - create: { id: u.id, email: u.email, name: u.name, role: u.role, emailVerified: new Date() }, - }) - } - - // ── Collectors ───────────────────────────────────────────── - await prisma.collector.upsert({ - where: { userId: USERS.collector.id }, - update: {}, - create: { userId: USERS.collector.id, publicHandle: "test_collector" }, - }) - - // ── Makers ───────────────────────────────────────────────── - for (const u of [USERS.maker, USERS.maker2]) { - await prisma.maker.upsert({ - where: { userId: u.id }, - update: {}, - create: { - userId: u.id, - slug: u.id.replace(TEST_PREFIX, ""), - region: "Cape Town", - craftCategory: "ceramics", - bio: "Test maker bio for E2E tests", - tier: "ADMITTED", - status: "LIVE", - stripeAccountId: `acct_${u.id}`, - stripeOnboarded: true, - }, - }) - - // Create application (already admitted) - const maker = await prisma.maker.findUnique({ where: { userId: u.id } }) - if (maker) { - await prisma.makerApplication.upsert({ - where: { makerId: maker.id }, - update: {}, - create: { - makerId: maker.id, - payload: { craft: "ceramics", experience: "5 years", portfolio: "https://example.com" }, - status: "ADMITTED", - decidedAt: new Date(), - }, - }) - } - } - - const maker1 = await prisma.maker.findUniqueOrThrow({ where: { userId: USERS.maker.id } }) - const maker2 = await prisma.maker.findUniqueOrThrow({ where: { userId: USERS.maker2.id } }) - - // ── Works + Variants ─────────────────────────────────────── - const works = [ - { - makerId: maker1.id, - slug: "test-ceramic-vase", - title: "Test Ceramic Vase", - editionKind: "OPEN" as const, - status: "PUBLISHED" as const, - publishedAt: new Date(), - description: "A beautiful hand-thrown ceramic vase for testing purchase flows.", - leadTimeDays: 14, - }, - { - makerId: maker2.id, - slug: "test-wooden-bowl", - title: "Test Wooden Bowl", - editionKind: "MADE_TO_ORDER" as const, - status: "PUBLISHED" as const, - publishedAt: new Date(), - description: "A hand-carved wooden bowl from reclaimed timber.", - leadTimeDays: 21, - }, - ] - - for (const w of works) { - const existing = await prisma.work.findUnique({ - where: { makerId_slug: { makerId: w.makerId, slug: w.slug } }, - }) - - const work = existing - ? await prisma.work.update({ - where: { id: existing.id }, - data: w, - }) - : await prisma.work.create({ data: w }) - - // Create variant - const variantId = `${work.id}_var_default` - await prisma.workVariant.upsert({ - where: { id: variantId }, - update: {}, - create: { - id: variantId, - workId: work.id, - priceMinor: 150000, // $1,500.00 - currency: "USD", - stock: 5, - madeToOrder: w.editionKind === "MADE_TO_ORDER", - }, - }) - - // Create materials (min 3 for listing standards) - const materials = [ - { name: "Clay", origin: "Western Cape" }, - { name: "Glaze", origin: "Local" }, - { name: "Kiln firing", origin: "In-house" }, - ] - - for (const m of materials) { - await prisma.workMaterial.create({ - data: { workId: work.id, ...m }, - }) - } - - // Create media (4 images for listing standards) - for (let i = 0; i < 4; i++) { - await prisma.workMedia.create({ - data: { - workId: work.id, - url: `https://picsum.photos/seed/${work.slug}_${i}/800/800`, - kind: "PHOTO", - position: i, - width: 800, - height: 800, - }, - }) - } - } - - // ── Invite codes ──────────────────────────────────────────── - // Known code so dev/staging smoke tests can sign in (M2). - await prisma.inviteCode.upsert({ - where: { code: "ATELIER-E2EINVITE" }, - update: {}, - create: { - code: "ATELIER-E2EINVITE", - role: "COLLECTOR", - maxUses: 100, - }, - }) - - console.log("✅ Seed complete.") - console.log(` Collector: ${USERS.collector.email}`) - console.log(` Maker 1: ${USERS.maker.email}`) - console.log(` Maker 2: ${USERS.maker2.email}`) - console.log(` Admin: ${USERS.admin.email}`) - console.log("\n All users share password: (magic link — no password needed)") -} - -seed() - .catch((e) => { - console.error("Seed failed:", e) - process.exit(1) - }) - .finally(() => prisma.$disconnect()) diff --git a/prisma/schema.prisma b/prisma/schema.prisma deleted file mode 100644 index 253854c..0000000 --- a/prisma/schema.prisma +++ /dev/null @@ -1,688 +0,0 @@ -// Atelier — Premium craftsman marketplace -// Schema derived from atelier-spec.md §10 Data Model -// Phase 0: Foundation schema with all entities for v1 scope - -generator client { - provider = "prisma-client" - output = "../src/generated/prisma" -} - -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") -} - -// ─── Identity & Auth ──────────────────────────────────────── - -enum Role { - GUEST - COLLECTOR - MAKER - CURATOR - ADMIN -} - -model User { - id String @id @default(cuid()) - email String @unique - emailVerified DateTime? - name String? - image String? - role Role @default(GUEST) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Relations - maker Maker? - collector Collector? - accounts Account[] - sessions Session[] - inviteRedemptions InviteRedemption[] - - @@map("users") -} - -// Invite-only access (M2): codes issued by admins, redeemed by email. -// A redemption upgrades the user's role (never downgrades); the sign-in -// provider refuses to send magic links to emails with no non-GUEST role. -model InviteCode { - id String @id @default(cuid()) - code String @unique - role Role @default(COLLECTOR) - maxUses Int @default(1) - usedCount Int @default(0) - expiresAt DateTime? - createdById String? - createdAt DateTime @default(now()) - - redemptions InviteRedemption[] - - @@map("invite_codes") -} - -model InviteRedemption { - id String @id @default(cuid()) - inviteId String - userId String - email String - createdAt DateTime @default(now()) - - invite InviteCode @relation(fields: [inviteId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@map("invite_redemptions") -} - -// Auth.js models -model Account { - id String @id @default(cuid()) - userId String - type String - provider String - providerAccountId String - refresh_token String? - access_token String? - expires_at Int? - token_type String? - scope String? - id_token String? - session_state String? - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([provider, providerAccountId]) - @@map("accounts") -} - -model Session { - id String @id @default(cuid()) - sessionToken String @unique - userId String - expires DateTime - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@map("sessions") -} - -// ─── Maker ──────────────────────────────────────────────────── - -enum MakerTier { - APPLICANT - ADMITTED - ESTABLISHED - MASTER -} - -enum MakerStatus { - SUBMITTED - IN_REVIEW - ADMITTED - ONBOARDING - LIVE - SUSPENDED - DECLINED -} - -model Maker { - id String @id @default(cuid()) - userId String @unique - slug String @unique - region String? - craftCategory String? - bio String? - story String? // Long-form maker story - portraitUrl String? - workshopPhotos String[] - tier MakerTier @default(APPLICANT) - status MakerStatus @default(SUBMITTED) - stripeAccountId String? // Stripe Connect account ID - stripeOnboarded Boolean @default(false) - acceptsCommissions Boolean @default(false) - platformFeeBps Int @default(1200) // Basis points (1200 = 12%) - leadTimeBaseDays Int @default(14) - shippingOrigin String? // Country code - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - application MakerApplication? - verifications MakerVerification[] - works Work[] - followers Follow[] - testimonials Testimonial[] - orderItems OrderItem[] - commissions Commission[] - stories Story[] - - @@map("makers") -} - -model MakerApplication { - id String @id @default(cuid()) - makerId String @unique - payload Json // Full application data - status MakerStatus @default(SUBMITTED) - juryNotes String? - reviewedBy String? // Admin userId - decidedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - maker Maker @relation(fields: [makerId], references: [id], onDelete: Cascade) - - @@map("maker_applications") -} - -enum VerificationKind { - IDENTITY - WORKSHOP_VISIT - MATERIALS_SOURCING - SUSTAINABILITY -} - -model MakerVerification { - id String @id @default(cuid()) - makerId String - kind VerificationKind - verifiedAt DateTime? - evidenceUrl String? - notes String? - createdAt DateTime @default(now()) - - maker Maker @relation(fields: [makerId], references: [id], onDelete: Cascade) - - @@unique([makerId, kind]) - @@map("maker_verifications") -} - -// ─── Works (the product/object) ────────────────────────────── - -enum EditionKind { - ONE_OF_ONE - LIMITED - OPEN - MADE_TO_ORDER -} - -enum WorkStatus { - DRAFT - PUBLISHED - ARCHIVED - SOLD_OUT -} - -model Work { - id String @id @default(cuid()) - makerId String - slug String - title String - description String? - editionKind EditionKind @default(MADE_TO_ORDER) - editionSize Int? // null for open/made-to-order - editionCurrent Int? // Current edition number sold - leadTimeDays Int @default(14) - status WorkStatus @default(DRAFT) - publishedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - maker Maker @relation(fields: [makerId], references: [id], onDelete: Cascade) - variants WorkVariant[] - materials WorkMaterial[] - media WorkMedia[] - processNotes WorkProcessNote[] - provenanceCertificates ProvenanceCertificate[] - collectionItems EditorialCollectionItem[] - waitlistSubs WaitlistSub[] - - @@unique([makerId, slug]) - @@map("works") -} - -model WorkVariant { - id String @id @default(cuid()) - workId String - sku String? - attributes Json? // { size, finish, etc. } - priceMinor Int // Price in minor currency units (cents) - currency String @default("USD") - stock Int @default(0) - madeToOrder Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - work Work @relation(fields: [workId], references: [id], onDelete: Cascade) - orderItems OrderItem[] - cartItems CartItem[] - - @@map("work_variants") -} - -model WorkMaterial { - id String @id @default(cuid()) - workId String - name String - origin String? // Geographic or source origin - percentage Int? // Percentage of total materials (for composite objects) - - work Work @relation(fields: [workId], references: [id], onDelete: Cascade) - - @@map("work_materials") -} - -enum MediaKind { - PHOTO - VIDEO - THREE_SIXTY -} - -model WorkMedia { - id String @id @default(cuid()) - workId String - kind MediaKind @default(PHOTO) - url String - width Int? - height Int? - position Int @default(0) // Ordering in gallery - altText String? - moderationStatus String @default("unchecked") // P2.3: unchecked|clean|flagged|blocked - - work Work @relation(fields: [workId], references: [id], onDelete: Cascade) - - @@map("work_media") -} - -model WorkProcessNote { - id String @id @default(cuid()) - workId String - hours Int? // Hours to create - technique String? - notes String? - photoUrls String[] - - work Work @relation(fields: [workId], references: [id], onDelete: Cascade) - - @@map("work_process_notes") -} - -model ProvenanceCertificate { - id String @id @default(cuid()) - workId String - editionNumber Int // Which edition this cert belongs to - pdfUrl String? - hash String? // Content hash for verification - issuedAt DateTime @default(now()) - - work Work @relation(fields: [workId], references: [id], onDelete: Cascade) - - @@map("provenance_certificates") -} - -// ─── Collector (Buyer) ────────────────────────────────────── - -model Collector { - id String @id @default(cuid()) - userId String @unique - publicHandle String? - publicCollectionOptIn Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - follows Follow[] - waitlistSubs WaitlistSub[] - orders Order[] - commissions Commission[] - testimonials Testimonial[] - savedSearches SavedSearch[] - cart Cart? - - @@map("collectors") -} - -model Cart { - id String @id @default(cuid()) - collectorId String @unique - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - collector Collector @relation(fields: [collectorId], references: [id], onDelete: Cascade) - items CartItem[] - - @@map("carts") -} - -model CartItem { - id String @id @default(cuid()) - cartId String - workVariantId String - quantity Int @default(1) - createdAt DateTime @default(now()) - - cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade) - variant WorkVariant @relation(fields: [workVariantId], references: [id]) - - @@unique([cartId, workVariantId]) - @@map("cart_items") -} - -model Follow { - id String @id @default(cuid()) - collectorId String - makerId String - createdAt DateTime @default(now()) - - collector Collector @relation(fields: [collectorId], references: [id], onDelete: Cascade) - maker Maker @relation(fields: [makerId], references: [id], onDelete: Cascade) - - @@unique([collectorId, makerId]) - @@map("follows") -} - -model WaitlistSub { - id String @id @default(cuid()) - collectorId String - workId String - createdAt DateTime @default(now()) - - collector Collector @relation(fields: [collectorId], references: [id], onDelete: Cascade) - work Work @relation(fields: [workId], references: [id], onDelete: Cascade) - - @@unique([collectorId, workId]) - @@map("waitlist_subs") -} - -// ─── Commerce ──────────────────────────────────────────────── - -enum OrderStatus { - PENDING - CONFIRMED - IN_PROGRESS - SHIPPED - DELIVERED - CANCELLED - DISPUTED -} - -model Order { - id String @id @default(cuid()) - collectorId String - status OrderStatus @default(PENDING) - totalMinor Int @default(0) - currency String @default("USD") - stripeSessionId String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - collector Collector @relation(fields: [collectorId], references: [id]) - items OrderItem[] - - @@map("orders") -} - -model OrderItem { - id String @id @default(cuid()) - orderId String - workVariantId String - makerId String - priceMinor Int - leadTimeDays Int - progressPhotos String[] // URLs of progress photos uploaded by maker - - order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) - variant WorkVariant @relation(fields: [workVariantId], references: [id]) - maker Maker @relation(fields: [makerId], references: [id]) - - @@map("order_items") -} - -model Shipment { - id String @id @default(cuid()) - orderId String - makerId String - carrier String? - tracking String? - status String @default("pending") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - // Using direct IDs since shipments are per-maker within an order - // Full relation to Order requires a compound key; simplifying for v1 - - @@map("shipments") -} - -// ─── Commissions ───────────────────────────────────────────── - -enum CommissionStatus { - SUBMITTED - PROPOSED - ACCEPTED - IN_PROGRESS - DELIVERED - DECLINED - CANCELLED -} - -model Commission { - id String @id @default(cuid()) - collectorId String - makerId String - brief String // Free text from collector - referenceUrls String[] // Reference images - budgetBand String? // e.g. "500-1000" - status CommissionStatus @default(SUBMITTED) - totalMinor Int? - currency String @default("USD") - milestones Json? // [{ description, amountMinor, status, dueAt }] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - collector Collector @relation(fields: [collectorId], references: [id]) - maker Maker @relation(fields: [makerId], references: [id]) - - @@map("commissions") -} - -// ─── Conversations ────────────────────────────────────────── - -enum ConversationScope { - WORK - COMMISSION -} - -model Conversation { - id String @id @default(cuid()) - scopeKind ConversationScope - scopeId String // workId or commissionId - openedById String - offenseCount Int @default(0) // P2.2: off-platform contact strikes (0-3) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - messages Message[] - - @@map("conversations") -} - -model Message { - id String @id @default(cuid()) - conversationId String - senderId String // userId - body String - flagged Boolean @default(false) // P2.2: flagged for off-platform contact - createdAt DateTime @default(now()) - - conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) - - @@map("messages") -} - -// ─── Editorial ─────────────────────────────────────────────── - -model EditorialCollection { - id String @id @default(cuid()) - slug String @unique - title String - copy String? - heroMediaUrl String? - publishedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - items EditorialCollectionItem[] - - @@map("editorial_collections") -} - -model EditorialCollectionItem { - id String @id @default(cuid()) - collectionId String - workId String - position Int @default(0) - caption String? - - collection EditorialCollection @relation(fields: [collectionId], references: [id], onDelete: Cascade) - work Work @relation(fields: [workId], references: [id], onDelete: Cascade) - - @@unique([collectionId, workId]) - @@map("editorial_collection_items") -} - -model Story { - id String @id @default(cuid()) - slug String @unique - title String - bodyMarkdown String - heroMediaUrl String? - makerId String? - publishedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - maker Maker? @relation(fields: [makerId], references: [id]) - - @@map("stories") -} - -enum FeatureKind { - MAKER_OF_THE_MONTH - DISPATCH - EDITORIAL -} - -model Feature { - id String @id @default(cuid()) - kind FeatureKind - slug String @unique - targetId String? // makerId or collectionId - scheduledFor DateTime? - publishedAt DateTime? - createdAt DateTime @default(now()) - - @@map("features") -} - -// ─── Reputation ────────────────────────────────────────────── - -model Testimonial { - id String @id @default(cuid()) - makerId String - collectorId String - body String - moderated Boolean @default(false) - displayed Boolean @default(false) - createdAt DateTime @default(now()) - - maker Maker @relation(fields: [makerId], references: [id], onDelete: Cascade) - collector Collector @relation(fields: [collectorId], references: [id]) - - @@map("testimonials") -} - -model ProcessedWebhookEvent { - id String @id // Stripe event ID - type String - processedAt DateTime @default(now()) - - @@map("processed_webhook_events") -} - -// ─── Analytics ─────────────────────────────────────────────── - -model PageView { - id String @id @default(cuid()) - path String - workId String? // Null for non-work pages - makerId String? // Denormalized for faster aggregation - referrer String? - createdAt DateTime @default(now()) - - @@index([workId]) - @@index([makerId, createdAt]) - @@map("page_views") -} - -model MakerAnalyticsDaily { - id String @id @default(cuid()) - makerId String - date DateTime @db.Date - views Int @default(0) - uniqueVisitors Int @default(0) - follows Int @default(0) - orders Int @default(0) - revenueMinor Int @default(0) - conversionRate Float @default(0) // orders / views - - @@unique([makerId, date]) - @@index([makerId]) - @@map("maker_analytics_daily") -} - -// ─── Saved Searches ────────────────────────────────────────── - -model SavedSearch { - id String @id @default(cuid()) - collectorId String - query String // Search query text - filters Json // { craft, region, material, priceMin, priceMax } - notifyEmail Boolean @default(true) - lastNotifiedAt DateTime? - createdAt DateTime @default(now()) - - collector Collector @relation(fields: [collectorId], references: [id], onDelete: Cascade) - - @@index([collectorId]) - @@map("saved_searches") -} - -// ─── Rate Limiting ─────────────────────────────────────────── - -model RateLimitBucket { - key String @id - tokens Int @default(0) - lastRefill DateTime @default(now()) - maxTokens Int - refillRate Float // Tokens per hour - - @@map("rate_limit_buckets") -} - -// ─── Audit Log ─────────────────────────────────────────────── - -model AuditLog { - id String @id @default(cuid()) - actorId String // userId who performed the action - action String // e.g. "standards.override", "tier.set", "maker.admitted" - targetType String // e.g. "work", "maker", "commission" - targetId String // ID of the affected entity - justification String? // Admin's reason for the action - metadata Json? // Optional extra context (old/new values, etc.) - createdAt DateTime @default(now()) - - @@index([targetType, targetId]) - @@index([actorId]) - @@map("audit_logs") -} - diff --git a/src/app/admin/invites/invites-console.tsx b/src/app/admin/invites/invites-console.tsx deleted file mode 100644 index 374931e..0000000 --- a/src/app/admin/invites/invites-console.tsx +++ /dev/null @@ -1,150 +0,0 @@ -"use client" - -import { useState } from "react" - -interface IssuedInvite { - id: string - code: string - role: string - maxUses: number - usedCount: number - expiresAt: string | null -} - -export function InvitesConsole() { - const [role, setRole] = useState("COLLECTOR") - const [maxUses, setMaxUses] = useState(1) - const [expiresInDays, setExpiresInDays] = useState("") - const [count, setCount] = useState(1) - const [issuing, setIssuing] = useState(false) - const [error, setError] = useState(null) - const [issued, setIssued] = useState([]) - const [copied, setCopied] = useState(null) - - async function handleIssue(e: React.FormEvent) { - e.preventDefault() - setIssuing(true) - setError(null) - setIssued([]) - - try { - const res = await fetch("/api/admin/invites", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - role, - maxUses: Number(maxUses), - expiresInDays: expiresInDays ? Number(expiresInDays) : undefined, - count: Number(count), - }), - }) - const data = await res.json() - if (!res.ok) { - setError(data.error || "Failed to issue invites") - return - } - setIssued(data.invites) - } catch { - setError("Failed to issue invites") - } finally { - setIssuing(false) - } - } - - async function copyCode(code: string) { - await navigator.clipboard.writeText(code) - setCopied(code) - setTimeout(() => setCopied(null), 1500) - } - - return ( -
-

Issue new codes

- -
- - - - - - - - - -
- - {error &&

{error}

} - - {issued.length > 0 && ( -
-

- Share these codes with your invitees: -

-
- {issued.map((invite) => ( -
- {invite.code} - -
- ))} -
-
- )} -
- ) -} diff --git a/src/app/admin/invites/page.tsx b/src/app/admin/invites/page.tsx deleted file mode 100644 index 386c9bd..0000000 --- a/src/app/admin/invites/page.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { prisma } from "@/lib/prisma" -import { InvitesConsole } from "./invites-console" - -export const dynamic = "force-dynamic" - -export default async function AdminInvitesPage() { - const invites = await prisma.inviteCode.findMany({ - orderBy: { createdAt: "desc" }, - take: 200, - include: { _count: { select: { redemptions: true } } }, - }) - - return ( -
-
-

Invite Codes

-

- Issue access codes. Redeemed codes grant the invited role at sign-in - — no one can register without one. -

- - - -
-

Issued codes

- {invites.length === 0 ? ( -

No invite codes issued yet.

- ) : ( -
- {invites.map((invite) => ( -
-
- {invite.code} - - {invite.role} - - {invite.expiresAt && ( - - expires {invite.expiresAt.toLocaleDateString()} - - )} -
- - {invite.usedCount}/{invite.maxUses} used - -
- ))} -
- )} -
-
-
- ) -} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx deleted file mode 100644 index 3a3ec81..0000000 --- a/src/app/admin/page.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { prisma } from "@/lib/prisma" -import { formatCurrency } from "@/lib/utils" - -export const dynamic = "force-dynamic" - -export default async function AdminDashboardPage() { - const now = new Date() - const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) - - // Fetch all metrics in parallel - const [ - applicationCount, - makersByTier, - gmvThisWeek, - gmvLastWeek, - openCommissions, - pendingRefunds, - totalOrders, - activeConversations, - ] = await Promise.all([ - // Applications in queue - prisma.makerApplication.count({ where: { status: "SUBMITTED" } }), - - // Makers by tier - prisma.maker.groupBy({ by: ["tier"], _count: true }), - - // GMV this week (orders confirmed this week) - prisma.order.aggregate({ - _sum: { totalMinor: true }, - where: { - status: { in: ["CONFIRMED", "IN_PROGRESS", "SHIPPED", "DELIVERED"] }, - createdAt: { gte: weekAgo }, - }, - }), - - // GMV last week (for comparison) - prisma.order.aggregate({ - _sum: { totalMinor: true }, - where: { - status: { in: ["CONFIRMED", "IN_PROGRESS", "SHIPPED", "DELIVERED"] }, - createdAt: { - gte: new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000), - lt: weekAgo, - }, - }, - }), - - // Open commissions - prisma.commission.count({ - where: { status: { in: ["SUBMITTED", "PROPOSED", "ACCEPTED", "IN_PROGRESS"] } }, - }), - - // Pending refunds / disputed orders - prisma.order.count({ where: { status: "DISPUTED" } }), - - // Total orders - prisma.order.count(), - - // Active conversations (messages in last 24h) - prisma.conversation.count({ - where: { - messages: { some: { createdAt: { gte: new Date(now.getTime() - 24 * 60 * 60 * 1000) } } }, - }, - }), - ]) - - const gmvAllTime = (await prisma.order.aggregate({ - _sum: { totalMinor: true }, - where: { status: { in: ["CONFIRMED", "IN_PROGRESS", "SHIPPED", "DELIVERED"] } }, - }))._sum.totalMinor || 0 - - const gmvWeekly = gmvThisWeek._sum.totalMinor || 0 - const gmvPrevious = gmvLastWeek._sum.totalMinor || 0 - const gmvChange = gmvPrevious > 0 - ? ((gmvWeekly - gmvPrevious) / gmvPrevious * 100).toFixed(1) - : null - - const tierCounts: Record = {} - for (const row of makersByTier) { tierCounts[row.tier] = row._count } - - return ( -
-
-

Atelier

-

- {new Date().toLocaleDateString("en-ZA", { weekday: "long", day: "numeric", month: "long", year: "numeric" })} -

- - {/* KPI row */} -
- 0 ? "warning" : "neutral"} - /> - - - 0 ? "warning" : "neutral"} - /> -
- - {/* Detail panels */} -
- {/* Makers by tier */} -
-

Makers by Tier

-
- - - - -
- - View all makers → - -
- - {/* Quick stats */} -
-

Platform Activity

-
- - - - -
-
-
- - {/* Quick links */} - -
-
- ) -} - -// ─── Sub-components ────────────────────────────────────────── - -function MetricCard({ - label, - value, - href, - variant, - trend, -}: { - label: string - value: string | number - href: string - variant: "neutral" | "warning" - trend?: string -}) { - return ( - -

{label}

-

- {value} -

- {trend && ( -

- {trend.startsWith("-") ? "↓" : "↑"} {trend} vs last week -

- )} -
- ) -} - -function TierRow({ label, count, color }: { label: string; count: number; color: string }) { - const max = 20 - const width = Math.min((count / max) * 100, 100) - return ( -
- {label} -
-
-
- {count} -
- ) -} - -function StatRow({ label, value }: { label: string; value: string | number }) { - return ( -
- {label} - {value} -
- ) -} diff --git a/src/app/api/admin/invites/route.ts b/src/app/api/admin/invites/route.ts deleted file mode 100644 index bff50a5..0000000 --- a/src/app/api/admin/invites/route.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { NextResponse } from "next/server" -import { prisma } from "@/lib/prisma" -import { getSession } from "@/lib/auth-utils" -import { generateInviteCode } from "@/lib/invites" -import { createInviteSchema } from "@/lib/validators/invite" -import { logger } from "@/lib/logger" - -// GET /api/admin/invites — list invite codes with usage (ADMIN only) -export async function GET() { - const session = await getSession() - if (!session?.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - if (session.user.role !== "ADMIN") { - return NextResponse.json({ error: "Admin only" }, { status: 403 }) - } - - const invites = await prisma.inviteCode.findMany({ - orderBy: { createdAt: "desc" }, - take: 200, - include: { _count: { select: { redemptions: true } } }, - }) - - return NextResponse.json({ invites }) -} - -// POST /api/admin/invites — issue one or more invite codes (ADMIN only) -export async function POST(req: Request) { - const session = await getSession() - if (!session?.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - if (session.user.role !== "ADMIN") { - return NextResponse.json({ error: "Admin only" }, { status: 403 }) - } - - let body: unknown - try { - body = await req.json() - } catch { - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }) - } - - const parsed = createInviteSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json( - { error: "Validation failed", details: parsed.error.flatten().fieldErrors }, - { status: 422 }, - ) - } - - const { role, maxUses, expiresInDays, count } = parsed.data - const expiresAt = expiresInDays - ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000) - : null - - const codes = await prisma.$transaction( - Array.from({ length: count }, () => - prisma.inviteCode.create({ - data: { - code: generateInviteCode(), - role, - maxUses, - expiresAt, - createdById: session.user.id, - }, - }), - ), - ) - - logger.info("Invite codes issued", { - count, - role, - maxUses, - byUserId: session.user.id, - }) - - return NextResponse.json( - { - invites: codes.map((c) => ({ - id: c.id, - code: c.code, - role: c.role, - maxUses: c.maxUses, - usedCount: c.usedCount, - expiresAt: c.expiresAt, - })), - }, - { status: 201 }, - ) -} diff --git a/src/app/api/applications/route.ts b/src/app/api/applications/route.ts deleted file mode 100644 index afb9401..0000000 --- a/src/app/api/applications/route.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { NextResponse } from "next/server" -import { getSession, guestDenied } from "@/lib/auth-utils" -import { prisma } from "@/lib/prisma" -import { submitApplicationSchema } from "@/lib/validators/application" -import { logger, businessEvent } from "@/lib/logger" -import { sendEmail } from "@/lib/email" -import { ApplicationReceivedEmail } from "@/emails/application-received" -import { RateLimiters } from "@/lib/rate-limiter" - -export async function POST(req: Request) { - const session = await getSession() - if (!session?.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - const denied = guestDenied(session) - if (denied) return denied - - // ── P2.1: Rate limiting — 3 applications per day ─────────── - const rateCheck = await RateLimiters.applicationSubmit(session.user.id) - if (!rateCheck.allowed) { - return NextResponse.json( - { error: "Too many applications. Please wait before submitting again." }, - { status: 429, headers: { "Retry-After": String(rateCheck.retryAfterSeconds) } } - ) - } - - const body = await req.json() - const parsed = submitApplicationSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json({ error: "Validation failed", details: parsed.error.flatten() }, { status: 400 }) - } - - // Check if user already has a maker account or pending application - const existing = await prisma.maker.findUnique({ - where: { userId: session.user.id }, - include: { application: true }, - }) - - if (existing) { - if (existing.status === "DECLINED") { - // Allow re-application — delete old application - if (existing.application) { - await prisma.makerApplication.delete({ where: { makerId: existing.id } }) - } - // Update existing maker record - await prisma.makerApplication.create({ - data: { - makerId: existing.id, - payload: parsed.data, - status: "SUBMITTED", - }, - }) - await prisma.maker.update({ - where: { id: existing.id }, - data: { status: "SUBMITTED" }, - }) - logger.info("Application resubmitted", { makerId: existing.id }) - return NextResponse.json({ status: "submitted", makerId: existing.id }) - } - return NextResponse.json( - { error: "You already have an application or maker account" }, - { status: 409 } - ) - } - - // Create maker + application in transaction - const slug = parsed.data.workshopName - ? parsed.data.workshopName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") - : `maker-${session.user.id.slice(-8)}` - - const maker = await prisma.maker.create({ - data: { - userId: session.user.id, - slug, - craftCategory: parsed.data.craftCategory, - tier: "APPLICANT", - status: "SUBMITTED", - application: { - create: { - payload: parsed.data, - status: "SUBMITTED", - }, - }, - }, - include: { application: true }, - }) - - // Update user role to APPLICANT (MAKER role after admission) - await prisma.user.update({ - where: { id: session.user.id }, - data: { role: "MAKER" }, - }) - - logger.info("Application submitted", { makerId: maker.id }) - - // Send confirmation email - sendEmail({ - to: parsed.data.email, - subject: "Your application to Atelier has been received", - react: ApplicationReceivedEmail({ - makerName: parsed.data.fullName, - craftCategory: parsed.data.craftCategory, - }), - }).catch(() => {}) // fire-and-forget - - businessEvent("application.submitted", { makerId: maker.id, craft: parsed.data.craftCategory }) - - return NextResponse.json({ - status: "submitted", - makerId: maker.id, - applicationId: maker.application!.id, - }) -} - -export async function GET(req: Request) { - const session = await getSession() - if (!session?.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - const denied = guestDenied(session) - if (denied) return denied - - const maker = await prisma.maker.findUnique({ - where: { userId: session.user.id }, - include: { application: true }, - }) - - if (!maker) { - return NextResponse.json({ status: "none" }) - } - - return NextResponse.json({ - status: maker.status, - makerId: maker.id, - application: maker.application ? { - id: maker.application.id, - status: maker.application.status, - submittedAt: maker.application.createdAt, - } : null, - }) -} diff --git a/src/app/api/cart/route.ts b/src/app/api/cart/route.ts deleted file mode 100644 index b6ae113..0000000 --- a/src/app/api/cart/route.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { NextResponse } from "next/server" -import { getSession, guestDenied } from "@/lib/auth-utils" -import { prisma } from "@/lib/prisma" -import { logger } from "@/lib/logger" - -// GET — get current cart -export async function GET() { - const session = await getSession() - if (!session?.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - const denied = guestDenied(session) - if (denied) return denied - - const collector = await prisma.collector.findUnique({ - where: { userId: session.user.id }, - include: { - cart: { - include: { - items: { - include: { - variant: { - include: { - work: { - include: { - maker: { select: { slug: true } }, - media: { orderBy: { position: "asc" }, take: 1 }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }) - - if (!collector?.cart) { - return NextResponse.json({ cart: { items: [] } }) - } - - return NextResponse.json({ cart: collector.cart }) -} - -// POST — add item to cart -export async function POST(req: Request) { - const session = await getSession() - if (!session?.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - const denied = guestDenied(session) - if (denied) return denied - - const body = await req.json() - const { workVariantId, quantity = 1, forceSingleMaker } = body - - if (!workVariantId) { - return NextResponse.json({ error: "workVariantId required" }, { status: 400 }) - } - - // Verify variant exists and is available - const variant = await prisma.workVariant.findUnique({ - where: { id: workVariantId }, - include: { work: { select: { status: true, makerId: true } } }, - }) - - if (!variant || variant.work.status !== "PUBLISHED") { - return NextResponse.json({ error: "Variant not available" }, { status: 404 }) - } - - // Get or create collector + cart - let collector = await prisma.collector.findUnique({ - where: { userId: session.user.id }, - }) - - if (!collector) { - collector = await prisma.collector.create({ - data: { userId: session.user.id }, - }) - } - - let cart = await prisma.cart.findUnique({ - where: { collectorId: collector.id }, - include: { - items: { - include: { - variant: { include: { work: { select: { makerId: true } } } }, - }, - }, - }, - }) - - if (!cart) { - cart = await prisma.cart.create({ - data: { collectorId: collector.id }, - include: { items: { include: { variant: { include: { work: { select: { makerId: true } } } } } } }, - }) - } - - // P0.1: Single-maker cart constraint — check for cross-maker contamination - const cartMakerIds = new Set( - cart.items.map((item) => item.variant.work.makerId) - ) - - if (cartMakerIds.size > 0 && !cartMakerIds.has(variant.work.makerId)) { - // Different maker detected - if (forceSingleMaker) { - // Clear cart and start fresh with this maker's item - await prisma.cartItem.deleteMany({ where: { cartId: cart.id } }) - cartMakerIds.clear() - } else { - // Ask the client to confirm by returning a conflict signal - return NextResponse.json({ - conflict: "different_maker", - currentMakerId: [...cartMakerIds][0], - requestedMakerId: variant.work.makerId, - message: "This piece is from a different maker — start a separate order?", - }, { status: 409 }) - } - } - - // Upsert cart item - const existing = await prisma.cartItem.findUnique({ - where: { cartId_workVariantId: { cartId: cart.id, workVariantId } }, - }) - - if (existing) { - await prisma.cartItem.update({ - where: { id: existing.id }, - data: { quantity: existing.quantity + quantity }, - }) - } else { - await prisma.cartItem.create({ - data: { cartId: cart.id, workVariantId, quantity }, - }) - } - - logger.info("Cart item added", { collectorId: collector.id, workVariantId, quantity }) - return NextResponse.json({ success: true }) -} - -// PATCH — update item quantity -export async function PATCH(req: Request) { - const session = await getSession() - if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - const denied = guestDenied(session) - if (denied) return denied - - const body = await req.json() - const { cartItemId, quantity } = body - - if (!cartItemId || quantity === undefined) { - return NextResponse.json({ error: "cartItemId and quantity required" }, { status: 400 }) - } - - if (quantity < 1) { - await prisma.cartItem.delete({ where: { id: cartItemId } }) - return NextResponse.json({ success: true, removed: true }) - } - - await prisma.cartItem.update({ - where: { id: cartItemId }, - data: { quantity }, - }) - - return NextResponse.json({ success: true }) -} - -// DELETE — remove item from cart -export async function DELETE(req: Request) { - const session = await getSession() - if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - const denied = guestDenied(session) - if (denied) return denied - - const { searchParams } = new URL(req.url) - const cartItemId = searchParams.get("id") - if (!cartItemId) return NextResponse.json({ error: "id required" }, { status: 400 }) - - await prisma.cartItem.delete({ where: { id: cartItemId } }) - return NextResponse.json({ success: true }) -} diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts deleted file mode 100644 index e44d177..0000000 --- a/src/app/api/checkout/route.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { NextResponse } from "next/server" -import { getSession, guestDenied } from "@/lib/auth-utils" -import { prisma } from "@/lib/prisma" -import Stripe from "stripe" -import { logger } from "@/lib/logger" - -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { - apiVersion: "2025-04-30.basil" as any, -}) - -export async function POST(req: Request) { - const session = await getSession() - if (!session?.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - const denied = guestDenied(session) - if (denied) return denied - - // Get user's cart - const collector = await prisma.collector.findUnique({ - where: { userId: session.user.id }, - include: { - cart: { - include: { - items: { - include: { - variant: { - include: { - work: { - include: { - maker: { select: { id: true, slug: true, stripeAccountId: true, platformFeeBps: true } }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - }) - - if (!collector?.cart?.items.length) { - return NextResponse.json({ error: "Cart is empty" }, { status: 400 }) - } - - // Create the order first (PENDING status) - const order = await prisma.order.create({ - data: { - collectorId: collector.id, - status: "PENDING", - totalMinor: 0, - currency: "USD", - items: { - create: collector.cart.items.map((item) => ({ - workVariantId: item.workVariantId, - makerId: item.variant.work.makerId, - priceMinor: item.variant.priceMinor * item.quantity, - leadTimeDays: item.variant.work.leadTimeDays, - })), - }, - }, - include: { items: true }, - }) - - // Calculate total - const totalMinor = order.items.reduce((sum, item) => sum + item.priceMinor, 0) - await prisma.order.update({ - where: { id: order.id }, - data: { totalMinor }, - }) - - // Build Stripe line items with Connect transfers - const lineItems = collector.cart.items.map((item) => ({ - price_data: { - currency: "usd", - product_data: { - name: item.variant.work.title, - metadata: { workVariantId: item.workVariantId, makerId: item.variant.work.makerId }, - }, - unit_amount: item.variant.priceMinor, - }, - quantity: item.quantity, - })) - - // Create Stripe Checkout session with Connect transfers - const stripeSession = await stripe.checkout.sessions.create({ - mode: "payment", - payment_method_types: ["card"], - line_items: lineItems, - success_url: `${process.env.NEXT_PUBLIC_APP_URL}/orders/${order.id}?success=true`, - cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/cart?cancelled=true`, - metadata: { orderId: order.id }, - payment_intent_data: { - application_fee_amount: Math.round( - (totalMinor * (collector.cart.items[0]?.variant.work.maker.platformFeeBps || 1200)) / 10000 - ), - ...(collector.cart.items[0]?.variant.work.maker.stripeAccountId - ? { transfer_data: { destination: collector.cart.items[0].variant.work.maker.stripeAccountId } } - : {}), - metadata: { orderId: order.id }, - }, - // For multi-maker orders with Connect, we need separate transfers - // This simplified version transfers to the first maker; full split uses transfers array - }) - - // Link Stripe session to order - await prisma.order.update({ - where: { id: order.id }, - data: { stripeSessionId: stripeSession.id }, - }) - - // Clear cart - await prisma.cartItem.deleteMany({ - where: { cartId: collector.cart.id }, - }) - - logger.info("Checkout session created", { - orderId: order.id, - stripeSessionId: stripeSession.id, - totalMinor, - itemCount: order.items.length, - }) - - return NextResponse.json({ url: stripeSession.url!, orderId: order.id }) -} diff --git a/src/app/api/commissions/[id]/pay/route.ts b/src/app/api/commissions/[id]/pay/route.ts deleted file mode 100644 index 157ef36..0000000 --- a/src/app/api/commissions/[id]/pay/route.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { NextResponse } from "next/server" -import { getSession, guestDenied } from "@/lib/auth-utils" -import { prisma } from "@/lib/prisma" -import Stripe from "stripe" -import { logger } from "@/lib/logger" - -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { - apiVersion: "2025-04-30.basil" as any, -}) - -export async function POST( - req: Request, - { params }: { params: Promise<{ id: string }> } -) { - const session = await getSession() - if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - const denied = guestDenied(session) - if (denied) return denied - - const { id } = await params - const body = await req.json() - const { milestoneIndex } = body // Index of milestone in the milestones array - - const collector = await prisma.collector.findUnique({ where: { userId: session.user.id } }) - if (!collector) return NextResponse.json({ error: "Not a collector" }, { status: 403 }) - - const commission = await prisma.commission.findUnique({ - where: { id }, - include: { maker: { select: { stripeAccountId: true } } }, - }) - - if (!commission || commission.collectorId !== collector.id) { - return NextResponse.json({ error: "Not found" }, { status: 404 }) - } - - const milestones = commission.milestones as any[] - if (!milestones || milestoneIndex === undefined || !milestones[milestoneIndex]) { - return NextResponse.json({ error: "Invalid milestone" }, { status: 400 }) - } - - const milestone = milestones[milestoneIndex] - if (milestone.status === "paid") { - return NextResponse.json({ error: "Milestone already paid" }, { status: 400 }) - } - - const stripeSession = await stripe.checkout.sessions.create({ - mode: "payment", - payment_method_types: ["card"], - line_items: [{ - price_data: { - currency: "usd", - product_data: { - name: `Commission: ${milestone.description}`, - description: `Milestone ${milestoneIndex + 1} of ${milestones.length}`, - }, - unit_amount: milestone.amountMinor, - }, - quantity: 1, - }], - success_url: `${process.env.NEXT_PUBLIC_APP_URL}/commissions/${id}?paid=true`, - cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/commissions/${id}`, - metadata: { commissionId: id, milestoneIndex: String(milestoneIndex) }, - payment_intent_data: { - application_fee_amount: Math.round(milestone.amountMinor * 0.12), - ...(commission.maker.stripeAccountId - ? { transfer_data: { destination: commission.maker.stripeAccountId } } - : {}), - }, - }) - - logger.info("Commission milestone payment initiated", { - commissionId: id, - milestoneIndex, - amount: milestone.amountMinor, - }) - - return NextResponse.json({ url: stripeSession.url }) -} diff --git a/src/app/api/commissions/[id]/route.ts b/src/app/api/commissions/[id]/route.ts deleted file mode 100644 index 6ef4dd5..0000000 --- a/src/app/api/commissions/[id]/route.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { NextResponse } from "next/server" -import { getSession, guestDenied } from "@/lib/auth-utils" -import { prisma } from "@/lib/prisma" -import { proposeCommissionSchema } from "@/lib/validators/commission" -import { logger, businessEvent } from "@/lib/logger" - -export async function GET( - req: Request, - { params }: { params: Promise<{ id: string }> } -) { - const session = await getSession() - if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - const denied = guestDenied(session) - if (denied) return denied - - const { id } = await params - const commission = await prisma.commission.findUnique({ - where: { id }, - include: { - maker: { select: { slug: true, stripeAccountId: true } }, - collector: { select: { publicHandle: true } }, - }, - }) - - if (!commission) return NextResponse.json({ error: "Not found" }, { status: 404 }) - return NextResponse.json({ commission }) -} - -// Maker: propose terms / accept / decline -export async function PATCH( - req: Request, - { params }: { params: Promise<{ id: string }> } -) { - const session = await getSession() - if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - const denied = guestDenied(session) - if (denied) return denied - - const { id } = await params - const body = await req.json() - const { action } = body // "propose" | "accept" | "decline" | "complete" - - // Collectors can accept; makers do everything else - const isCollectorAction = action === "accept" - const maker = isCollectorAction ? null : await prisma.maker.findUnique({ where: { userId: session.user.id } }) - if (!isCollectorAction && !maker) return NextResponse.json({ error: "Not a maker" }, { status: 403 }) - - const commission = await prisma.commission.findUnique({ where: { id } }) - if (!commission) return NextResponse.json({ error: "Not found" }, { status: 404 }) - - // Collectors can only accept their own commissions - if (isCollectorAction) { - const collector = await prisma.collector.findUnique({ where: { userId: session.user.id } }) - if (!collector || commission.collectorId !== collector.id) { - return NextResponse.json({ error: "Not your commission" }, { status: 403 }) - } - } else if (commission.makerId !== maker!.id) { - return NextResponse.json({ error: "Not found" }, { status: 404 }) - } - - let update: any = {} - let event: string | null = null - - switch (action) { - case "propose": { - const parsed = proposeCommissionSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json({ error: "Validation failed", details: parsed.error.flatten() }, { status: 400 }) - } - update = { - status: "PROPOSED", - totalMinor: parsed.data.totalMinor, - currency: parsed.data.currency, - milestones: parsed.data.milestones, - } - event = "commission.terms_proposed" - break - } - case "accept": { - if (commission.status !== "PROPOSED") { - return NextResponse.json({ error: "Commission must be in PROPOSED state" }, { status: 400 }) - } - update = { status: "ACCEPTED" } - event = "commission.accepted" - break - } - case "decline": { - update = { status: "DECLINED" } - event = "commission.declined" - break - } - case "complete": { - if (commission.status !== "ACCEPTED" && commission.status !== "IN_PROGRESS") { - return NextResponse.json({ error: "Commission must be ACCEPTED or IN_PROGRESS" }, { status: 400 }) - } - update = { status: "DELIVERED" } - event = "commission.delivered" - break - } - default: - return NextResponse.json({ error: "Invalid action" }, { status: 400 }) - } - - const updated = await prisma.commission.update({ - where: { id }, - data: update, - }) - - if (event) { - businessEvent(event, { commissionId: id, makerId: maker?.id || commission.makerId }) - } - - logger.info(`Commission ${action}`, { commissionId: id }) - return NextResponse.json({ commission: updated }) -} diff --git a/src/app/api/commissions/route.ts b/src/app/api/commissions/route.ts deleted file mode 100644 index 70de304..0000000 --- a/src/app/api/commissions/route.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { NextResponse } from "next/server" -import { getSession, guestDenied } from "@/lib/auth-utils" -import { prisma } from "@/lib/prisma" -import { submitCommissionSchema } from "@/lib/validators/commission" -import { logger, businessEvent } from "@/lib/logger" - -// POST — submit a commission brief -export async function POST(req: Request) { - const session = await getSession() - if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - const denied = guestDenied(session) - if (denied) return denied - - const collector = await prisma.collector.findUnique({ where: { userId: session.user.id } }) - if (!collector) return NextResponse.json({ error: "No collector profile" }, { status: 404 }) - - const body = await req.json() - const parsed = submitCommissionSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json({ error: "Validation failed", details: parsed.error.flatten() }, { status: 400 }) - } - - // Verify maker exists and accepts commissions - const maker = await prisma.maker.findUnique({ where: { id: parsed.data.makerId } }) - if (!maker || maker.status !== "LIVE") { - return NextResponse.json({ error: "Maker not found" }, { status: 404 }) - } - if (!maker.acceptsCommissions) { - return NextResponse.json({ error: "This maker does not accept commissions" }, { status: 400 }) - } - - const commission = await prisma.commission.create({ - data: { - collectorId: collector.id, - makerId: parsed.data.makerId, - brief: parsed.data.brief, - referenceUrls: parsed.data.referenceUrls || [], - budgetBand: parsed.data.budgetBand, - status: "SUBMITTED", - }, - }) - - businessEvent("commission.submitted", { commissionId: commission.id, makerId: maker.id }) - logger.info("Commission submitted", { commissionId: commission.id }) - - return NextResponse.json({ commission }, { status: 201 }) -} - -// GET — list commissions (collector or maker view) -export async function GET(req: Request) { - const session = await getSession() - if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - const denied = guestDenied(session) - if (denied) return denied - - const { searchParams } = new URL(req.url) - const role = searchParams.get("role") || "collector" - - if (role === "maker") { - const maker = await prisma.maker.findUnique({ where: { userId: session.user.id } }) - if (!maker) return NextResponse.json({ error: "Not a maker" }, { status: 403 }) - - const commissions = await prisma.commission.findMany({ - where: { makerId: maker.id }, - include: { - collector: { select: { publicHandle: true } }, - }, - orderBy: { updatedAt: "desc" }, - }) - return NextResponse.json({ commissions }) - } - - const collector = await prisma.collector.findUnique({ where: { userId: session.user.id } }) - if (!collector) return NextResponse.json({ commissions: [] }) - - const commissions = await prisma.commission.findMany({ - where: { collectorId: collector.id }, - include: { - maker: { select: { slug: true } }, - }, - orderBy: { updatedAt: "desc" }, - }) - - return NextResponse.json({ commissions }) -} diff --git a/src/app/api/e2e/session/route.ts b/src/app/api/e2e/session/route.ts deleted file mode 100644 index be28e1d..0000000 --- a/src/app/api/e2e/session/route.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * E2E-only endpoint: generates a valid Auth.js v5 session cookie for a test user. - * This exists because jose's WebCrypto (used by node --input-type=module) - * produces incompatible JWE output vs jose's Node crypto (used by Next.js). - * By generating cookies server-side, the format always matches exactly. - * - * Only enabled when running in development mode. - * Requires AUTH_E2E_TOKEN in the request to prevent accidental exposure. - */ -import { NextResponse } from "next/server" -import { encode } from "@auth/core/jwt" -import { prisma } from "@/lib/prisma" -import type { Role } from "@/generated/prisma/client" - -interface TestUserRequest { - id: string - email: string - name: string - role: string -} - -export async function POST(req: Request) { - // Guard: only allow in dev, and only with a known test token - if (process.env.NODE_ENV !== "development") { - return NextResponse.json({ error: "Not available" }, { status: 404 }) - } - - if (process.env.E2E_TEST_TOKEN && req.headers.get("x-e2e-token") !== process.env.E2E_TEST_TOKEN) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - - const body: TestUserRequest = await req.json() - if (!body.id || !body.email || !body.role) { - return NextResponse.json({ error: "Missing id, email, or role" }, { status: 400 }) - } - - // Upsert User + role-specific records so API routes find them - await ensureTestUser(body) - - const token = await encode({ - token: { - sub: body.id, - email: body.email, - name: body.name || body.email, - role: body.role, - id: body.id, - }, - secret: process.env.AUTH_SECRET!, - salt: "authjs.session-token", - maxAge: 60 * 60, // 1 hour - }) - - return NextResponse.json({ - cookie: { - name: "authjs.session-token", - value: token, - domain: "localhost", - path: "/", - }, - }) -} - -async function ensureTestUser(user: TestUserRequest) { - const role = user.role as Role - await prisma.user.upsert({ - where: { id: user.id }, - create: { - id: user.id, - email: user.email, - name: user.name, - role, - }, - update: { role, name: user.name }, - }) - - if (user.role === "MAKER") { - await prisma.maker.upsert({ - where: { userId: user.id }, - create: { - userId: user.id, - slug: user.id, // use test id as slug - acceptsCommissions: true, - region: "US", - tier: "ESTABLISHED", - bio: "E2E test maker", - }, - update: { acceptsCommissions: true }, - }) - } - - if (user.role === "COLLECTOR") { - await prisma.collector.upsert({ - where: { userId: user.id }, - create: { - userId: user.id, - }, - update: {}, - }) - } -} diff --git a/src/app/api/invites/redeem/route.ts b/src/app/api/invites/redeem/route.ts deleted file mode 100644 index 023801e..0000000 --- a/src/app/api/invites/redeem/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { NextResponse } from "next/server" -import { prisma } from "@/lib/prisma" -import { redeemInvite } from "@/lib/invites" -import { redeemInviteSchema } from "@/lib/validators/invite" -import { logger } from "@/lib/logger" - -// POST /api/invites/redeem — public: redeem an invite code for an email. -// Creates the user (with the invited role) if they don't exist yet, so the -// magic-link sign-in flow only ever serves invited emails. -export async function POST(req: Request) { - let body: unknown - try { - body = await req.json() - } catch { - return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }) - } - - const parsed = redeemInviteSchema.safeParse(body) - if (!parsed.success) { - return NextResponse.json( - { error: "Validation failed", details: parsed.error.flatten().fieldErrors }, - { status: 422 }, - ) - } - - const result = await redeemInvite(prisma, parsed.data) - if (!result.ok) { - const message = - result.error === "expired" - ? "This invite code has expired." - : result.error === "max_uses" - ? "This invite code has already been fully used." - : "This invite code is not valid." - return NextResponse.json({ error: message }, { status: result.status }) - } - - logger.info("Invite redeemed", { email: parsed.data.email, role: result.role }) - return NextResponse.json({ ok: true, role: result.role }) -} diff --git a/src/app/api/orders/[id]/route.ts b/src/app/api/orders/[id]/route.ts deleted file mode 100644 index d0c6176..0000000 --- a/src/app/api/orders/[id]/route.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { NextResponse } from "next/server" -import { getSession, guestDenied } from "@/lib/auth-utils" -import { prisma } from "@/lib/prisma" -import { logger } from "@/lib/logger" -import { evaluateMakerTier } from "@/lib/tiers" -import { generateProvenanceCertificate } from "@/lib/provenance" - -export async function GET( - req: Request, - { params }: { params: Promise<{ id: string }> } -) { - const session = await getSession() - if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - const denied = guestDenied(session) - if (denied) return denied - - const { id } = await params - - const order = await prisma.order.findUnique({ - where: { id }, - include: { - items: { - include: { - variant: { - include: { - work: { - include: { - media: { orderBy: { position: "asc" }, take: 1 }, - maker: { select: { slug: true, region: true } }, - }, - }, - }, - }, - maker: { select: { slug: true } }, - }, - }, - }, - }) - - if (!order) return NextResponse.json({ error: "Not found" }, { status: 404 }) - - return NextResponse.json({ order }) -} - -// Maker: update order item progress -export async function PATCH( - req: Request, - { params }: { params: Promise<{ id: string }> } -) { - const session = await getSession() - if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - const denied = guestDenied(session) - if (denied) return denied - - const { id } = await params - const maker = await prisma.maker.findUnique({ where: { userId: session.user.id } }) - if (!maker) return NextResponse.json({ error: "Not a maker" }, { status: 403 }) - - const body = await req.json() - const { orderItemId, status, progressPhotoUrl, trackingNumber, carrier } = body - - if (orderItemId) { - const item = await prisma.orderItem.findUnique({ - where: { id: orderItemId }, - include: { order: true }, - }) - - if (!item || item.makerId !== maker.id) { - return NextResponse.json({ error: "Not your order item" }, { status: 403 }) - } - - // OrderItem has no `status` field — status lives on Order - // Progress photo tracking - if (progressPhotoUrl) { - await prisma.orderItem.update({ - where: { id: orderItemId }, - data: { - progressPhotos: [...(item.progressPhotos || []), progressPhotoUrl], - }, - }) - } - - // If maker sets status to IN_PROGRESS, update the order - if (status === "IN_PROGRESS") { - await prisma.order.update({ - where: { id: item.orderId }, - data: { status: "IN_PROGRESS" }, - }) - } - - // If shipping, create or update shipment - if (trackingNumber) { - await prisma.shipment.upsert({ - where: { id: `${item.orderId}-${item.makerId}` }, - create: { - orderId: item.orderId, - makerId: item.makerId, - carrier: carrier || "other", - tracking: trackingNumber, - status: "shipped", - }, - update: { - carrier: carrier || "other", - tracking: trackingNumber, - status: "shipped", - }, - }) - - // Update order status if all items shipped - const unshippedItems = await prisma.orderItem.count({ - where: { orderId: item.orderId, NOT: { id: orderItemId } }, - }) - - if (unshippedItems === 0) { - await prisma.order.update({ - where: { id: item.orderId }, - data: { status: "SHIPPED" }, - }) - } - } - - logger.info("Order item updated by maker", { orderItemId, makerId: maker.id, status }) - - // Generate provenance certificate when item is marked shipped - if (status === "SHIPPED" || trackingNumber) { - generateProvenanceCertificate(orderItemId) - .then(async (cert) => { - if (!cert) return - - // Upload PDF to storage - const { uploadBuffer } = await import("@/lib/storage") - const pdfUrl = await uploadBuffer( - "certificate", - cert.buffer, - `cert-${cert.hash}.pdf`, - "application/pdf" - ) - - // Update certificate with PDF URL - const certRecord = await prisma.provenanceCertificate.findFirst({ - where: { hash: cert.hash }, - }) - if (certRecord) { - await prisma.provenanceCertificate.update({ - where: { id: certRecord.id }, - data: { pdfUrl }, - }) - } - - // Email the collector - const order = await prisma.order.findUnique({ - where: { id: item.orderId }, - include: { - collector: { include: { user: { select: { email: true } } } }, - }, - }) - - if (order?.collector?.user?.email) { - const { sendEmail } = await import("@/lib/email") - const { CertificateEmail } = await import("@/emails/certificate") - const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000" - - sendEmail({ - to: order.collector.user.email, - subject: `Your Certificate of Provenance — ${cert.workTitle}`, - react: CertificateEmail({ - workTitle: cert.workTitle, - makerName: cert.makerName, - editionNumber: cert.editionNumber, - verifyUrl: `${appUrl}/verify/${cert.hash}`, - pdfUrl, - }), - }).catch(() => {}) - - logger.info("Certificate email queued", { - orderId: item.orderId, - certificationHash: cert.hash, - collectorEmail: order.collector.user.email, - }) - } - }) - .catch((err) => { - logger.error("Certificate generation failed", { orderItemId, error: String(err) }) - }) - } - - // Evaluate tier after shipping - evaluateMakerTier(maker.id).catch(() => {}) - } - - return NextResponse.json({ success: true }) -} diff --git a/src/app/api/orders/route.ts b/src/app/api/orders/route.ts deleted file mode 100644 index 7dc1c4b..0000000 --- a/src/app/api/orders/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { NextResponse } from "next/server" -import { getSession, guestDenied } from "@/lib/auth-utils" -import { prisma } from "@/lib/prisma" - -export async function GET() { - const session = await getSession() - if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - const denied = guestDenied(session) - if (denied) return denied - - const collector = session.user.id - ? await prisma.collector.findUnique({ where: { userId: session.user.id } }) - : null - - if (!collector) return NextResponse.json({ orders: [] }) - - const orders = await prisma.order.findMany({ - where: { collectorId: collector.id }, - include: { - items: { - include: { - variant: { - include: { - work: { - include: { - media: { orderBy: { position: "asc" }, take: 1 }, - maker: { select: { slug: true } }, - }, - }, - }, - }, - }, - }, - }, - orderBy: { createdAt: "desc" }, - }) - - return NextResponse.json({ orders }) -} diff --git a/src/app/sign-in/sign-in-form.tsx b/src/app/sign-in/sign-in-form.tsx deleted file mode 100644 index bc44764..0000000 --- a/src/app/sign-in/sign-in-form.tsx +++ /dev/null @@ -1,103 +0,0 @@ -"use client" - -import { useState } from "react" -import { signIn } from "next-auth/react" - -/** - * Invite-gated magic-link sign-in (M2). - * - * The invite code is redeemed against the API first; the magic link is only - * requested once redemption succeeds. Uninvited emails get a clear rejection - * before any email is sent. - */ -export function SignInForm({ initialInvite }: { initialInvite?: string }) { - const [email, setEmail] = useState("") - const [inviteCode, setInviteCode] = useState(initialInvite ?? "") - const [state, setState] = useState<"idle" | "redeeming" | "sent" | "error">("idle") - const [error, setError] = useState(null) - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault() - setState("redeeming") - setError(null) - - // Redeem the invite first — creates/upgrades the user so the magic-link - // provider gate lets the email through. - try { - const res = await fetch("/api/invites/redeem", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code: inviteCode, email }), - }) - const data = await res.json() - - if (!res.ok) { - setError(data.error || "That invite code could not be redeemed.") - setState("error") - return - } - } catch { - setError("Something went wrong. Please try again.") - setState("error") - return - } - - // Invite accepted — request the magic link. (If the email provider fails, - // the invite is still valid; the error page will surface it.) - await signIn("resend", { email, redirect: false }) - setState("sent") - } - - if (state === "sent") { - return ( -
-

- Invite accepted. Check your email — we sent a magic link to{" "} - {email}. -

-
- ) - } - - return ( -
-
- - setInviteCode(e.target.value)} - required - className="mt-1 w-full border border-border bg-transparent px-4 py-3 text-base placeholder:text-muted-foreground focus:outline-none focus:border-foreground transition-colors" - /> -
-
- - setEmail(e.target.value)} - required - className="mt-1 w-full border border-border bg-transparent px-4 py-3 text-base placeholder:text-muted-foreground focus:outline-none focus:border-foreground transition-colors" - /> -
- {error &&

{error}

} - -

- Atelier is invite-only. Need a code? Ask a maker or collector you know. -

-
- ) -} diff --git a/src/lib/__tests__/invites.test.ts b/src/lib/__tests__/invites.test.ts deleted file mode 100644 index 22cf834..0000000 --- a/src/lib/__tests__/invites.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Unit tests: invites.ts (M2 — invite-only access flow) - * - * Tests code generation, redemption success/failure paths, the atomic maxUses - * guard, and role-upgrade semantics. Uses mocked Prisma. - */ -import { describe, it, expect, vi, beforeEach } from "vitest" - -vi.mock("@/lib/prisma", () => ({ - prisma: { - inviteCode: { findUnique: vi.fn(), updateMany: vi.fn(), update: vi.fn() }, - user: { findUnique: vi.fn(), upsert: vi.fn() }, - inviteRedemption: { create: vi.fn() }, - }, -})) - -import { prisma } from "@/lib/prisma" -import { generateInviteCode, normalizeCode, redeemInvite, upgradeRole } from "@/lib/invites" - -const mockFindInvite = prisma.inviteCode.findUnique as ReturnType -const mockClaim = prisma.inviteCode.updateMany as ReturnType -const mockRollback = prisma.inviteCode.update as ReturnType -const mockFindUser = prisma.user.findUnique as ReturnType -const mockUpsertUser = prisma.user.upsert as ReturnType -const mockRedemption = prisma.inviteRedemption.create as ReturnType - -// The mocked prisma object, cast to the shape redeemInvite expects. -const mockPrisma = prisma as unknown as Parameters[0] - -function validInvite(overrides: Record = {}) { - return { - id: "invite-1", - code: "ATELIER-ABCD2345", - role: "COLLECTOR", - maxUses: 1, - usedCount: 0, - expiresAt: null, - ...overrides, - } -} - -beforeEach(() => { - vi.clearAllMocks() - mockClaim.mockResolvedValue({ count: 1 }) - mockUpsertUser.mockImplementation(({ create }) => Promise.resolve({ id: "u1", ...create })) - mockRedemption.mockResolvedValue({ id: "r1" }) -}) - -describe("generateInviteCode", () => { - it("produces ATELIER- prefixed codes of the expected length", () => { - const code = generateInviteCode() - expect(code).toMatch(/^ATELIER-[A-HJ-NP-Z2-9]{8}$/) - // unambiguous alphabet in the random segment (no 0/O/1/I/L) - expect(code.slice("ATELIER-".length)).not.toMatch(/[01ILO]/) - }) - - it("produces distinct codes", () => { - const codes = new Set(Array.from({ length: 50 }, () => generateInviteCode())) - expect(codes.size).toBe(50) - }) -}) - -describe("normalizeCode", () => { - it("uppercases, trims, and strips internal whitespace", () => { - expect(normalizeCode(" atelier- abcd 2345 ")).toBe("ATELIER-ABCD2345") - }) -}) - -describe("redeemInvite", () => { - it("redeems a valid invite, creating the user with the invited role", async () => { - mockFindInvite.mockResolvedValue(validInvite()) - mockFindUser.mockResolvedValue(null) - - const result = await redeemInvite(mockPrisma, { - code: "atelier-abcd2345", - email: " New@Example.com ", - }) - - expect(result).toEqual({ ok: true, role: "COLLECTOR", createdUser: true }) - expect(mockUpsertUser).toHaveBeenCalledWith( - expect.objectContaining({ - where: { email: "new@example.com" }, - create: expect.objectContaining({ role: "COLLECTOR" }), - }), - ) - expect(mockRedemption).toHaveBeenCalledWith( - expect.objectContaining({ data: { inviteId: "invite-1", userId: "u1", email: "new@example.com" } }), - ) - }) - - it("rejects an unknown code", async () => { - mockFindInvite.mockResolvedValue(null) - const result = await redeemInvite(mockPrisma, { code: "ATELIER-NOPE1234", email: "a@b.com" }) - expect(result).toEqual({ ok: false, error: "invalid", status: 403 }) - expect(mockClaim).not.toHaveBeenCalled() - }) - - it("rejects an expired code", async () => { - mockFindInvite.mockResolvedValue( - validInvite({ expiresAt: new Date(Date.now() - 1000) }), - ) - const result = await redeemInvite(mockPrisma, { code: "ATELIER-ABCD2345", email: "a@b.com" }) - expect(result).toEqual({ ok: false, error: "expired", status: 403 }) - expect(mockClaim).not.toHaveBeenCalled() - }) - - it("rejects a fully-used code via the atomic guard", async () => { - mockFindInvite.mockResolvedValue(validInvite({ usedCount: 1 })) - mockClaim.mockResolvedValue({ count: 0 }) - - const result = await redeemInvite(mockPrisma, { code: "ATELIER-ABCD2345", email: "a@b.com" }) - expect(result).toEqual({ ok: false, error: "max_uses", status: 403 }) - }) - - it("claims atomically with a usedCount < maxUses guard", async () => { - mockFindInvite.mockResolvedValue(validInvite({ maxUses: 3, usedCount: 2 })) - mockFindUser.mockResolvedValue(null) - - await redeemInvite(mockPrisma, { code: "ATELIER-ABCD2345", email: "a@b.com" }) - - expect(mockClaim).toHaveBeenCalledWith({ - where: { id: "invite-1", usedCount: { lt: 3 } }, - data: { usedCount: { increment: 1 } }, - }) - }) - - it("rolls back the claim when user creation fails", async () => { - mockFindInvite.mockResolvedValue(validInvite()) - mockUpsertUser.mockRejectedValue(new Error("db down")) - - await expect( - redeemInvite(mockPrisma, { code: "ATELIER-ABCD2345", email: "a@b.com" }), - ).rejects.toThrow("db down") - - expect(mockRollback).toHaveBeenCalledWith({ - where: { id: "invite-1" }, - data: { usedCount: { decrement: 1 } }, - }) - }) -}) - -describe("upgradeRole", () => { - it("upgrades GUEST → invited role", () => { - expect(upgradeRole("GUEST", "COLLECTOR")).toBe("COLLECTOR") - expect(upgradeRole("COLLECTOR", "MAKER")).toBe("MAKER") - }) - - it("never downgrades a higher role", () => { - expect(upgradeRole("MAKER", "COLLECTOR")).toBe("MAKER") - expect(upgradeRole("CURATOR", "MAKER")).toBe("CURATOR") - }) - - it("ADMIN is absolute in both directions", () => { - expect(upgradeRole("ADMIN", "GUEST")).toBe("ADMIN") - expect(upgradeRole("GUEST", "ADMIN")).toBe("ADMIN") - }) -}) diff --git a/src/lib/auth-utils.ts b/src/lib/auth-utils.ts deleted file mode 100644 index b6e2cff..0000000 --- a/src/lib/auth-utils.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { auth } from "@/lib/auth" -import { redirect } from "next/navigation" -import { NextResponse } from "next/server" - -type Role = "GUEST" | "COLLECTOR" | "MAKER" | "CURATOR" | "ADMIN" - -export async function requireRole(...roles: Role[]) { - const session = await auth() - if (!session?.user) redirect("/sign-in") - if (roles.length > 0 && !roles.includes(session.user.role as Role)) { - redirect("/") - } - return session -} - -export async function getSession() { - return await auth() -} - -/** - * API-route guard: rejects unauthenticated requests (401) and GUEST-role - * requests (403). GUEST users exist only via the E2E backdoor or pre-invite - * legacy rows — invited users always hold a real role, so this enforces the - * invite-only gate on purchase and maker surfaces. - */ -export function guestDenied( - session: { user?: { role?: string | null } | null } | null, -): NextResponse | null { - if (!session?.user) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - if (session.user.role === "GUEST") { - return NextResponse.json( - { error: "An invite is required to use Atelier." }, - { status: 403 }, - ) - } - return null -} diff --git a/src/lib/auth.ts b/src/lib/auth.ts deleted file mode 100644 index 017026e..0000000 --- a/src/lib/auth.ts +++ /dev/null @@ -1,69 +0,0 @@ -import NextAuth from "next-auth" -import { PrismaAdapter } from "@auth/prisma-adapter" -import { Resend } from "resend" -import ResendProvider from "next-auth/providers/resend" -import { prisma } from "@/lib/prisma" -import authConfig from "@/lib/auth.config" - -/** - * Invite-gated magic-link provider (M2). - * - * The default Resend provider would happily email a magic link to any address. - * We override sendVerificationRequest to refuse emails for users who have not - * redeemed an invite (no user row, or still role GUEST). The redemption API - * creates/upgrades the user first, so invited users pass through here. - * - * NOTE: this gate lives in auth.ts (Node runtime) on purpose — auth.config.ts - * is imported by middleware (edge) and must stay prisma-free. - */ -const inviteGatedResend = ResendProvider({ - from: "atelier@mg.yourdomain.com", - async sendVerificationRequest({ identifier, url, provider }) { - const user = await prisma.user.findUnique({ where: { email: identifier } }) - if (!user || user.role === "GUEST") { - throw new Error("INVITE_REQUIRED: this email has not been invited to Atelier.") - } - - const client = new Resend(process.env.RESEND_API_KEY) - const { error } = await client.emails.send({ - from: provider.from as string, - to: identifier, - subject: "Your sign-in link for Atelier", - html: - `

Welcome to Atelier.

` + - `

Sign in to Atelier — this link expires shortly.

` + - `

If you didn't request this, you can safely ignore this email.

`, - }) - if (error) { - throw new Error(`Failed to send sign-in email: ${error.message}`) - } - }, -}) - -export const { handlers, auth, signIn, signOut } = NextAuth({ - adapter: PrismaAdapter(prisma), - session: { strategy: "jwt" }, - pages: { - signIn: "/sign-in", - error: "/auth/error", - }, - ...authConfig, - providers: [inviteGatedResend], - callbacks: { - ...authConfig.callbacks, - async jwt({ token, user }) { - if (user) { - token.role = user.role - token.id = user.id - } - return token - }, - async session({ session, token }) { - if (session.user) { - session.user.role = (token.role as typeof session.user.role) || "COLLECTOR" - session.user.id = (token.id as string) || (token.sub as string) || "" - } - return session - }, - }, -}) diff --git a/src/lib/invites.ts b/src/lib/invites.ts deleted file mode 100644 index 1a10383..0000000 --- a/src/lib/invites.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Invite-only access flow (M2). - * - * Admins issue InviteCodes; a user redeems one with their email. Redemption - * upgrades the user's role (never downgrades, never demotes ADMIN) and is - * atomic against concurrent redemptions via an updateMany guard on maxUses. - */ -import { randomInt } from "node:crypto" -import type { Role } from "@/generated/prisma/client" -import type { PrismaClient } from "@/generated/prisma/client" - -// Unambiguous alphabet: no 0/O/1/I/L -const CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" -const CODE_LENGTH = 8 - -export type RedeemResult = - | { ok: true; role: Role; createdUser: boolean } - | { ok: false; error: "invalid" | "expired" | "max_uses"; status: number } - -export function generateInviteCode(): string { - const chars: string[] = [] - for (let i = 0; i < CODE_LENGTH; i++) { - chars.push(CODE_ALPHABET[randomInt(CODE_ALPHABET.length)]) - } - return `ATELIER-${chars.join("")}` -} - -export function normalizeCode(code: string): string { - return code.trim().toUpperCase().replace(/\s+/g, "") -} - -/** - * Redeem an invite code for an email address. Creates the user row if it does - * not exist yet (magic-link sign-in will then find an invited user), upgrades - * the role otherwise. - */ -export async function redeemInvite( - prisma: PrismaClient, - input: { code: string; email: string }, -): Promise { - const email = input.email.trim().toLowerCase() - const code = normalizeCode(input.code) - - const invite = await prisma.inviteCode.findUnique({ where: { code } }) - if (!invite) return { ok: false, error: "invalid", status: 403 } - if (invite.expiresAt && invite.expiresAt < new Date()) { - return { ok: false, error: "expired", status: 403 } - } - - // Atomic maxUses guard: only one concurrent redemption wins when full. - const claimed = await prisma.inviteCode.updateMany({ - where: { id: invite.id, usedCount: { lt: invite.maxUses } }, - data: { usedCount: { increment: 1 } }, - }) - if (claimed.count === 0) return { ok: false, error: "max_uses", status: 403 } - - try { - const existing = await prisma.user.findUnique({ where: { email } }) - const createdUser = !existing - - const user = await prisma.user.upsert({ - where: { email }, - create: { email, name: null, role: invite.role }, - update: { role: upgradeRole(existing?.role ?? "GUEST", invite.role) }, - }) - - await prisma.inviteRedemption.create({ - data: { inviteId: invite.id, userId: user.id, email }, - }) - - return { ok: true, role: user.role, createdUser } - } catch (err) { - // Roll back the claim so a failed redemption doesn't burn a use. - await prisma.inviteCode.update({ - where: { id: invite.id }, - data: { usedCount: { decrement: 1 } }, - }) - throw err - } -} - -/** - * Invites only ever upgrade a role, never downgrade it. ADMIN is absolute — - * nothing can strip it. - */ -export function upgradeRole(current: Role, invited: Role): Role { - if (current === "ADMIN") return "ADMIN" - if (invited === "ADMIN") return "ADMIN" - const rank: Record = { - GUEST: 0, - COLLECTOR: 1, - MAKER: 2, - CURATOR: 3, - ADMIN: 4, - } - return rank[invited] > rank[current] ? invited : current -} diff --git a/src/lib/validators/invite.ts b/src/lib/validators/invite.ts deleted file mode 100644 index a5bcfa3..0000000 --- a/src/lib/validators/invite.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { z } from "zod" - -/** Roles an admin can issue an invite for. GUEST is never issuable. */ -export const inviteRoleSchema = z.enum(["COLLECTOR", "MAKER", "CURATOR", "ADMIN"]) - -export const createInviteSchema = z - .object({ - role: inviteRoleSchema.default("COLLECTOR"), - maxUses: z.number().int().min(1).max(1000).default(1), - expiresInDays: z.number().int().min(1).max(365).optional(), - count: z.number().int().min(1).max(50).default(1), - }) - .strict() - -export const redeemInviteSchema = z - .object({ - code: z.string().trim().min(4).max(64), - email: z.string().trim().email().max(254), - }) - .strict() - -export type CreateInviteInput = z.infer -export type RedeemInviteInput = z.infer diff --git a/src/middleware.ts b/src/middleware.ts deleted file mode 100644 index fe00116..0000000 --- a/src/middleware.ts +++ /dev/null @@ -1,54 +0,0 @@ -import NextAuth from "next-auth" -import authConfig from "@/lib/auth.config" - -// Middleware-compatible auth: uses JWT, no database adapter needed -const { auth: middlewareAuth } = NextAuth(authConfig) - -const PURCHASE_SURFACES = ["/cart", "/orders", "/commissions"] -const MAKER_ROLES = ["MAKER", "CURATOR", "ADMIN"] -const STAFF_ROLES = ["ADMIN", "CURATOR"] - -export default middlewareAuth((req) => { - const { nextUrl } = req - const isLoggedIn = !!req.auth - const role = req.auth?.user?.role as string | undefined - - const isDashboard = nextUrl.pathname.startsWith("/dashboard") - const isAdmin = nextUrl.pathname.startsWith("/admin") - const isPurchase = PURCHASE_SURFACES.some( - (p) => nextUrl.pathname === p || nextUrl.pathname.startsWith(`${p}/`), - ) - - // Admin + maker surfaces: must be logged in - if ((isDashboard || isAdmin) && !isLoggedIn) { - return Response.redirect(new URL("/sign-in", nextUrl)) - } - - // Admin surface: staff only - if (isAdmin && !STAFF_ROLES.includes(role ?? "")) { - return Response.redirect(new URL("/", nextUrl)) - } - - // Maker dashboard: makers + staff only (collectors get the public site) - if (isDashboard && !MAKER_ROLES.includes(role ?? "")) { - return Response.redirect(new URL("/", nextUrl)) - } - - // Purchase surfaces: any invited user, but never a GUEST - if (isPurchase && (!isLoggedIn || role === "GUEST")) { - return Response.redirect(new URL("/sign-in", nextUrl)) - } - - return null -}) - -export const config = { - matcher: [ - "/dashboard/:path*", - "/admin/:path*", - "/apply/:path*", - "/cart/:path*", - "/orders/:path*", - "/commissions/:path*", - ], -} diff --git a/test-results/.last-run.json b/test-results/.last-run.json deleted file mode 100644 index 5fca3f8..0000000 --- a/test-results/.last-run.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "failed", - "failedTests": [] -} \ No newline at end of file From b9b242efee339093e3a4b5183ca37c4c4e1577ab Mon Sep 17 00:00:00 2001 From: mattdani21 Date: Thu, 6 Aug 2026 22:17:05 +0200 Subject: [PATCH 3/4] M1.4: mark GOAL.md checkbox done --- GOAL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GOAL.md b/GOAL.md index 923ac43..b6d602d 100644 --- a/GOAL.md +++ b/GOAL.md @@ -9,7 +9,7 @@ Launch Atelier as the invite-only marketplace where craftspeople sell their work - [ ] Provision the 7 real services per `docs/P1.3-provisioning.md` (Cloud Run, Cloud SQL, GCS, Sentry, Resend, Stripe Connect, secrets) and record the results back in STATUS.md - [ ] Set the GitHub vars/secrets the workflows consume (`GCP_WIP`, `GCP_SA`, `GCR_HOST`, `GCP_PROJECT`, `GCP_REGION`, `atelier-staging-db-url`, `atelier-auth-secret`, `atelier-stripe-secret`, `atelier-stripe-webhook`, `atelier-resend-key`) - [ ] Verify `.github/workflows/deploy-staging.yml` auto-deploys on push to main and the health gate (`/api/health`) passes before the traffic shift -- [ ] Resolve the duplicate legacy `.github/workflows/deploy.yml` (GCP_SA_KEY-based, also triggers on main) so staging and prod deploys are unambiguous +- [x] Resolve the duplicate legacy `.github/workflows/deploy.yml` (GCP_SA_KEY-based, also triggers on main) so staging and prod deploys are unambiguous *Definition of done:* a push to main results in one green staging deploy; the staging URL serves `/api/health` = 200 and a seeded browse page. ### M2 — Invite-only access flow From 03b632c2443c7f50267c5041108a00a937da2324 Mon Sep 17 00:00:00 2001 From: mattdani21 Date: Fri, 7 Aug 2026 08:18:13 +0200 Subject: [PATCH 4/4] M1.4: restore files swept out by botched git add -A Commit 90f91e0 intended to strip only M2 invite-flow files, but the broad git add also deleted core app files (prisma/schema.prisma, auth, middleware, cart/checkout/orders/commissions routes, e2e seed). Restore them from main so the PR scope is exactly the stated intent: deploy.yml removal + GOAL.md/STATE.md docs updates. --- e2e/setup/seed.ts | 191 +++++++ prisma/schema.prisma | 656 ++++++++++++++++++++++ src/app/admin/page.tsx | 223 ++++++++ src/app/api/applications/route.ts | 137 +++++ src/app/api/cart/route.ts | 175 ++++++ src/app/api/checkout/route.ts | 124 ++++ src/app/api/commissions/[id]/pay/route.ts | 76 +++ src/app/api/commissions/[id]/route.ts | 111 ++++ src/app/api/commissions/route.ts | 81 +++ src/app/api/e2e/session/route.ts | 98 ++++ src/app/api/orders/[id]/route.ts | 188 +++++++ src/app/api/orders/route.ts | 37 ++ src/app/sign-in/sign-in-form.tsx | 49 ++ src/lib/auth-utils.ts | 17 + src/lib/auth.ts | 31 + src/middleware.ts | 30 + 16 files changed, 2224 insertions(+) create mode 100644 e2e/setup/seed.ts create mode 100644 prisma/schema.prisma create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/api/applications/route.ts create mode 100644 src/app/api/cart/route.ts create mode 100644 src/app/api/checkout/route.ts create mode 100644 src/app/api/commissions/[id]/pay/route.ts create mode 100644 src/app/api/commissions/[id]/route.ts create mode 100644 src/app/api/commissions/route.ts create mode 100644 src/app/api/e2e/session/route.ts create mode 100644 src/app/api/orders/[id]/route.ts create mode 100644 src/app/api/orders/route.ts create mode 100644 src/app/sign-in/sign-in-form.tsx create mode 100644 src/lib/auth-utils.ts create mode 100644 src/lib/auth.ts create mode 100644 src/middleware.ts diff --git a/e2e/setup/seed.ts b/e2e/setup/seed.ts new file mode 100644 index 0000000..a0a1774 --- /dev/null +++ b/e2e/setup/seed.ts @@ -0,0 +1,191 @@ +/** + * E2E test data seed. + * Creates test users, makers, works, and variants via the Prisma client. + * Call via: npx tsx e2e/setup/seed.ts + * + * The script reads DATABASE_URL from the environment and pushes the schema + * (idempotent, via db push) before seeding if --push is passed. + */ +import { PrismaClient } from "../../src/generated/prisma/client" + +const prisma = new PrismaClient() + +// ─── Test identifiers ──────────────────────────────────────── +const TEST_PREFIX = "e2e_" + +const USERS = { + collector: { + id: `${TEST_PREFIX}collector`, + email: "collector@atelier.test", + name: "E2E Collector", + role: "COLLECTOR" as const, + }, + maker: { + id: `${TEST_PREFIX}maker`, + email: "maker@atelier.test", + name: "E2E Maker", + role: "MAKER" as const, + }, + maker2: { + id: `${TEST_PREFIX}maker2`, + email: "maker2@atelier.test", + name: "E2E Maker Two", + role: "MAKER" as const, + }, + admin: { + id: `${TEST_PREFIX}admin`, + email: "admin@atelier.test", + name: "E2E Admin", + role: "ADMIN" as const, + }, +} + +async function seed() { + console.log("🌱 Seeding E2E test data…") + + // ── Users ────────────────────────────────────────────────── + for (const u of Object.values(USERS)) { + await prisma.user.upsert({ + where: { id: u.id }, + update: {}, + create: { id: u.id, email: u.email, name: u.name, role: u.role, emailVerified: new Date() }, + }) + } + + // ── Collectors ───────────────────────────────────────────── + await prisma.collector.upsert({ + where: { userId: USERS.collector.id }, + update: {}, + create: { userId: USERS.collector.id, publicHandle: "test_collector" }, + }) + + // ── Makers ───────────────────────────────────────────────── + for (const u of [USERS.maker, USERS.maker2]) { + await prisma.maker.upsert({ + where: { userId: u.id }, + update: {}, + create: { + userId: u.id, + slug: u.id.replace(TEST_PREFIX, ""), + region: "Cape Town", + craftCategory: "ceramics", + bio: "Test maker bio for E2E tests", + tier: "ADMITTED", + status: "LIVE", + stripeAccountId: `acct_${u.id}`, + stripeOnboarded: true, + }, + }) + + // Create application (already admitted) + const maker = await prisma.maker.findUnique({ where: { userId: u.id } }) + if (maker) { + await prisma.makerApplication.upsert({ + where: { makerId: maker.id }, + update: {}, + create: { + makerId: maker.id, + payload: { craft: "ceramics", experience: "5 years", portfolio: "https://example.com" }, + status: "ADMITTED", + decidedAt: new Date(), + }, + }) + } + } + + const maker1 = await prisma.maker.findUniqueOrThrow({ where: { userId: USERS.maker.id } }) + const maker2 = await prisma.maker.findUniqueOrThrow({ where: { userId: USERS.maker2.id } }) + + // ── Works + Variants ─────────────────────────────────────── + const works = [ + { + makerId: maker1.id, + slug: "test-ceramic-vase", + title: "Test Ceramic Vase", + editionKind: "OPEN" as const, + status: "PUBLISHED" as const, + publishedAt: new Date(), + description: "A beautiful hand-thrown ceramic vase for testing purchase flows.", + leadTimeDays: 14, + }, + { + makerId: maker2.id, + slug: "test-wooden-bowl", + title: "Test Wooden Bowl", + editionKind: "MADE_TO_ORDER" as const, + status: "PUBLISHED" as const, + publishedAt: new Date(), + description: "A hand-carved wooden bowl from reclaimed timber.", + leadTimeDays: 21, + }, + ] + + for (const w of works) { + const existing = await prisma.work.findUnique({ + where: { makerId_slug: { makerId: w.makerId, slug: w.slug } }, + }) + + const work = existing + ? await prisma.work.update({ + where: { id: existing.id }, + data: w, + }) + : await prisma.work.create({ data: w }) + + // Create variant + const variantId = `${work.id}_var_default` + await prisma.workVariant.upsert({ + where: { id: variantId }, + update: {}, + create: { + id: variantId, + workId: work.id, + priceMinor: 150000, // $1,500.00 + currency: "USD", + stock: 5, + madeToOrder: w.editionKind === "MADE_TO_ORDER", + }, + }) + + // Create materials (min 3 for listing standards) + const materials = [ + { name: "Clay", origin: "Western Cape" }, + { name: "Glaze", origin: "Local" }, + { name: "Kiln firing", origin: "In-house" }, + ] + + for (const m of materials) { + await prisma.workMaterial.create({ + data: { workId: work.id, ...m }, + }) + } + + // Create media (4 images for listing standards) + for (let i = 0; i < 4; i++) { + await prisma.workMedia.create({ + data: { + workId: work.id, + url: `https://picsum.photos/seed/${work.slug}_${i}/800/800`, + kind: "PHOTO", + position: i, + width: 800, + height: 800, + }, + }) + } + } + + console.log("✅ Seed complete.") + console.log(` Collector: ${USERS.collector.email}`) + console.log(` Maker 1: ${USERS.maker.email}`) + console.log(` Maker 2: ${USERS.maker2.email}`) + console.log(` Admin: ${USERS.admin.email}`) + console.log("\n All users share password: (magic link — no password needed)") +} + +seed() + .catch((e) => { + console.error("Seed failed:", e) + process.exit(1) + }) + .finally(() => prisma.$disconnect()) diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..cea126d --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,656 @@ +// Atelier — Premium craftsman marketplace +// Schema derived from atelier-spec.md §10 Data Model +// Phase 0: Foundation schema with all entities for v1 scope + +generator client { + provider = "prisma-client" + output = "../src/generated/prisma" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ─── Identity & Auth ──────────────────────────────────────── + +enum Role { + GUEST + COLLECTOR + MAKER + CURATOR + ADMIN +} + +model User { + id String @id @default(cuid()) + email String @unique + emailVerified DateTime? + name String? + image String? + role Role @default(GUEST) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + maker Maker? + collector Collector? + accounts Account[] + sessions Session[] + + @@map("users") +} + +// Auth.js models +model Account { + id String @id @default(cuid()) + userId String + type String + provider String + providerAccountId String + refresh_token String? + access_token String? + expires_at Int? + token_type String? + scope String? + id_token String? + session_state String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) + @@map("accounts") +} + +model Session { + id String @id @default(cuid()) + sessionToken String @unique + userId String + expires DateTime + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("sessions") +} + +// ─── Maker ──────────────────────────────────────────────────── + +enum MakerTier { + APPLICANT + ADMITTED + ESTABLISHED + MASTER +} + +enum MakerStatus { + SUBMITTED + IN_REVIEW + ADMITTED + ONBOARDING + LIVE + SUSPENDED + DECLINED +} + +model Maker { + id String @id @default(cuid()) + userId String @unique + slug String @unique + region String? + craftCategory String? + bio String? + story String? // Long-form maker story + portraitUrl String? + workshopPhotos String[] + tier MakerTier @default(APPLICANT) + status MakerStatus @default(SUBMITTED) + stripeAccountId String? // Stripe Connect account ID + stripeOnboarded Boolean @default(false) + acceptsCommissions Boolean @default(false) + platformFeeBps Int @default(1200) // Basis points (1200 = 12%) + leadTimeBaseDays Int @default(14) + shippingOrigin String? // Country code + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + application MakerApplication? + verifications MakerVerification[] + works Work[] + followers Follow[] + testimonials Testimonial[] + orderItems OrderItem[] + commissions Commission[] + stories Story[] + + @@map("makers") +} + +model MakerApplication { + id String @id @default(cuid()) + makerId String @unique + payload Json // Full application data + status MakerStatus @default(SUBMITTED) + juryNotes String? + reviewedBy String? // Admin userId + decidedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + maker Maker @relation(fields: [makerId], references: [id], onDelete: Cascade) + + @@map("maker_applications") +} + +enum VerificationKind { + IDENTITY + WORKSHOP_VISIT + MATERIALS_SOURCING + SUSTAINABILITY +} + +model MakerVerification { + id String @id @default(cuid()) + makerId String + kind VerificationKind + verifiedAt DateTime? + evidenceUrl String? + notes String? + createdAt DateTime @default(now()) + + maker Maker @relation(fields: [makerId], references: [id], onDelete: Cascade) + + @@unique([makerId, kind]) + @@map("maker_verifications") +} + +// ─── Works (the product/object) ────────────────────────────── + +enum EditionKind { + ONE_OF_ONE + LIMITED + OPEN + MADE_TO_ORDER +} + +enum WorkStatus { + DRAFT + PUBLISHED + ARCHIVED + SOLD_OUT +} + +model Work { + id String @id @default(cuid()) + makerId String + slug String + title String + description String? + editionKind EditionKind @default(MADE_TO_ORDER) + editionSize Int? // null for open/made-to-order + editionCurrent Int? // Current edition number sold + leadTimeDays Int @default(14) + status WorkStatus @default(DRAFT) + publishedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + maker Maker @relation(fields: [makerId], references: [id], onDelete: Cascade) + variants WorkVariant[] + materials WorkMaterial[] + media WorkMedia[] + processNotes WorkProcessNote[] + provenanceCertificates ProvenanceCertificate[] + collectionItems EditorialCollectionItem[] + waitlistSubs WaitlistSub[] + + @@unique([makerId, slug]) + @@map("works") +} + +model WorkVariant { + id String @id @default(cuid()) + workId String + sku String? + attributes Json? // { size, finish, etc. } + priceMinor Int // Price in minor currency units (cents) + currency String @default("USD") + stock Int @default(0) + madeToOrder Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + work Work @relation(fields: [workId], references: [id], onDelete: Cascade) + orderItems OrderItem[] + cartItems CartItem[] + + @@map("work_variants") +} + +model WorkMaterial { + id String @id @default(cuid()) + workId String + name String + origin String? // Geographic or source origin + percentage Int? // Percentage of total materials (for composite objects) + + work Work @relation(fields: [workId], references: [id], onDelete: Cascade) + + @@map("work_materials") +} + +enum MediaKind { + PHOTO + VIDEO + THREE_SIXTY +} + +model WorkMedia { + id String @id @default(cuid()) + workId String + kind MediaKind @default(PHOTO) + url String + width Int? + height Int? + position Int @default(0) // Ordering in gallery + altText String? + moderationStatus String @default("unchecked") // P2.3: unchecked|clean|flagged|blocked + + work Work @relation(fields: [workId], references: [id], onDelete: Cascade) + + @@map("work_media") +} + +model WorkProcessNote { + id String @id @default(cuid()) + workId String + hours Int? // Hours to create + technique String? + notes String? + photoUrls String[] + + work Work @relation(fields: [workId], references: [id], onDelete: Cascade) + + @@map("work_process_notes") +} + +model ProvenanceCertificate { + id String @id @default(cuid()) + workId String + editionNumber Int // Which edition this cert belongs to + pdfUrl String? + hash String? // Content hash for verification + issuedAt DateTime @default(now()) + + work Work @relation(fields: [workId], references: [id], onDelete: Cascade) + + @@map("provenance_certificates") +} + +// ─── Collector (Buyer) ────────────────────────────────────── + +model Collector { + id String @id @default(cuid()) + userId String @unique + publicHandle String? + publicCollectionOptIn Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + follows Follow[] + waitlistSubs WaitlistSub[] + orders Order[] + commissions Commission[] + testimonials Testimonial[] + savedSearches SavedSearch[] + cart Cart? + + @@map("collectors") +} + +model Cart { + id String @id @default(cuid()) + collectorId String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + collector Collector @relation(fields: [collectorId], references: [id], onDelete: Cascade) + items CartItem[] + + @@map("carts") +} + +model CartItem { + id String @id @default(cuid()) + cartId String + workVariantId String + quantity Int @default(1) + createdAt DateTime @default(now()) + + cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade) + variant WorkVariant @relation(fields: [workVariantId], references: [id]) + + @@unique([cartId, workVariantId]) + @@map("cart_items") +} + +model Follow { + id String @id @default(cuid()) + collectorId String + makerId String + createdAt DateTime @default(now()) + + collector Collector @relation(fields: [collectorId], references: [id], onDelete: Cascade) + maker Maker @relation(fields: [makerId], references: [id], onDelete: Cascade) + + @@unique([collectorId, makerId]) + @@map("follows") +} + +model WaitlistSub { + id String @id @default(cuid()) + collectorId String + workId String + createdAt DateTime @default(now()) + + collector Collector @relation(fields: [collectorId], references: [id], onDelete: Cascade) + work Work @relation(fields: [workId], references: [id], onDelete: Cascade) + + @@unique([collectorId, workId]) + @@map("waitlist_subs") +} + +// ─── Commerce ──────────────────────────────────────────────── + +enum OrderStatus { + PENDING + CONFIRMED + IN_PROGRESS + SHIPPED + DELIVERED + CANCELLED + DISPUTED +} + +model Order { + id String @id @default(cuid()) + collectorId String + status OrderStatus @default(PENDING) + totalMinor Int @default(0) + currency String @default("USD") + stripeSessionId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + collector Collector @relation(fields: [collectorId], references: [id]) + items OrderItem[] + + @@map("orders") +} + +model OrderItem { + id String @id @default(cuid()) + orderId String + workVariantId String + makerId String + priceMinor Int + leadTimeDays Int + progressPhotos String[] // URLs of progress photos uploaded by maker + + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) + variant WorkVariant @relation(fields: [workVariantId], references: [id]) + maker Maker @relation(fields: [makerId], references: [id]) + + @@map("order_items") +} + +model Shipment { + id String @id @default(cuid()) + orderId String + makerId String + carrier String? + tracking String? + status String @default("pending") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Using direct IDs since shipments are per-maker within an order + // Full relation to Order requires a compound key; simplifying for v1 + + @@map("shipments") +} + +// ─── Commissions ───────────────────────────────────────────── + +enum CommissionStatus { + SUBMITTED + PROPOSED + ACCEPTED + IN_PROGRESS + DELIVERED + DECLINED + CANCELLED +} + +model Commission { + id String @id @default(cuid()) + collectorId String + makerId String + brief String // Free text from collector + referenceUrls String[] // Reference images + budgetBand String? // e.g. "500-1000" + status CommissionStatus @default(SUBMITTED) + totalMinor Int? + currency String @default("USD") + milestones Json? // [{ description, amountMinor, status, dueAt }] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + collector Collector @relation(fields: [collectorId], references: [id]) + maker Maker @relation(fields: [makerId], references: [id]) + + @@map("commissions") +} + +// ─── Conversations ────────────────────────────────────────── + +enum ConversationScope { + WORK + COMMISSION +} + +model Conversation { + id String @id @default(cuid()) + scopeKind ConversationScope + scopeId String // workId or commissionId + openedById String + offenseCount Int @default(0) // P2.2: off-platform contact strikes (0-3) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + messages Message[] + + @@map("conversations") +} + +model Message { + id String @id @default(cuid()) + conversationId String + senderId String // userId + body String + flagged Boolean @default(false) // P2.2: flagged for off-platform contact + createdAt DateTime @default(now()) + + conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) + + @@map("messages") +} + +// ─── Editorial ─────────────────────────────────────────────── + +model EditorialCollection { + id String @id @default(cuid()) + slug String @unique + title String + copy String? + heroMediaUrl String? + publishedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + items EditorialCollectionItem[] + + @@map("editorial_collections") +} + +model EditorialCollectionItem { + id String @id @default(cuid()) + collectionId String + workId String + position Int @default(0) + caption String? + + collection EditorialCollection @relation(fields: [collectionId], references: [id], onDelete: Cascade) + work Work @relation(fields: [workId], references: [id], onDelete: Cascade) + + @@unique([collectionId, workId]) + @@map("editorial_collection_items") +} + +model Story { + id String @id @default(cuid()) + slug String @unique + title String + bodyMarkdown String + heroMediaUrl String? + makerId String? + publishedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + maker Maker? @relation(fields: [makerId], references: [id]) + + @@map("stories") +} + +enum FeatureKind { + MAKER_OF_THE_MONTH + DISPATCH + EDITORIAL +} + +model Feature { + id String @id @default(cuid()) + kind FeatureKind + slug String @unique + targetId String? // makerId or collectionId + scheduledFor DateTime? + publishedAt DateTime? + createdAt DateTime @default(now()) + + @@map("features") +} + +// ─── Reputation ────────────────────────────────────────────── + +model Testimonial { + id String @id @default(cuid()) + makerId String + collectorId String + body String + moderated Boolean @default(false) + displayed Boolean @default(false) + createdAt DateTime @default(now()) + + maker Maker @relation(fields: [makerId], references: [id], onDelete: Cascade) + collector Collector @relation(fields: [collectorId], references: [id]) + + @@map("testimonials") +} + +model ProcessedWebhookEvent { + id String @id // Stripe event ID + type String + processedAt DateTime @default(now()) + + @@map("processed_webhook_events") +} + +// ─── Analytics ─────────────────────────────────────────────── + +model PageView { + id String @id @default(cuid()) + path String + workId String? // Null for non-work pages + makerId String? // Denormalized for faster aggregation + referrer String? + createdAt DateTime @default(now()) + + @@index([workId]) + @@index([makerId, createdAt]) + @@map("page_views") +} + +model MakerAnalyticsDaily { + id String @id @default(cuid()) + makerId String + date DateTime @db.Date + views Int @default(0) + uniqueVisitors Int @default(0) + follows Int @default(0) + orders Int @default(0) + revenueMinor Int @default(0) + conversionRate Float @default(0) // orders / views + + @@unique([makerId, date]) + @@index([makerId]) + @@map("maker_analytics_daily") +} + +// ─── Saved Searches ────────────────────────────────────────── + +model SavedSearch { + id String @id @default(cuid()) + collectorId String + query String // Search query text + filters Json // { craft, region, material, priceMin, priceMax } + notifyEmail Boolean @default(true) + lastNotifiedAt DateTime? + createdAt DateTime @default(now()) + + collector Collector @relation(fields: [collectorId], references: [id], onDelete: Cascade) + + @@index([collectorId]) + @@map("saved_searches") +} + +// ─── Rate Limiting ─────────────────────────────────────────── + +model RateLimitBucket { + key String @id + tokens Int @default(0) + lastRefill DateTime @default(now()) + maxTokens Int + refillRate Float // Tokens per hour + + @@map("rate_limit_buckets") +} + +// ─── Audit Log ─────────────────────────────────────────────── + +model AuditLog { + id String @id @default(cuid()) + actorId String // userId who performed the action + action String // e.g. "standards.override", "tier.set", "maker.admitted" + targetType String // e.g. "work", "maker", "commission" + targetId String // ID of the affected entity + justification String? // Admin's reason for the action + metadata Json? // Optional extra context (old/new values, etc.) + createdAt DateTime @default(now()) + + @@index([targetType, targetId]) + @@index([actorId]) + @@map("audit_logs") +} + diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..858c2f9 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,223 @@ +import { prisma } from "@/lib/prisma" +import { formatCurrency } from "@/lib/utils" + +export const dynamic = "force-dynamic" + +export default async function AdminDashboardPage() { + const now = new Date() + const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) + + // Fetch all metrics in parallel + const [ + applicationCount, + makersByTier, + gmvThisWeek, + gmvLastWeek, + openCommissions, + pendingRefunds, + totalOrders, + activeConversations, + ] = await Promise.all([ + // Applications in queue + prisma.makerApplication.count({ where: { status: "SUBMITTED" } }), + + // Makers by tier + prisma.maker.groupBy({ by: ["tier"], _count: true }), + + // GMV this week (orders confirmed this week) + prisma.order.aggregate({ + _sum: { totalMinor: true }, + where: { + status: { in: ["CONFIRMED", "IN_PROGRESS", "SHIPPED", "DELIVERED"] }, + createdAt: { gte: weekAgo }, + }, + }), + + // GMV last week (for comparison) + prisma.order.aggregate({ + _sum: { totalMinor: true }, + where: { + status: { in: ["CONFIRMED", "IN_PROGRESS", "SHIPPED", "DELIVERED"] }, + createdAt: { + gte: new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000), + lt: weekAgo, + }, + }, + }), + + // Open commissions + prisma.commission.count({ + where: { status: { in: ["SUBMITTED", "PROPOSED", "ACCEPTED", "IN_PROGRESS"] } }, + }), + + // Pending refunds / disputed orders + prisma.order.count({ where: { status: "DISPUTED" } }), + + // Total orders + prisma.order.count(), + + // Active conversations (messages in last 24h) + prisma.conversation.count({ + where: { + messages: { some: { createdAt: { gte: new Date(now.getTime() - 24 * 60 * 60 * 1000) } } }, + }, + }), + ]) + + const gmvAllTime = (await prisma.order.aggregate({ + _sum: { totalMinor: true }, + where: { status: { in: ["CONFIRMED", "IN_PROGRESS", "SHIPPED", "DELIVERED"] } }, + }))._sum.totalMinor || 0 + + const gmvWeekly = gmvThisWeek._sum.totalMinor || 0 + const gmvPrevious = gmvLastWeek._sum.totalMinor || 0 + const gmvChange = gmvPrevious > 0 + ? ((gmvWeekly - gmvPrevious) / gmvPrevious * 100).toFixed(1) + : null + + const tierCounts: Record = {} + for (const row of makersByTier) { tierCounts[row.tier] = row._count } + + return ( +
+
+

Atelier

+

+ {new Date().toLocaleDateString("en-ZA", { weekday: "long", day: "numeric", month: "long", year: "numeric" })} +

+ + {/* KPI row */} +
+ 0 ? "warning" : "neutral"} + /> + + + 0 ? "warning" : "neutral"} + /> +
+ + {/* Detail panels */} +
+ {/* Makers by tier */} +
+

Makers by Tier

+
+ + + + +
+ + View all makers → + +
+ + {/* Quick stats */} +
+

Platform Activity

+
+ + + + +
+
+
+ + {/* Quick links */} + +
+
+ ) +} + +// ─── Sub-components ────────────────────────────────────────── + +function MetricCard({ + label, + value, + href, + variant, + trend, +}: { + label: string + value: string | number + href: string + variant: "neutral" | "warning" + trend?: string +}) { + return ( + +

{label}

+

+ {value} +

+ {trend && ( +

+ {trend.startsWith("-") ? "↓" : "↑"} {trend} vs last week +

+ )} +
+ ) +} + +function TierRow({ label, count, color }: { label: string; count: number; color: string }) { + const max = 20 + const width = Math.min((count / max) * 100, 100) + return ( +
+ {label} +
+
+
+ {count} +
+ ) +} + +function StatRow({ label, value }: { label: string; value: string | number }) { + return ( +
+ {label} + {value} +
+ ) +} diff --git a/src/app/api/applications/route.ts b/src/app/api/applications/route.ts new file mode 100644 index 0000000..0490492 --- /dev/null +++ b/src/app/api/applications/route.ts @@ -0,0 +1,137 @@ +import { NextResponse } from "next/server" +import { getSession } from "@/lib/auth-utils" +import { prisma } from "@/lib/prisma" +import { submitApplicationSchema } from "@/lib/validators/application" +import { logger, businessEvent } from "@/lib/logger" +import { sendEmail } from "@/lib/email" +import { ApplicationReceivedEmail } from "@/emails/application-received" +import { RateLimiters } from "@/lib/rate-limiter" + +export async function POST(req: Request) { + const session = await getSession() + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + // ── P2.1: Rate limiting — 3 applications per day ─────────── + const rateCheck = await RateLimiters.applicationSubmit(session.user.id) + if (!rateCheck.allowed) { + return NextResponse.json( + { error: "Too many applications. Please wait before submitting again." }, + { status: 429, headers: { "Retry-After": String(rateCheck.retryAfterSeconds) } } + ) + } + + const body = await req.json() + const parsed = submitApplicationSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: "Validation failed", details: parsed.error.flatten() }, { status: 400 }) + } + + // Check if user already has a maker account or pending application + const existing = await prisma.maker.findUnique({ + where: { userId: session.user.id }, + include: { application: true }, + }) + + if (existing) { + if (existing.status === "DECLINED") { + // Allow re-application — delete old application + if (existing.application) { + await prisma.makerApplication.delete({ where: { makerId: existing.id } }) + } + // Update existing maker record + await prisma.makerApplication.create({ + data: { + makerId: existing.id, + payload: parsed.data, + status: "SUBMITTED", + }, + }) + await prisma.maker.update({ + where: { id: existing.id }, + data: { status: "SUBMITTED" }, + }) + logger.info("Application resubmitted", { makerId: existing.id }) + return NextResponse.json({ status: "submitted", makerId: existing.id }) + } + return NextResponse.json( + { error: "You already have an application or maker account" }, + { status: 409 } + ) + } + + // Create maker + application in transaction + const slug = parsed.data.workshopName + ? parsed.data.workshopName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") + : `maker-${session.user.id.slice(-8)}` + + const maker = await prisma.maker.create({ + data: { + userId: session.user.id, + slug, + craftCategory: parsed.data.craftCategory, + tier: "APPLICANT", + status: "SUBMITTED", + application: { + create: { + payload: parsed.data, + status: "SUBMITTED", + }, + }, + }, + include: { application: true }, + }) + + // Update user role to APPLICANT (MAKER role after admission) + await prisma.user.update({ + where: { id: session.user.id }, + data: { role: "MAKER" }, + }) + + logger.info("Application submitted", { makerId: maker.id }) + + // Send confirmation email + sendEmail({ + to: parsed.data.email, + subject: "Your application to Atelier has been received", + react: ApplicationReceivedEmail({ + makerName: parsed.data.fullName, + craftCategory: parsed.data.craftCategory, + }), + }).catch(() => {}) // fire-and-forget + + businessEvent("application.submitted", { makerId: maker.id, craft: parsed.data.craftCategory }) + + return NextResponse.json({ + status: "submitted", + makerId: maker.id, + applicationId: maker.application!.id, + }) +} + +export async function GET(req: Request) { + const session = await getSession() + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const maker = await prisma.maker.findUnique({ + where: { userId: session.user.id }, + include: { application: true }, + }) + + if (!maker) { + return NextResponse.json({ status: "none" }) + } + + return NextResponse.json({ + status: maker.status, + makerId: maker.id, + application: maker.application ? { + id: maker.application.id, + status: maker.application.status, + submittedAt: maker.application.createdAt, + } : null, + }) +} diff --git a/src/app/api/cart/route.ts b/src/app/api/cart/route.ts new file mode 100644 index 0000000..59a92c7 --- /dev/null +++ b/src/app/api/cart/route.ts @@ -0,0 +1,175 @@ +import { NextResponse } from "next/server" +import { getSession } from "@/lib/auth-utils" +import { prisma } from "@/lib/prisma" +import { logger } from "@/lib/logger" + +// GET — get current cart +export async function GET() { + const session = await getSession() + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const collector = await prisma.collector.findUnique({ + where: { userId: session.user.id }, + include: { + cart: { + include: { + items: { + include: { + variant: { + include: { + work: { + include: { + maker: { select: { slug: true } }, + media: { orderBy: { position: "asc" }, take: 1 }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }) + + if (!collector?.cart) { + return NextResponse.json({ cart: { items: [] } }) + } + + return NextResponse.json({ cart: collector.cart }) +} + +// POST — add item to cart +export async function POST(req: Request) { + const session = await getSession() + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const body = await req.json() + const { workVariantId, quantity = 1, forceSingleMaker } = body + + if (!workVariantId) { + return NextResponse.json({ error: "workVariantId required" }, { status: 400 }) + } + + // Verify variant exists and is available + const variant = await prisma.workVariant.findUnique({ + where: { id: workVariantId }, + include: { work: { select: { status: true, makerId: true } } }, + }) + + if (!variant || variant.work.status !== "PUBLISHED") { + return NextResponse.json({ error: "Variant not available" }, { status: 404 }) + } + + // Get or create collector + cart + let collector = await prisma.collector.findUnique({ + where: { userId: session.user.id }, + }) + + if (!collector) { + collector = await prisma.collector.create({ + data: { userId: session.user.id }, + }) + } + + let cart = await prisma.cart.findUnique({ + where: { collectorId: collector.id }, + include: { + items: { + include: { + variant: { include: { work: { select: { makerId: true } } } }, + }, + }, + }, + }) + + if (!cart) { + cart = await prisma.cart.create({ + data: { collectorId: collector.id }, + include: { items: { include: { variant: { include: { work: { select: { makerId: true } } } } } } }, + }) + } + + // P0.1: Single-maker cart constraint — check for cross-maker contamination + const cartMakerIds = new Set( + cart.items.map((item) => item.variant.work.makerId) + ) + + if (cartMakerIds.size > 0 && !cartMakerIds.has(variant.work.makerId)) { + // Different maker detected + if (forceSingleMaker) { + // Clear cart and start fresh with this maker's item + await prisma.cartItem.deleteMany({ where: { cartId: cart.id } }) + cartMakerIds.clear() + } else { + // Ask the client to confirm by returning a conflict signal + return NextResponse.json({ + conflict: "different_maker", + currentMakerId: [...cartMakerIds][0], + requestedMakerId: variant.work.makerId, + message: "This piece is from a different maker — start a separate order?", + }, { status: 409 }) + } + } + + // Upsert cart item + const existing = await prisma.cartItem.findUnique({ + where: { cartId_workVariantId: { cartId: cart.id, workVariantId } }, + }) + + if (existing) { + await prisma.cartItem.update({ + where: { id: existing.id }, + data: { quantity: existing.quantity + quantity }, + }) + } else { + await prisma.cartItem.create({ + data: { cartId: cart.id, workVariantId, quantity }, + }) + } + + logger.info("Cart item added", { collectorId: collector.id, workVariantId, quantity }) + return NextResponse.json({ success: true }) +} + +// PATCH — update item quantity +export async function PATCH(req: Request) { + const session = await getSession() + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const body = await req.json() + const { cartItemId, quantity } = body + + if (!cartItemId || quantity === undefined) { + return NextResponse.json({ error: "cartItemId and quantity required" }, { status: 400 }) + } + + if (quantity < 1) { + await prisma.cartItem.delete({ where: { id: cartItemId } }) + return NextResponse.json({ success: true, removed: true }) + } + + await prisma.cartItem.update({ + where: { id: cartItemId }, + data: { quantity }, + }) + + return NextResponse.json({ success: true }) +} + +// DELETE — remove item from cart +export async function DELETE(req: Request) { + const session = await getSession() + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const { searchParams } = new URL(req.url) + const cartItemId = searchParams.get("id") + if (!cartItemId) return NextResponse.json({ error: "id required" }, { status: 400 }) + + await prisma.cartItem.delete({ where: { id: cartItemId } }) + return NextResponse.json({ success: true }) +} diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts new file mode 100644 index 0000000..d051fd6 --- /dev/null +++ b/src/app/api/checkout/route.ts @@ -0,0 +1,124 @@ +import { NextResponse } from "next/server" +import { getSession } from "@/lib/auth-utils" +import { prisma } from "@/lib/prisma" +import Stripe from "stripe" +import { logger } from "@/lib/logger" + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: "2025-04-30.basil" as any, +}) + +export async function POST(req: Request) { + const session = await getSession() + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + // Get user's cart + const collector = await prisma.collector.findUnique({ + where: { userId: session.user.id }, + include: { + cart: { + include: { + items: { + include: { + variant: { + include: { + work: { + include: { + maker: { select: { id: true, slug: true, stripeAccountId: true, platformFeeBps: true } }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }) + + if (!collector?.cart?.items.length) { + return NextResponse.json({ error: "Cart is empty" }, { status: 400 }) + } + + // Create the order first (PENDING status) + const order = await prisma.order.create({ + data: { + collectorId: collector.id, + status: "PENDING", + totalMinor: 0, + currency: "USD", + items: { + create: collector.cart.items.map((item) => ({ + workVariantId: item.workVariantId, + makerId: item.variant.work.makerId, + priceMinor: item.variant.priceMinor * item.quantity, + leadTimeDays: item.variant.work.leadTimeDays, + })), + }, + }, + include: { items: true }, + }) + + // Calculate total + const totalMinor = order.items.reduce((sum, item) => sum + item.priceMinor, 0) + await prisma.order.update({ + where: { id: order.id }, + data: { totalMinor }, + }) + + // Build Stripe line items with Connect transfers + const lineItems = collector.cart.items.map((item) => ({ + price_data: { + currency: "usd", + product_data: { + name: item.variant.work.title, + metadata: { workVariantId: item.workVariantId, makerId: item.variant.work.makerId }, + }, + unit_amount: item.variant.priceMinor, + }, + quantity: item.quantity, + })) + + // Create Stripe Checkout session with Connect transfers + const stripeSession = await stripe.checkout.sessions.create({ + mode: "payment", + payment_method_types: ["card"], + line_items: lineItems, + success_url: `${process.env.NEXT_PUBLIC_APP_URL}/orders/${order.id}?success=true`, + cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/cart?cancelled=true`, + metadata: { orderId: order.id }, + payment_intent_data: { + application_fee_amount: Math.round( + (totalMinor * (collector.cart.items[0]?.variant.work.maker.platformFeeBps || 1200)) / 10000 + ), + ...(collector.cart.items[0]?.variant.work.maker.stripeAccountId + ? { transfer_data: { destination: collector.cart.items[0].variant.work.maker.stripeAccountId } } + : {}), + metadata: { orderId: order.id }, + }, + // For multi-maker orders with Connect, we need separate transfers + // This simplified version transfers to the first maker; full split uses transfers array + }) + + // Link Stripe session to order + await prisma.order.update({ + where: { id: order.id }, + data: { stripeSessionId: stripeSession.id }, + }) + + // Clear cart + await prisma.cartItem.deleteMany({ + where: { cartId: collector.cart.id }, + }) + + logger.info("Checkout session created", { + orderId: order.id, + stripeSessionId: stripeSession.id, + totalMinor, + itemCount: order.items.length, + }) + + return NextResponse.json({ url: stripeSession.url!, orderId: order.id }) +} diff --git a/src/app/api/commissions/[id]/pay/route.ts b/src/app/api/commissions/[id]/pay/route.ts new file mode 100644 index 0000000..6834eca --- /dev/null +++ b/src/app/api/commissions/[id]/pay/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server" +import { getSession } from "@/lib/auth-utils" +import { prisma } from "@/lib/prisma" +import Stripe from "stripe" +import { logger } from "@/lib/logger" + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: "2025-04-30.basil" as any, +}) + +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getSession() + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const { id } = await params + const body = await req.json() + const { milestoneIndex } = body // Index of milestone in the milestones array + + const collector = await prisma.collector.findUnique({ where: { userId: session.user.id } }) + if (!collector) return NextResponse.json({ error: "Not a collector" }, { status: 403 }) + + const commission = await prisma.commission.findUnique({ + where: { id }, + include: { maker: { select: { stripeAccountId: true } } }, + }) + + if (!commission || commission.collectorId !== collector.id) { + return NextResponse.json({ error: "Not found" }, { status: 404 }) + } + + const milestones = commission.milestones as any[] + if (!milestones || milestoneIndex === undefined || !milestones[milestoneIndex]) { + return NextResponse.json({ error: "Invalid milestone" }, { status: 400 }) + } + + const milestone = milestones[milestoneIndex] + if (milestone.status === "paid") { + return NextResponse.json({ error: "Milestone already paid" }, { status: 400 }) + } + + const stripeSession = await stripe.checkout.sessions.create({ + mode: "payment", + payment_method_types: ["card"], + line_items: [{ + price_data: { + currency: "usd", + product_data: { + name: `Commission: ${milestone.description}`, + description: `Milestone ${milestoneIndex + 1} of ${milestones.length}`, + }, + unit_amount: milestone.amountMinor, + }, + quantity: 1, + }], + success_url: `${process.env.NEXT_PUBLIC_APP_URL}/commissions/${id}?paid=true`, + cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/commissions/${id}`, + metadata: { commissionId: id, milestoneIndex: String(milestoneIndex) }, + payment_intent_data: { + application_fee_amount: Math.round(milestone.amountMinor * 0.12), + ...(commission.maker.stripeAccountId + ? { transfer_data: { destination: commission.maker.stripeAccountId } } + : {}), + }, + }) + + logger.info("Commission milestone payment initiated", { + commissionId: id, + milestoneIndex, + amount: milestone.amountMinor, + }) + + return NextResponse.json({ url: stripeSession.url }) +} diff --git a/src/app/api/commissions/[id]/route.ts b/src/app/api/commissions/[id]/route.ts new file mode 100644 index 0000000..08fef6a --- /dev/null +++ b/src/app/api/commissions/[id]/route.ts @@ -0,0 +1,111 @@ +import { NextResponse } from "next/server" +import { getSession } from "@/lib/auth-utils" +import { prisma } from "@/lib/prisma" +import { proposeCommissionSchema } from "@/lib/validators/commission" +import { logger, businessEvent } from "@/lib/logger" + +export async function GET( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getSession() + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const { id } = await params + const commission = await prisma.commission.findUnique({ + where: { id }, + include: { + maker: { select: { slug: true, stripeAccountId: true } }, + collector: { select: { publicHandle: true } }, + }, + }) + + if (!commission) return NextResponse.json({ error: "Not found" }, { status: 404 }) + return NextResponse.json({ commission }) +} + +// Maker: propose terms / accept / decline +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getSession() + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const { id } = await params + const body = await req.json() + const { action } = body // "propose" | "accept" | "decline" | "complete" + + // Collectors can accept; makers do everything else + const isCollectorAction = action === "accept" + const maker = isCollectorAction ? null : await prisma.maker.findUnique({ where: { userId: session.user.id } }) + if (!isCollectorAction && !maker) return NextResponse.json({ error: "Not a maker" }, { status: 403 }) + + const commission = await prisma.commission.findUnique({ where: { id } }) + if (!commission) return NextResponse.json({ error: "Not found" }, { status: 404 }) + + // Collectors can only accept their own commissions + if (isCollectorAction) { + const collector = await prisma.collector.findUnique({ where: { userId: session.user.id } }) + if (!collector || commission.collectorId !== collector.id) { + return NextResponse.json({ error: "Not your commission" }, { status: 403 }) + } + } else if (commission.makerId !== maker!.id) { + return NextResponse.json({ error: "Not found" }, { status: 404 }) + } + + let update: any = {} + let event: string | null = null + + switch (action) { + case "propose": { + const parsed = proposeCommissionSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: "Validation failed", details: parsed.error.flatten() }, { status: 400 }) + } + update = { + status: "PROPOSED", + totalMinor: parsed.data.totalMinor, + currency: parsed.data.currency, + milestones: parsed.data.milestones, + } + event = "commission.terms_proposed" + break + } + case "accept": { + if (commission.status !== "PROPOSED") { + return NextResponse.json({ error: "Commission must be in PROPOSED state" }, { status: 400 }) + } + update = { status: "ACCEPTED" } + event = "commission.accepted" + break + } + case "decline": { + update = { status: "DECLINED" } + event = "commission.declined" + break + } + case "complete": { + if (commission.status !== "ACCEPTED" && commission.status !== "IN_PROGRESS") { + return NextResponse.json({ error: "Commission must be ACCEPTED or IN_PROGRESS" }, { status: 400 }) + } + update = { status: "DELIVERED" } + event = "commission.delivered" + break + } + default: + return NextResponse.json({ error: "Invalid action" }, { status: 400 }) + } + + const updated = await prisma.commission.update({ + where: { id }, + data: update, + }) + + if (event) { + businessEvent(event, { commissionId: id, makerId: maker?.id || commission.makerId }) + } + + logger.info(`Commission ${action}`, { commissionId: id }) + return NextResponse.json({ commission: updated }) +} diff --git a/src/app/api/commissions/route.ts b/src/app/api/commissions/route.ts new file mode 100644 index 0000000..795e206 --- /dev/null +++ b/src/app/api/commissions/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from "next/server" +import { getSession } from "@/lib/auth-utils" +import { prisma } from "@/lib/prisma" +import { submitCommissionSchema } from "@/lib/validators/commission" +import { logger, businessEvent } from "@/lib/logger" + +// POST — submit a commission brief +export async function POST(req: Request) { + const session = await getSession() + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const collector = await prisma.collector.findUnique({ where: { userId: session.user.id } }) + if (!collector) return NextResponse.json({ error: "No collector profile" }, { status: 404 }) + + const body = await req.json() + const parsed = submitCommissionSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: "Validation failed", details: parsed.error.flatten() }, { status: 400 }) + } + + // Verify maker exists and accepts commissions + const maker = await prisma.maker.findUnique({ where: { id: parsed.data.makerId } }) + if (!maker || maker.status !== "LIVE") { + return NextResponse.json({ error: "Maker not found" }, { status: 404 }) + } + if (!maker.acceptsCommissions) { + return NextResponse.json({ error: "This maker does not accept commissions" }, { status: 400 }) + } + + const commission = await prisma.commission.create({ + data: { + collectorId: collector.id, + makerId: parsed.data.makerId, + brief: parsed.data.brief, + referenceUrls: parsed.data.referenceUrls || [], + budgetBand: parsed.data.budgetBand, + status: "SUBMITTED", + }, + }) + + businessEvent("commission.submitted", { commissionId: commission.id, makerId: maker.id }) + logger.info("Commission submitted", { commissionId: commission.id }) + + return NextResponse.json({ commission }, { status: 201 }) +} + +// GET — list commissions (collector or maker view) +export async function GET(req: Request) { + const session = await getSession() + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const { searchParams } = new URL(req.url) + const role = searchParams.get("role") || "collector" + + if (role === "maker") { + const maker = await prisma.maker.findUnique({ where: { userId: session.user.id } }) + if (!maker) return NextResponse.json({ error: "Not a maker" }, { status: 403 }) + + const commissions = await prisma.commission.findMany({ + where: { makerId: maker.id }, + include: { + collector: { select: { publicHandle: true } }, + }, + orderBy: { updatedAt: "desc" }, + }) + return NextResponse.json({ commissions }) + } + + const collector = await prisma.collector.findUnique({ where: { userId: session.user.id } }) + if (!collector) return NextResponse.json({ commissions: [] }) + + const commissions = await prisma.commission.findMany({ + where: { collectorId: collector.id }, + include: { + maker: { select: { slug: true } }, + }, + orderBy: { updatedAt: "desc" }, + }) + + return NextResponse.json({ commissions }) +} diff --git a/src/app/api/e2e/session/route.ts b/src/app/api/e2e/session/route.ts new file mode 100644 index 0000000..9388357 --- /dev/null +++ b/src/app/api/e2e/session/route.ts @@ -0,0 +1,98 @@ +/** + * E2E-only endpoint: generates a valid Auth.js v5 session cookie for a test user. + * This exists because jose's WebCrypto (used by node --input-type=module) + * produces incompatible JWE output vs jose's Node crypto (used by Next.js). + * By generating cookies server-side, the format always matches exactly. + * + * Only enabled when running in development mode. + * Requires AUTH_E2E_TOKEN in the request to prevent accidental exposure. + */ +import { NextResponse } from "next/server" +import { encode } from "@auth/core/jwt" +import { prisma } from "@/lib/prisma" + +interface TestUserRequest { + id: string + email: string + name: string + role: string +} + +export async function POST(req: Request) { + // Guard: only allow in dev, and only with a known test token + if (process.env.NODE_ENV !== "development") { + return NextResponse.json({ error: "Not available" }, { status: 404 }) + } + + if (process.env.E2E_TEST_TOKEN && req.headers.get("x-e2e-token") !== process.env.E2E_TEST_TOKEN) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const body: TestUserRequest = await req.json() + if (!body.id || !body.email || !body.role) { + return NextResponse.json({ error: "Missing id, email, or role" }, { status: 400 }) + } + + // Upsert User + role-specific records so API routes find them + await ensureTestUser(body) + + const token = await encode({ + token: { + sub: body.id, + email: body.email, + name: body.name || body.email, + role: body.role, + id: body.id, + }, + secret: process.env.AUTH_SECRET!, + salt: "authjs.session-token", + maxAge: 60 * 60, // 1 hour + }) + + return NextResponse.json({ + cookie: { + name: "authjs.session-token", + value: token, + domain: "localhost", + path: "/", + }, + }) +} + +async function ensureTestUser(user: TestUserRequest) { + await prisma.user.upsert({ + where: { id: user.id }, + create: { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + }, + update: { role: user.role, name: user.name }, + }) + + if (user.role === "MAKER") { + await prisma.maker.upsert({ + where: { userId: user.id }, + create: { + userId: user.id, + slug: user.id, // use test id as slug + acceptsCommissions: true, + region: "US", + tier: "ESTABLISHED", + bio: "E2E test maker", + }, + update: { acceptsCommissions: true }, + }) + } + + if (user.role === "COLLECTOR") { + await prisma.collector.upsert({ + where: { userId: user.id }, + create: { + userId: user.id, + }, + update: {}, + }) + } +} diff --git a/src/app/api/orders/[id]/route.ts b/src/app/api/orders/[id]/route.ts new file mode 100644 index 0000000..73f4908 --- /dev/null +++ b/src/app/api/orders/[id]/route.ts @@ -0,0 +1,188 @@ +import { NextResponse } from "next/server" +import { getSession } from "@/lib/auth-utils" +import { prisma } from "@/lib/prisma" +import { logger } from "@/lib/logger" +import { evaluateMakerTier } from "@/lib/tiers" +import { generateProvenanceCertificate } from "@/lib/provenance" + +export async function GET( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getSession() + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const { id } = await params + + const order = await prisma.order.findUnique({ + where: { id }, + include: { + items: { + include: { + variant: { + include: { + work: { + include: { + media: { orderBy: { position: "asc" }, take: 1 }, + maker: { select: { slug: true, region: true } }, + }, + }, + }, + }, + maker: { select: { slug: true } }, + }, + }, + }, + }) + + if (!order) return NextResponse.json({ error: "Not found" }, { status: 404 }) + + return NextResponse.json({ order }) +} + +// Maker: update order item progress +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getSession() + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const { id } = await params + const maker = await prisma.maker.findUnique({ where: { userId: session.user.id } }) + if (!maker) return NextResponse.json({ error: "Not a maker" }, { status: 403 }) + + const body = await req.json() + const { orderItemId, status, progressPhotoUrl, trackingNumber, carrier } = body + + if (orderItemId) { + const item = await prisma.orderItem.findUnique({ + where: { id: orderItemId }, + include: { order: true }, + }) + + if (!item || item.makerId !== maker.id) { + return NextResponse.json({ error: "Not your order item" }, { status: 403 }) + } + + // OrderItem has no `status` field — status lives on Order + // Progress photo tracking + if (progressPhotoUrl) { + await prisma.orderItem.update({ + where: { id: orderItemId }, + data: { + progressPhotos: [...(item.progressPhotos || []), progressPhotoUrl], + }, + }) + } + + // If maker sets status to IN_PROGRESS, update the order + if (status === "IN_PROGRESS") { + await prisma.order.update({ + where: { id: item.orderId }, + data: { status: "IN_PROGRESS" }, + }) + } + + // If shipping, create or update shipment + if (trackingNumber) { + await prisma.shipment.upsert({ + where: { id: `${item.orderId}-${item.makerId}` }, + create: { + orderId: item.orderId, + makerId: item.makerId, + carrier: carrier || "other", + tracking: trackingNumber, + status: "shipped", + }, + update: { + carrier: carrier || "other", + tracking: trackingNumber, + status: "shipped", + }, + }) + + // Update order status if all items shipped + const unshippedItems = await prisma.orderItem.count({ + where: { orderId: item.orderId, NOT: { id: orderItemId } }, + }) + + if (unshippedItems === 0) { + await prisma.order.update({ + where: { id: item.orderId }, + data: { status: "SHIPPED" }, + }) + } + } + + logger.info("Order item updated by maker", { orderItemId, makerId: maker.id, status }) + + // Generate provenance certificate when item is marked shipped + if (status === "SHIPPED" || trackingNumber) { + generateProvenanceCertificate(orderItemId) + .then(async (cert) => { + if (!cert) return + + // Upload PDF to storage + const { uploadBuffer } = await import("@/lib/storage") + const pdfUrl = await uploadBuffer( + "certificate", + cert.buffer, + `cert-${cert.hash}.pdf`, + "application/pdf" + ) + + // Update certificate with PDF URL + const certRecord = await prisma.provenanceCertificate.findFirst({ + where: { hash: cert.hash }, + }) + if (certRecord) { + await prisma.provenanceCertificate.update({ + where: { id: certRecord.id }, + data: { pdfUrl }, + }) + } + + // Email the collector + const order = await prisma.order.findUnique({ + where: { id: item.orderId }, + include: { + collector: { include: { user: { select: { email: true } } } }, + }, + }) + + if (order?.collector?.user?.email) { + const { sendEmail } = await import("@/lib/email") + const { CertificateEmail } = await import("@/emails/certificate") + const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000" + + sendEmail({ + to: order.collector.user.email, + subject: `Your Certificate of Provenance — ${cert.workTitle}`, + react: CertificateEmail({ + workTitle: cert.workTitle, + makerName: cert.makerName, + editionNumber: cert.editionNumber, + verifyUrl: `${appUrl}/verify/${cert.hash}`, + pdfUrl, + }), + }).catch(() => {}) + + logger.info("Certificate email queued", { + orderId: item.orderId, + certificationHash: cert.hash, + collectorEmail: order.collector.user.email, + }) + } + }) + .catch((err) => { + logger.error("Certificate generation failed", { orderItemId, error: String(err) }) + }) + } + + // Evaluate tier after shipping + evaluateMakerTier(maker.id).catch(() => {}) + } + + return NextResponse.json({ success: true }) +} diff --git a/src/app/api/orders/route.ts b/src/app/api/orders/route.ts new file mode 100644 index 0000000..d366f36 --- /dev/null +++ b/src/app/api/orders/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server" +import { getSession } from "@/lib/auth-utils" +import { prisma } from "@/lib/prisma" + +export async function GET() { + const session = await getSession() + if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const collector = session.user.id + ? await prisma.collector.findUnique({ where: { userId: session.user.id } }) + : null + + if (!collector) return NextResponse.json({ orders: [] }) + + const orders = await prisma.order.findMany({ + where: { collectorId: collector.id }, + include: { + items: { + include: { + variant: { + include: { + work: { + include: { + media: { orderBy: { position: "asc" }, take: 1 }, + maker: { select: { slug: true } }, + }, + }, + }, + }, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }) + + return NextResponse.json({ orders }) +} diff --git a/src/app/sign-in/sign-in-form.tsx b/src/app/sign-in/sign-in-form.tsx new file mode 100644 index 0000000..40803bb --- /dev/null +++ b/src/app/sign-in/sign-in-form.tsx @@ -0,0 +1,49 @@ +"use client" + +import { useState } from "react" +import { signIn } from "next-auth/react" + +export function SignInForm() { + const [email, setEmail] = useState("") + const [sent, setSent] = useState(false) + const [loading, setLoading] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setLoading(true) + await signIn("resend", { email, redirect: false }) + setSent(true) + setLoading(false) + } + + if (sent) { + return ( +
+

+ Check your email. We sent a magic link to{" "} + {email}. +

+
+ ) + } + + return ( +
+ setEmail(e.target.value)} + required + className="w-full border border-border bg-transparent px-4 py-3 text-base placeholder:text-muted-foreground focus:outline-none focus:border-foreground transition-colors" + /> + +
+ ) +} diff --git a/src/lib/auth-utils.ts b/src/lib/auth-utils.ts new file mode 100644 index 0000000..5c22b2c --- /dev/null +++ b/src/lib/auth-utils.ts @@ -0,0 +1,17 @@ +import { auth } from "@/lib/auth" +import { redirect } from "next/navigation" + +type Role = "GUEST" | "COLLECTOR" | "MAKER" | "CURATOR" | "ADMIN" + +export async function requireRole(...roles: Role[]) { + const session = await auth() + if (!session?.user) redirect("/sign-in") + if (roles.length > 0 && !roles.includes(session.user.role as Role)) { + redirect("/") + } + return session +} + +export async function getSession() { + return await auth() +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..d7d7086 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,31 @@ +import NextAuth from "next-auth" +import { PrismaAdapter } from "@auth/prisma-adapter" +import { prisma } from "@/lib/prisma" +import authConfig from "@/lib/auth.config" + +export const { handlers, auth, signIn, signOut } = NextAuth({ + adapter: PrismaAdapter(prisma), + session: { strategy: "jwt" }, + pages: { + signIn: "/sign-in", + error: "/auth/error", + }, + ...authConfig, + callbacks: { + ...authConfig.callbacks, + async jwt({ token, user }) { + if (user) { + token.role = user.role + token.id = user.id + } + return token + }, + async session({ session, token }) { + if (session.user) { + session.user.role = (token.role as typeof session.user.role) || "COLLECTOR" + session.user.id = (token.id as string) || (token.sub as string) || "" + } + return session + }, + }, +}) diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..26a062f --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,30 @@ +import NextAuth from "next-auth" +import authConfig from "@/lib/auth.config" + +// Middleware-compatible auth: uses JWT, no database adapter needed +const { auth: middlewareAuth } = NextAuth(authConfig) + +export default middlewareAuth((req) => { + const { nextUrl } = req + const isLoggedIn = !!req.auth + + const isDashboard = nextUrl.pathname.startsWith("/dashboard") + const isAdmin = nextUrl.pathname.startsWith("/admin") + + if ((isDashboard || isAdmin) && !isLoggedIn) { + return Response.redirect(new URL("/sign-in", nextUrl)) + } + + if (isAdmin) { + const role = req.auth?.user?.role + if (role !== "ADMIN" && role !== "CURATOR") { + return Response.redirect(new URL("/", nextUrl)) + } + } + + return null +}) + +export const config = { + matcher: ["/dashboard/:path*", "/admin/:path*", "/apply/:path*"], +}