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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/relaunch/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ Format: `YYYY-MM-DD · WP-XX · decision · why · alternatives considered`.
- 2026-10-27 · WP-08 · Quota consume = check-then-mark (not atomic) in `assertAndConsumeQuota` + `consumeEpisodeQuota` · a workspace has a single owner (§3: no teams in the MVP), so a race can only double-count the owner against themselves; the pipeline routes (WP-05/06) call these around POST /api/episodes · a transactional `INSERT … WHERE count < limit` (needs a CTE on every create for no real contention)
- 2026-10-27 · WP-08 · Billing page renders as a partial-prerender shell with the plan card streamed in `<Suspense>`; the quota reset date is computed at request time · Next 16 `cacheComponents` forbids route segment `dynamic` and blocks `new Date()` during prerender; the streaming shell keeps the instant preview · `export const instant = false` (newer, less documented escape hatch)

- 2026-10-27 · WP-06 · E2E run against an embedded Postgres (PGlite) behind `DATABASE_URL=pglite:<dir>` + `scripts/e2e-seed.mjs`; tests select it with `E2E_DB=1` (`pnpm e2e:db`), keyless suite unchanged · real U6/U11–U13 flows need a real DB; no Neon instance exists (owner blocker) · ephemeral docker postgres (no docker in CI), mocking (not e2e)
- 2026-10-27 · WP-06 · E2E auth seam: `E2E_AUTH_USER_ID` honored only when Clerk is absent AND DATABASE_URL starts with `pglite:` — it can never authenticate against a real database (WP-02's fail-closed rule preserved) · e2e needs an identity without Clerk keys · any-env preview user (weakens fail-closed)
- 2026-10-27 · WP-06 · PGlite kept as one instance per dataDir via globalThis (Turbopack loads db modules per route chunk; a second instance on the same dir aborts the wasm) and `serverExternalPackages` so its wasm loader stays unbundled; `replaceSegments` falls back to sequential statements without neon's `.batch` · observed crashes; documented PGlite+bundler guidance · per-chunk instances
- 2026-10-27 · WP-06 · `cacheComponents`/`partialPrefetching` disabled app-wide · the PPR runtime replay guard rejects `Date.now()` in streamed per-user pages (intermittent second-request failures); every page already uses `connection()` + Suspense which renders identically without it · per-route workarounds (fragile, guard fired from replayed shells)
- 2026-10-27 · WP-06 · Stored segment ids namespaced `seg_<episodeId[:8]>_<idx>` — segments.id is the GLOBAL primary key (§7.1), so per-episode `seg_000000` collided across episodes (would break the 2nd episode in production) · found by the DB-mode e2e · uuid ids (breaks §7.3 prompt-shape familiarity)
- 2026-10-27 · WP-01 · `/dev/components` ships in every build; "dev only" is enforced by noindex metadata + zero inbound links (no nav/sitemap entry) · WP-00 fixed `pnpm e2e` to test the production build via `next start`, so a NODE_ENV or env-var gate would break the axe e2e locally and in CI · gating the route on `NODE_ENV=development`; a `NEXT_PUBLIC_*` flag set in the CI e2e step
- 2026-10-27 · WP-01 · Toast = shadcn `sonner` with the Toaster pinned bottom-right + 4 s (§5.3); dropped the generated `next-themes` wiring, sonner `theme="system"` follows prefers-color-scheme like every other component · the registry has no radix `toast` for the radix style ("only available for Base UI projects" per CLI) and sonner is shadcn's current default · hand-rolled wrapper on radix Toast primitives
- 2026-10-27 · WP-01 · shadcn `--muted-foreground` maps to §5.2 `--body`, not `--faint`: faint on surface-2/3 sits at/below the 4.5:1 AA floor (4.51:1 / ~4.2:1) and muted-foreground renders on muted surfaces (dialog footers, segmented tabs); faint remains in use for text on bg/surface (4.83:1) via custom components · acceptance says fix the component mapping, not the spec tokens · mapping muted-foreground to faint and flagging the AA miss in the PR
Expand Down
3 changes: 3 additions & 0 deletions web/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,6 @@ next-env.d.ts
test-results/
playwright-report/
blob-report/

# e2e
.pglite-e2e/
48 changes: 48 additions & 0 deletions web/app/(app)/app/episodes/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// MK-5/MK-6: processing and workspace share this URL; the client view drives
// the pipeline and polls until terminal (§7.5). Dynamic per-user data is
// streamed behind Suspense (Next 16 cacheComponents).
import { Suspense } from "react";
import { connection } from "next/server";
import Link from "next/link";
import { requireUser } from "@/lib/auth";
import { getShowByOwner } from "@/db/queries";
import { handleEpisodeWithArtifactsGet } from "@/lib/pipeline/service";
import { WorkspaceView } from "@/components/workspace/workspace-view";

export default function EpisodeWorkspacePage({ params }: PageProps<"/app/episodes/[id]">) {
return (
<Suspense
fallback={
<main className="flex flex-1 items-center justify-center p-10 text-sm text-muted-foreground" data-testid="workspace-loading">
Loading your workspace…
</main>
}
>
<WorkspaceContent params={params} />
</Suspense>
);
}

async function WorkspaceContent({ params }: { params: Promise<{ id: string }> }) {
// Per-user episode data — request-time rendering.
await connection();
const userId = await requireUser();
const { id } = await params;

const result = await handleEpisodeWithArtifactsGet(userId, id);
if (result.status !== 200) {
return (
<main className="flex flex-1 flex-col items-center gap-2 p-10 text-center">
<h1 className="text-lg font-semibold">Episode not found</h1>
<Link href="/app/episodes" className="text-sm text-[var(--accent)]">
Back to episodes
</Link>
</main>
);
}

const data = result.json as unknown as import("@/lib/pipeline/client").WorkspaceData;
const show = await getShowByOwner(userId);

return <WorkspaceView initial={data} freePlan={data.freePlan} showName={show?.name ?? "Your show"} />;
}
15 changes: 15 additions & 0 deletions web/app/api/artifacts/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { getAuthUserId } from "@/lib/auth";
import { AUTH_REQUIRED_COPY } from "@/lib/copy";
import { handleArtifactPatch } from "@/lib/pipeline/service";

/** PATCH /api/artifacts/:id — {content} save · {ready} · {undo:true} (§7.5). */
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
const userId = await getAuthUserId();
if (!userId) {
return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 });
}
const { id } = await params;
const body: unknown = await req.json().catch(() => ({}));
const result = await handleArtifactPatch(userId, id, body);
return Response.json(result.json, { status: result.status });
}
14 changes: 14 additions & 0 deletions web/app/api/episodes/[id]/analyze/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { getAuthUserId } from "@/lib/auth";
import { AUTH_REQUIRED_COPY } from "@/lib/copy";
import { handleAnalyzePost } from "@/lib/pipeline/service";

