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
13 changes: 13 additions & 0 deletions docs/relaunch/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@
Agents append one entry per tie-break decision (01-product.md §0.4). Newest first.
Format: `YYYY-MM-DD · WP-XX · decision · why · alternatives considered`.

- 2026-10-27 · WP-05 · Live ASR smoke test skipped: `.env.relaunch` holds sanitized placeholders (values literally `"[SENSITIVE]"`, no `gsk_` key, ASR_PROVIDER ≠ groq) · the handoff rule says skip the live call when ASR_PROVIDER≠groq; the Groq adapter is covered by fetch-injected unit tests (request shape, error mapping, language gate) instead · running a live call with a placeholder key
- 2026-10-27 · WP-05 · Chunk manifest (path + offsetMs per ≤10-min chunk) serialized as JSON into `episodes.audio_blob_path` · §7.1 has a single text column and the server must re-run transcription idempotently after a refresh without client state (U4); a plain single-path string still parses as one chunk at 0 ms · new table/column (schema is WP-02's), body-only manifest (lost on refresh)
- 2026-10-27 · WP-05 · POST /api/episodes accepts `blobPaths[]` + parallel `chunkOffsetsMs[]` beyond the §7.5 body shape · §8.3 requires per-chunk offsets for the overlap dedupe and §7.5 pins `blobPaths[]`; parallel arrays are the smallest coherent extension · nested `chunks[]` objects in create (used only on transcribe), path-embedded offsets
- 2026-10-27 · WP-05 · Transcript format labels: `Name (00:12:03):` paren shape → "Riverside", `[00:12:03] Name:` bracket shape → "Otter", speaker-only `Name:` lines → "Descript" · the spec groups all three exports under two line shapes without disambiguating marks, so detection needs a deterministic heuristic; every M2 format name still gets its own label + fixture · one generic "speaker-timestamped" label for all three (U5 chip must say Riverside)
- 2026-10-27 · WP-05 · Paste path applies the same Latin-ratio ≥0.6 gate as ASR (`supportedInput` port) at create time → 400 `unsupported_language` with the exact U19 copy, nothing created · non-Latin transcripts would yield empty artifacts downstream (same reason `api/generate-suite.js` gates) and U19 says "wasn't charged" — no episode row means nothing to refund · letting generation fail later (WP-04's problem, worse UX)
- 2026-10-27 · WP-05 · Entitlements boundary stubbed in `lib/billing/entitlements.ts` (free=2/month, ≤60 min; pro=10, ≤2 h; ≤300 MB uploads; type allow-list) with quota counted via `countQuotaEpisodes` · WP-08 owns the real logic; intake routes need a documented seam now and must not fake success — over-quota returns 402 + §6 wall copy in tests with the count mocked · inlining free-plan constants in routes (WP-08 would have to untangle them)
- 2026-10-27 · WP-05 · GET /api/audio/:episodeId takes `?t=<ms>` and returns a signed URL for the chunk containing t + `chunkOffsetMs` so playback can seek within the chunk · §8.3 stores ≤10-min chunks (no single audio file), §7.5 promises a short-lived signed URL; signed via `issueSignedToken`+`presignUrl` (get, 10 min) — BLOB_READ_WRITE_TOKEN is not configured anywhere, so the boundary is stubbed and unit-tested with the seam mocked (PR blocker noted) · uploading a duplicate full-file audio for playback
- 2026-10-27 · WP-05 · Browser compression ports site/app.html's decode→OfflineAudioContext(16 kHz mono)→MediaRecorder pipeline but records ≤10-min chunks with a 2 s overlap at 24 kbps · §8.3 pins chunked output (~1.8 MB/chunk) for the 25 MB Groq limit; MediaRecorder encodes in real time (same as the legacy app) — the pure decisions (planChunks, bitrate, mime choice) are unit-tested in Node · WebCodecs AudioEncoder (faster-than-realtime, weak Safari support, not "ported from site/app.html")
- 2026-10-27 · WP-05 · Overlap dedupe drops chunk N's leading segments inside [offsetMs, offsetMs+2 s) only when chunk N-1 transcribed up to the seam (a segment ends ≥ seam) · blind dropping loses words when Whisper returns nothing near the previous chunk's tail; the guard makes the merge safe for real ASR output · always-drop (simpler, lossy)
- 2026-10-27 · WP-05 · Trailing chunk plan absorbs a ≤2 s stub into the previous chunk (cap becomes ≤602 s) · a 2 s upload + Groq call for a sliver of overlap is pure waste; still far under the 25 MB ASR limit · dropping the stub (loses the overlap safety on the last seam)
- 2026-10-27 · WP-05 · Quota is checked (not incremented) at POST /api/uploads/token and counted at POST /api/episodes · §7.5 asks for a quota check on the token route, but §7.5 also says create "counts quota" — the count must happen exactly once, at create; the token-route check just fails fast before a 300 MB upload · counting at token issue (double-counts on chunked uploads)
- 2026-10-27 · WP-05 · `db/queries.ts` extended additively (episodes/segments queries) instead of a new module · the handoff designates `@/db/queries` the documented DB boundary for WP-05 storage; no WP-02 function was modified · parallel `db/queries-episodes.ts` (two seams to mock everywhere)

- 2026-10-27 · WP-08 · Billing DB access extends the shared `db/queries.ts` boundary (`updateWorkspacePlan`, `getWorkspaceById`, `getWorkspaceByOwnerEmail`, `countFoundingWorkspaces`, `countQuotaEpisodes`, `markEpisodeQuotaCounted`, `refundEpisodeQuota`, `getUserEmail`) instead of a private `lib/billing/store` · `@/db/queries` is the documented boundary every service mocks/tests against; a parallel store module would fork DB conventions · separate `lib/billing/store.ts` (self-contained but two DB paths)
- 2026-10-27 · WP-08 · Webhook idempotency key = sha256 of the raw body in `webhook_events` · Lemon Squeezy delivers no unique event id (only `meta.event_name`), and LS redeliveries are byte-identical, so the hash is a stable dedupe key; verification candidates are normalized before hashing · subscription-id+updated_at composite (collides across event types); ignoring idempotency (duplicate plan writes are harmless but webhook_events exists for this)
- 2026-10-27 · WP-08 · Cancel keeps Pro: `subscription_cancelled` sets `status=cancelled` + `current_period_end=ends_at` with plan unchanged; `effectivePlan()` derives the downgrade at read time; `subscription_expired` sets plan=free · matches LS's grace-period model and U15 "see access end date" with no cron (§0.2 has no queue infra); the DB row keeps the truth the UI shows · a scheduled downgrade job (no scheduler in the MVP)
Expand Down
8 changes: 8 additions & 0 deletions web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ CLERK_WEBHOOK_SIGNING_SECRET=
# NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
# NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up

# --- WP-05 (intake + ASR) ---
# Vercel Blob read-write token (Vercel → Storage → Blob). NOT configured yet —
# owner blocker: audio upload and playback answer 503 until it is set.
BLOB_READ_WRITE_TOKEN=

# Groq (ASR + fallback LLM). ASR adapter uses GROQ_API_KEY (or ASR_API_KEY).
GROQ_API_KEY=

# ── Lemon Squeezy (WP-08 billing; §9). Owner blocker until the store is live
# (docs/FOR-ABID.md §1) — everything degrades to preview copy when unset.
# API key (dashboard.lemonsqueezy.com → Settings → API keys). Server only.
Expand Down
5 changes: 4 additions & 1 deletion web/app/(app)/app/episodes/new/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Suspense } from "react";
import { EpisodeIntakeShell } from "@/components/app/episode-intake-shell";

export const metadata = { title: "New episode · EpisodeKit" };
Expand All @@ -6,7 +7,9 @@ export default function NewEpisodePage() {
return (
<div className="flex flex-col gap-6">
<h1 className="text-xl font-semibold">New episode</h1>
<EpisodeIntakeShell />
<Suspense fallback={<div className="h-64 animate-pulse rounded-lg bg-neutral-100" />}>
<EpisodeIntakeShell />
</Suspense>
</div>
);
}
5 changes: 4 additions & 1 deletion web/app/(app)/app/onboarding/first/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Suspense } from "react";
import Link from "next/link";
import { EpisodeIntakeShell } from "@/components/app/episode-intake-shell";
import { StepDots } from "@/components/app/step-dots";
Expand All @@ -15,7 +16,9 @@ export default function OnboardingFirstPage() {
You can do this later from Episodes.
</p>
<div className="mt-6">
<EpisodeIntakeShell />
<Suspense fallback={<div className="h-64 animate-pulse rounded-lg bg-neutral-100" />}>
<EpisodeIntakeShell />
</Suspense>
</div>
<div className="mt-6 text-right">
<Link href="/app" className="text-sm underline" data-testid="skip-first">
Expand Down
16 changes: 16 additions & 0 deletions web/app/api/audio/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { getAuthUserId } from "@/lib/auth";
import { AUTH_REQUIRED_COPY } from "@/lib/copy";
import { handleAudioGet } from "@/lib/episodes/service";

/** GET /api/audio/:episodeId — short-lived signed URL for playback (§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 tParam = new URL(req.url).searchParams.get("t");
const tMs = tParam !== null && /^\d+$/.test(tParam) ? Number(tParam) : 0;
const result = await handleAudioGet(userId, id, tMs);
return Response.json(result.json, { status: result.status });
}
14 changes: 14 additions & 0 deletions web/app/api/episodes/[id]/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 { handleEpisodeGet } from "@/lib/episodes/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 }> }) {
const userId = await getAuthUserId();
if (!userId) {
return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 });
}
const { id } = await params;
const result = await handleEpisodeGet(userId, id);
return Response.json(result.json, { status: result.status });
}
19 changes: 19 additions & 0 deletions web/app/api/episodes/[id]/transcribe/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { getAuthUserId } from "@/lib/auth";
import { AUTH_REQUIRED_COPY } from "@/lib/copy";
import { handleTranscribePost } from "@/lib/episodes/service";

// Groq chunked transcription of a 2 h episode must not hit the function
// timeout (§7.5: maxDuration 300).
export const maxDuration = 300;

/** POST /api/episodes/:id/transcribe — upload→queued→transcribing→queued (§8.3). */
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 body: unknown = await req.json().catch(() => ({}));
const result = await handleTranscribePost(userId, id, body);
return Response.json(result.json, { status: result.status });
}
14 changes: 14 additions & 0 deletions web/app/api/episodes/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 { handleEpisodesPost } from "@/lib/episodes/service";

/** POST /api/episodes — create an episode from upload/paste/sample (§7.5). */
export async function POST(req: Request) {
const userId = await getAuthUserId();
if (!userId) {
return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 });
}
const body: unknown = await req.json().catch(() => null);
const result = await handleEpisodesPost(userId, body);
return Response.json(result.json, { status: result.status });
}
13 changes: 13 additions & 0 deletions web/app/api/uploads/token/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { getAuthUserId } from "@/lib/auth";
import { AUTH_REQUIRED_COPY } from "@/lib/copy";
import { handleUploadsTokenPost } from "@/lib/episodes/service";

/** POST /api/uploads/token — @vercel/blob client-upload token (§7.5). */
export async function POST(req: Request) {
const userId = await getAuthUserId();
if (!userId) {
return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 });
}
const result = await handleUploadsTokenPost(userId, req);
return Response.json(result.json, { status: result.status });
}
Loading
Loading