diff --git a/app/api/routes-f/creator-dashboard/__tests__/route.test.ts b/app/api/routes-f/creator-dashboard/__tests__/route.test.ts new file mode 100644 index 00000000..7dfe5c8d --- /dev/null +++ b/app/api/routes-f/creator-dashboard/__tests__/route.test.ts @@ -0,0 +1,59 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +import { GET } from "../route"; + +const makeRequest = (search: string): import("next/server").NextRequest => + new Request( + `http://localhost/api/routes-f/creator-dashboard${search}` + ) as unknown as import("next/server").NextRequest; + +describe("GET /api/routes-f/creator-dashboard — validation", () => { + it("returns 400 when creator_id is missing", async () => { + const res = await GET(makeRequest("")); + expect(res.status).toBe(400); + }); +}); + +describe("GET /api/routes-f/creator-dashboard — not found", () => { + it("returns 404 for unknown creator", async () => { + const res = await GET(makeRequest("?creator_id=unknown_creator")); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toBe("Creator not found"); + }); +}); + +describe("GET /api/routes-f/creator-dashboard — aggregations", () => { + it("returns correct stats for creator_001", async () => { + const res = await GET(makeRequest("?creator_id=creator_001")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.creator_id).toBe("creator_001"); + expect(body.follower_count).toBe(12500); + expect(body.active_subs).toBe(318); + expect(body.last_stream_at).toBe("2026-06-20T18:00:00.000Z"); + }); + + it("rounds currency values to 2 decimal places", async () => { + const res = await GET(makeRequest("?creator_id=creator_001")); + const body = await res.json(); + expect(body.monthly_recurring_revenue_usdc).toBe(1847.5); + expect(body.total_tips_lifetime_usdc).toBe(9234.75); + }); + + it("returns null last_stream_at for creator with no streams", async () => { + const res = await GET(makeRequest("?creator_id=creator_002")); + const body = await res.json(); + expect(body.last_stream_at).toBeNull(); + expect(body.follower_count).toBe(340); + expect(body.active_subs).toBe(8); + }); +}); \ No newline at end of file diff --git a/app/api/routes-f/creator-dashboard/_lib/seed.ts b/app/api/routes-f/creator-dashboard/_lib/seed.ts new file mode 100644 index 00000000..13ffcfa8 --- /dev/null +++ b/app/api/routes-f/creator-dashboard/_lib/seed.ts @@ -0,0 +1,31 @@ +export type CreatorStats = { + creator_id: string; + follower_count: number; + monthly_recurring_revenue_usdc: number; + last_stream_at: string | null; + total_tips_lifetime_usdc: number; + active_subs: number; +}; + +const SEED: CreatorStats[] = [ + { + creator_id: "creator_001", + follower_count: 12500, + monthly_recurring_revenue_usdc: 1847.5, + last_stream_at: "2026-06-20T18:00:00.000Z", + total_tips_lifetime_usdc: 9234.75, + active_subs: 318, + }, + { + creator_id: "creator_002", + follower_count: 340, + monthly_recurring_revenue_usdc: 49.99, + last_stream_at: null, + total_tips_lifetime_usdc: 12.5, + active_subs: 8, + }, +]; + +export function getSeedCreatorStats(creatorId: string): CreatorStats | null { + return SEED.find((s) => s.creator_id === creatorId) ?? null; +} \ No newline at end of file diff --git a/app/api/routes-f/creator-dashboard/route.ts b/app/api/routes-f/creator-dashboard/route.ts new file mode 100644 index 00000000..d091b95a --- /dev/null +++ b/app/api/routes-f/creator-dashboard/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { getSeedCreatorStats } from "./_lib/seed"; + +const querySchema = z.object({ + creator_id: z.string().min(1), +}); + +export async function GET(req: NextRequest): Promise { + const { searchParams } = new URL(req.url); + const queryResult = validateQuery(searchParams, querySchema); + if (queryResult instanceof NextResponse) return queryResult; + + const { creator_id } = queryResult.data; + const stats = getSeedCreatorStats(creator_id); + if (!stats) { + return NextResponse.json({ error: "Creator not found" }, { status: 404 }); + } + + return NextResponse.json({ + creator_id: stats.creator_id, + follower_count: stats.follower_count, + monthly_recurring_revenue_usdc: + Math.round(stats.monthly_recurring_revenue_usdc * 100) / 100, + last_stream_at: stats.last_stream_at, + total_tips_lifetime_usdc: + Math.round(stats.total_tips_lifetime_usdc * 100) / 100, + active_subs: stats.active_subs, + }); +} \ No newline at end of file diff --git a/app/api/routes-f/email-digest/__tests__/route.test.ts b/app/api/routes-f/email-digest/__tests__/route.test.ts new file mode 100644 index 00000000..c27c3b89 --- /dev/null +++ b/app/api/routes-f/email-digest/__tests__/route.test.ts @@ -0,0 +1,108 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +import { GET, PUT, __resetEmailDigest } from "../route"; + +const makeGetRequest = (search: string): import("next/server").NextRequest => + new Request( + `http://localhost/api/routes-f/email-digest${search}` + ) as unknown as import("next/server").NextRequest; + +const makePutRequest = (body: unknown): import("next/server").NextRequest => + new Request("http://localhost/api/routes-f/email-digest", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) as unknown as import("next/server").NextRequest; + +beforeEach(() => { + __resetEmailDigest(); +}); + +describe("GET /api/routes-f/email-digest — validation", () => { + it("returns 400 when viewer_id is missing", async () => { + const res = await GET(makeGetRequest("")); + expect(res.status).toBe(400); + }); + + it("returns 404 for unknown viewer", async () => { + const res = await GET(makeGetRequest("?viewer_id=nobody")); + expect(res.status).toBe(404); + }); +}); + +describe("GET /api/routes-f/email-digest — read preferences", () => { + it("returns enabled digest preferences for viewer_001", async () => { + const res = await GET(makeGetRequest("?viewer_id=viewer_001")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.enabled).toBe(true); + expect(body.day_of_week).toBe(1); + expect(body.sections).toEqual(["live_alerts", "new_clips"]); + }); + + it("returns disabled digest for viewer_002", async () => { + const res = await GET(makeGetRequest("?viewer_id=viewer_002")); + const body = await res.json(); + expect(body.enabled).toBe(false); + expect(body.sections).toEqual([]); + }); +}); + +describe("PUT /api/routes-f/email-digest — toggle and section selection", () => { + it("toggles enabled to false", async () => { + const res = await PUT( + makePutRequest({ viewer_id: "viewer_001", enabled: false }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.enabled).toBe(false); + }); + + it("updates day_of_week", async () => { + const res = await PUT( + makePutRequest({ viewer_id: "viewer_001", day_of_week: 3 }) + ); + const body = await res.json(); + expect(body.day_of_week).toBe(3); + }); + + it("updates sections selection", async () => { + const res = await PUT( + makePutRequest({ + viewer_id: "viewer_002", + sections: ["tip_summary", "recommendations"], + }) + ); + const body = await res.json(); + expect(body.sections).toEqual(["tip_summary", "recommendations"]); + }); + + it("returns 400 for invalid section value", async () => { + const res = await PUT( + makePutRequest({ viewer_id: "viewer_001", sections: ["invalid_section"] }) + ); + expect(res.status).toBe(400); + }); + + it("persists changes visible on subsequent GET", async () => { + await PUT( + makePutRequest({ + viewer_id: "viewer_001", + enabled: false, + sections: ["tip_summary"], + }) + ); + const res = await GET(makeGetRequest("?viewer_id=viewer_001")); + const body = await res.json(); + expect(body.enabled).toBe(false); + expect(body.sections).toEqual(["tip_summary"]); + }); +}); \ No newline at end of file diff --git a/app/api/routes-f/email-digest/route.ts b/app/api/routes-f/email-digest/route.ts new file mode 100644 index 00000000..09f40f66 --- /dev/null +++ b/app/api/routes-f/email-digest/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateQuery, validateBody } from "@/app/api/routes-f/_lib/validate"; + +const VALID_SECTIONS = [ + "live_alerts", + "new_clips", + "tip_summary", + "recommendations", +] as const; +type Section = (typeof VALID_SECTIONS)[number]; + +type DigestPreference = { + viewer_id: string; + enabled: boolean; + day_of_week: number; + sections: Section[]; +}; + +let store: DigestPreference[] = [ + { + viewer_id: "viewer_001", + enabled: true, + day_of_week: 1, + sections: ["live_alerts", "new_clips"], + }, + { + viewer_id: "viewer_002", + enabled: false, + day_of_week: 5, + sections: [], + }, +]; + +export function __resetEmailDigest(): void { + store = [ + { + viewer_id: "viewer_001", + enabled: true, + day_of_week: 1, + sections: ["live_alerts", "new_clips"], + }, + { + viewer_id: "viewer_002", + enabled: false, + day_of_week: 5, + sections: [], + }, + ]; +} + +const querySchema = z.object({ + viewer_id: z.string().min(1), +}); + +const putBodySchema = z.object({ + viewer_id: z.string().min(1), + enabled: z.boolean().optional(), + day_of_week: z.number().int().min(0).max(6).optional(), + sections: z.array(z.enum(VALID_SECTIONS)).optional(), +}); + +export async function GET(req: NextRequest): Promise { + const { searchParams } = new URL(req.url); + const queryResult = validateQuery(searchParams, querySchema); + if (queryResult instanceof NextResponse) return queryResult; + + const { viewer_id } = queryResult.data; + const prefs = store.find((p) => p.viewer_id === viewer_id); + if (!prefs) { + return NextResponse.json({ error: "Viewer not found" }, { status: 404 }); + } + + return NextResponse.json({ + enabled: prefs.enabled, + day_of_week: prefs.day_of_week, + sections: prefs.sections, + }); +} + +export async function PUT(req: NextRequest): Promise { + const bodyResult = await validateBody(req, putBodySchema); + if (bodyResult instanceof NextResponse) return bodyResult; + + const { viewer_id, enabled, day_of_week, sections } = bodyResult.data; + const prefs = store.find((p) => p.viewer_id === viewer_id); + if (!prefs) { + return NextResponse.json({ error: "Viewer not found" }, { status: 404 }); + } + + if (enabled !== undefined) prefs.enabled = enabled; + if (day_of_week !== undefined) prefs.day_of_week = day_of_week; + if (sections !== undefined) prefs.sections = sections; + + return NextResponse.json({ + enabled: prefs.enabled, + day_of_week: prefs.day_of_week, + sections: prefs.sections, + }); +} \ No newline at end of file diff --git a/app/api/routes-f/notifications-read/__tests__/route.test.ts b/app/api/routes-f/notifications-read/__tests__/route.test.ts new file mode 100644 index 00000000..29159c56 --- /dev/null +++ b/app/api/routes-f/notifications-read/__tests__/route.test.ts @@ -0,0 +1,66 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +import { POST, __resetNotificationsRead } from "../route"; + +const makeRequest = (body: unknown): import("next/server").NextRequest => + new Request("http://localhost/api/routes-f/notifications-read", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) as unknown as import("next/server").NextRequest; + +beforeEach(() => { + __resetNotificationsRead(); +}); + +describe("POST /api/routes-f/notifications-read — validation", () => { + it("returns 400 when viewer_id is missing", async () => { + const res = await POST(makeRequest({})); + expect(res.status).toBe(400); + }); +}); + +describe("POST /api/routes-f/notifications-read — mark read", () => { + it("marks a single notification read", async () => { + const res = await POST(makeRequest({ viewer_id: "viewer_001", ids: ["n_001"] })); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.updated_count).toBe(1); + }); + + it("marks multiple notifications read", async () => { + const res = await POST( + makeRequest({ viewer_id: "viewer_001", ids: ["n_001", "n_002"] }) + ); + const body = await res.json(); + expect(body.updated_count).toBe(2); + }); + + it("marks all notifications read when all=true", async () => { + const res = await POST(makeRequest({ viewer_id: "viewer_001", all: true })); + const body = await res.json(); + expect(body.updated_count).toBe(3); + }); + + it("does not count already-read notifications", async () => { + const res = await POST( + makeRequest({ viewer_id: "viewer_001", ids: ["n_003"] }) + ); + const body = await res.json(); + expect(body.updated_count).toBe(0); + }); + + it("only marks notifications belonging to the given viewer_id", async () => { + const res = await POST(makeRequest({ viewer_id: "viewer_002", all: true })); + const body = await res.json(); + expect(body.updated_count).toBe(2); + }); +}); \ No newline at end of file diff --git a/app/api/routes-f/notifications-read/route.ts b/app/api/routes-f/notifications-read/route.ts new file mode 100644 index 00000000..df4b8c21 --- /dev/null +++ b/app/api/routes-f/notifications-read/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +type NotificationRecord = { + id: string; + viewer_id: string; + read: boolean; +}; + +let notifications: NotificationRecord[] = [ + { id: "n_001", viewer_id: "viewer_001", read: false }, + { id: "n_002", viewer_id: "viewer_001", read: false }, + { id: "n_003", viewer_id: "viewer_001", read: true }, + { id: "n_004", viewer_id: "viewer_001", read: false }, + { id: "n_101", viewer_id: "viewer_002", read: false }, + { id: "n_102", viewer_id: "viewer_002", read: false }, +]; + +export function __resetNotificationsRead(): void { + notifications = [ + { id: "n_001", viewer_id: "viewer_001", read: false }, + { id: "n_002", viewer_id: "viewer_001", read: false }, + { id: "n_003", viewer_id: "viewer_001", read: true }, + { id: "n_004", viewer_id: "viewer_001", read: false }, + { id: "n_101", viewer_id: "viewer_002", read: false }, + { id: "n_102", viewer_id: "viewer_002", read: false }, + ]; +} + +const bodySchema = z.object({ + viewer_id: z.string().min(1), + ids: z.array(z.string()).optional(), + all: z.boolean().optional(), +}); + +export async function POST(req: NextRequest): Promise { + const bodyResult = await validateBody(req, bodySchema); + if (bodyResult instanceof NextResponse) return bodyResult; + + const { viewer_id, ids, all } = bodyResult.data; + + let updated_count = 0; + + for (const n of notifications) { + if (n.viewer_id !== viewer_id) continue; + if (n.read) continue; + if (all || (ids && ids.includes(n.id))) { + n.read = true; + updated_count++; + } + } + + return NextResponse.json({ updated_count }); +} \ No newline at end of file diff --git a/app/api/routes-f/viewer-retention/__tests__/route.test.ts b/app/api/routes-f/viewer-retention/__tests__/route.test.ts new file mode 100644 index 00000000..bb8779e4 --- /dev/null +++ b/app/api/routes-f/viewer-retention/__tests__/route.test.ts @@ -0,0 +1,70 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +import { GET } from "../route"; + +const makeRequest = (search: string): import("next/server").NextRequest => + new Request( + `http://localhost/api/routes-f/viewer-retention${search}` + ) as unknown as import("next/server").NextRequest; + +describe("GET /api/routes-f/viewer-retention — validation", () => { + it("returns 400 when stream_id is missing", async () => { + const res = await GET(makeRequest("")); + expect(res.status).toBe(400); + }); +}); + +describe("GET /api/routes-f/viewer-retention — not found", () => { + it("returns 404 for unknown stream", async () => { + const res = await GET(makeRequest("?stream_id=unknown")); + expect(res.status).toBe(404); + }); +}); + +describe("GET /api/routes-f/viewer-retention — retention curve", () => { + it("returns points array with correct shape", async () => { + const res = await GET(makeRequest("?stream_id=stream_001")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body.points)).toBe(true); + expect(body.points).toHaveLength(5); + const first = body.points[0]; + expect(first).toHaveProperty("minute"); + expect(first).toHaveProperty("percent_of_peak"); + expect(first).toHaveProperty("viewer_count"); + }); + + it("normalizes peak minute to 100%", async () => { + const res = await GET(makeRequest("?stream_id=stream_001")); + const body = await res.json(); + const maxPercent = Math.max( + ...body.points.map((p: { percent_of_peak: number }) => p.percent_of_peak) + ); + expect(maxPercent).toBe(100); + }); + + it("shows strong drop-off for stream_001", async () => { + const res = await GET(makeRequest("?stream_id=stream_001")); + const body = await res.json(); + const last = body.points[body.points.length - 1]; + expect(last.percent_of_peak).toBeLessThan(20); + }); + + it("shows steady audience for stream_002", async () => { + const res = await GET(makeRequest("?stream_id=stream_002")); + const body = await res.json(); + const percents = body.points.map( + (p: { percent_of_peak: number }) => p.percent_of_peak + ); + const min = Math.min(...percents); + expect(min).toBeGreaterThanOrEqual(90); + }); +}); \ No newline at end of file diff --git a/app/api/routes-f/viewer-retention/_lib/seed.ts b/app/api/routes-f/viewer-retention/_lib/seed.ts new file mode 100644 index 00000000..08c88d15 --- /dev/null +++ b/app/api/routes-f/viewer-retention/_lib/seed.ts @@ -0,0 +1,36 @@ +export type ViewerSample = { + minute: number; + viewer_count: number; +}; + +export type StreamViewerData = { + stream_id: string; + samples: ViewerSample[]; +}; + +const SEED: StreamViewerData[] = [ + { + stream_id: "stream_001", + samples: [ + { minute: 0, viewer_count: 1000 }, + { minute: 5, viewer_count: 800 }, + { minute: 10, viewer_count: 500 }, + { minute: 15, viewer_count: 200 }, + { minute: 20, viewer_count: 100 }, + ], + }, + { + stream_id: "stream_002", + samples: [ + { minute: 0, viewer_count: 400 }, + { minute: 5, viewer_count: 395 }, + { minute: 10, viewer_count: 410 }, + { minute: 15, viewer_count: 390 }, + { minute: 20, viewer_count: 405 }, + ], + }, +]; + +export function getSeedStreamData(streamId: string): StreamViewerData | null { + return SEED.find((s) => s.stream_id === streamId) ?? null; +} \ No newline at end of file diff --git a/app/api/routes-f/viewer-retention/route.ts b/app/api/routes-f/viewer-retention/route.ts new file mode 100644 index 00000000..79ac9bd1 --- /dev/null +++ b/app/api/routes-f/viewer-retention/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { getSeedStreamData } from "./_lib/seed"; + +const querySchema = z.object({ + stream_id: z.string().min(1), +}); + +export async function GET(req: NextRequest): Promise { + const { searchParams } = new URL(req.url); + const queryResult = validateQuery(searchParams, querySchema); + if (queryResult instanceof NextResponse) return queryResult; + + const { stream_id } = queryResult.data; + const data = getSeedStreamData(stream_id); + if (!data) { + return NextResponse.json({ error: "Stream not found" }, { status: 404 }); + } + + const peak = Math.max(...data.samples.map((s) => s.viewer_count)); + const points = data.samples.map((s) => ({ + minute: s.minute, + percent_of_peak: peak > 0 ? Math.round((s.viewer_count / peak) * 100) : 0, + viewer_count: s.viewer_count, + })); + + return NextResponse.json({ points }); +} \ No newline at end of file