/** POST /api/episodes/:id/analyze — chapters/topics/candidate quotes (§7.5). */
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const userId = await getAuthUserId();
if (!userId) {
return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 });
}
const { id } = await params;
const result = await handleAnalyzePost(userId, id);
return Response.json(result.json, { status: result.status });
}
17 changes: 17 additions & 0 deletions web/app/api/episodes/[id]/artifacts/[kind]/generate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { getAuthUserId } from "@/lib/auth";
import { AUTH_REQUIRED_COPY } from "@/lib/copy";
import { handleArtifactGeneratePost } from "@/lib/pipeline/service";

export const maxDuration = 300;

/** POST /api/episodes/:id/artifacts/:kind/generate — one artifact (§7.5). */
export async function POST(req: Request, { params }: { params: Promise<{ id: string; kind: string }> }) {
const userId = await getAuthUserId();
if (!userId) {
return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 });
}
const { id, kind } = await params;
const body: unknown = await req.json().catch(() => ({}));
const result = await handleArtifactGeneratePost(userId, id, kind, body);
return Response.json(result.json, { status: result.status });
}
12 changes: 12 additions & 0 deletions web/app/api/episodes/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getAuthUserId } from "@/lib/auth";
import { AUTH_REQUIRED_COPY } from "@/lib/copy";
import { handleEpisodeGet } from "@/lib/episodes/service";
import { handleEpisodeDelete } from "@/lib/pipeline/service";

/** GET /api/episodes/:id — episode + status, polled every 2 s (§7.5, U4). */
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
Expand All @@ -12,3 +13,14 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
const result = await handleEpisodeGet(userId, id);
return Response.json(result.json, { status: result.status });
}

/** DELETE /api/episodes/:id — edit meta handled by PATCH; delete + blob (§7.5). */
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const userId = await getAuthUserId();
if (!userId) {
return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 });
}
const { id } = await params;
const result = await handleEpisodeDelete(userId, id);
return Response.json(result.json, { status: result.status });
}
15 changes: 15 additions & 0 deletions web/app/api/episodes/[id]/workspace/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { getAuthUserId } from "@/lib/auth";
import { AUTH_REQUIRED_COPY } from "@/lib/copy";
import { handleEpisodeWithArtifactsGet } from "@/lib/pipeline/service";

/** GET /api/episodes/:id/workspace — episode + artifacts + analysis + segments
* for the workspace view; polled while not terminal (§7.5). */
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const userId = await getAuthUserId();
if (!userId) {
return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 });
}
const { id } = await params;
const result = await handleEpisodeWithArtifactsGet(userId, id);
return Response.json(result.json, { status: result.status });
}
Loading
Loading