Skip to content
Closed
59 changes: 59 additions & 0 deletions app/api/routes-f/creator-dashboard/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
31 changes: 31 additions & 0 deletions app/api/routes-f/creator-dashboard/_lib/seed.ts
Original file line number Diff line number Diff line change
@@ -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;
}
31 changes: 31 additions & 0 deletions app/api/routes-f/creator-dashboard/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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,
});
}
108 changes: 108 additions & 0 deletions app/api/routes-f/email-digest/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
100 changes: 100 additions & 0 deletions app/api/routes-f/email-digest/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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<NextResponse> {
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,
});
}
Loading
Loading