Skip to content

Commit b50b418

Browse files
authored
Merge pull request #505 from HasToBeJames/codex/427-429-431-444-routes-f-endpoints
feat: add routes-f highlights, invites, templates, and analytics endpoints
2 parents 5b40d19 + 58be048 commit b50b418

19 files changed

Lines changed: 1647 additions & 16 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
jest.mock("next/server", () => ({
2+
NextResponse: {
3+
json: (body: unknown, init?: ResponseInit) =>
4+
new Response(JSON.stringify(body), {
5+
...init,
6+
headers: { "Content-Type": "application/json" },
7+
}),
8+
},
9+
}));
10+
11+
jest.mock("@vercel/postgres", () => ({ sql: jest.fn() }));
12+
13+
jest.mock("@/lib/auth/verify-session", () => ({
14+
verifySession: jest.fn(),
15+
}));
16+
17+
jest.mock("../_lib/db", () => ({
18+
ensureAnalyticsSchema: jest.fn().mockResolvedValue(undefined),
19+
}));
20+
21+
import { sql } from "@vercel/postgres";
22+
import { verifySession } from "@/lib/auth/verify-session";
23+
import { GET, POST } from "../route";
24+
25+
const sqlMock = sql as unknown as jest.Mock;
26+
const verifySessionMock = verifySession as jest.Mock;
27+
const STREAM_ID = "550e8400-e29b-41d4-a716-446655440000";
28+
29+
function makeRequest(method: string, path: string, body?: object) {
30+
return new Request(`http://localhost${path}`, {
31+
method,
32+
headers: body ? { "Content-Type": "application/json" } : undefined,
33+
body: body ? JSON.stringify(body) : undefined,
34+
}) as unknown as import("next/server").NextRequest;
35+
}
36+
37+
describe("routes-f analytics", () => {
38+
beforeEach(() => {
39+
jest.clearAllMocks();
40+
jest.spyOn(console, "error").mockImplementation(() => {});
41+
});
42+
43+
afterEach(() => {
44+
jest.restoreAllMocks();
45+
});
46+
47+
it("returns 401 for unauthenticated requests", async () => {
48+
verifySessionMock.mockResolvedValue({
49+
ok: false,
50+
response: new Response(JSON.stringify({ error: "Unauthorized" }), {
51+
status: 401,
52+
headers: { "Content-Type": "application/json" },
53+
}),
54+
});
55+
56+
const res = await GET(makeRequest("GET", "/api/routes-f/analytics"));
57+
58+
expect(res.status).toBe(401);
59+
});
60+
61+
it("returns aggregated watch analytics", async () => {
62+
verifySessionMock.mockResolvedValue({
63+
ok: true,
64+
userId: "viewer-id",
65+
wallet: null,
66+
privyId: "did:privy:abc",
67+
username: "viewer",
68+
email: "viewer@example.com",
69+
});
70+
71+
sqlMock
72+
.mockResolvedValueOnce({
73+
rows: [{ total_watch_time: 3600, sessions_count: 4 }],
74+
})
75+
.mockResolvedValueOnce({
76+
rows: [{ category: "Tech", watch_time: 1800, sessions: 2 }],
77+
})
78+
.mockResolvedValueOnce({
79+
rows: [
80+
{
81+
stream_id: STREAM_ID,
82+
username: "alice",
83+
avatar: null,
84+
watch_time: 1800,
85+
sessions: 2,
86+
},
87+
],
88+
})
89+
.mockResolvedValueOnce({
90+
rows: [{ bucket: "2026-03-28", watch_time: 1200, sessions: 1 }],
91+
})
92+
.mockResolvedValueOnce({
93+
rows: [{ bucket: "2026-03-24", watch_time: 2400, sessions: 3 }],
94+
})
95+
.mockResolvedValueOnce({
96+
rows: [{ bucket: "2026-03", watch_time: 3600, sessions: 4 }],
97+
});
98+
99+
const res = await GET(makeRequest("GET", "/api/routes-f/analytics"));
100+
const json = await res.json();
101+
102+
expect(res.status).toBe(200);
103+
expect(json.total_watch_time).toBe(3600);
104+
expect(json.top_streams).toHaveLength(1);
105+
});
106+
107+
it("records a watch event", async () => {
108+
verifySessionMock.mockResolvedValue({
109+
ok: true,
110+
userId: "viewer-id",
111+
wallet: null,
112+
privyId: "did:privy:abc",
113+
username: "viewer",
114+
email: "viewer@example.com",
115+
});
116+
117+
sqlMock
118+
.mockResolvedValueOnce({ rows: [{ id: STREAM_ID }] })
119+
.mockResolvedValueOnce({
120+
rows: [
121+
{
122+
id: "event-id",
123+
user_id: "viewer-id",
124+
stream_id: STREAM_ID,
125+
duration_seconds: 120,
126+
category: "Tech",
127+
watched_at: "2026-03-28T00:00:00Z",
128+
},
129+
],
130+
});
131+
132+
const res = await POST(
133+
makeRequest("POST", "/api/routes-f/analytics", {
134+
stream_id: STREAM_ID,
135+
duration_seconds: 120,
136+
category: "Tech",
137+
})
138+
);
139+
140+
expect(res.status).toBe(201);
141+
});
142+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { sql } from "@vercel/postgres";
2+
3+
export async function ensureAnalyticsSchema(): Promise<void> {
4+
await sql`
5+
CREATE TABLE IF NOT EXISTS route_f_watch_events (
6+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
7+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
8+
stream_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
9+
duration_seconds INTEGER NOT NULL,
10+
category VARCHAR(80) NOT NULL,
11+
watched_at TIMESTAMPTZ NOT NULL DEFAULT now()
12+
)
13+
`;
14+
15+
await sql`
16+
CREATE INDEX IF NOT EXISTS idx_route_f_watch_events_user_time
17+
ON route_f_watch_events (user_id, watched_at DESC)
18+
`;
19+
20+
await sql`
21+
CREATE INDEX IF NOT EXISTS idx_route_f_watch_events_stream_time
22+
ON route_f_watch_events (stream_id, watched_at DESC)
23+
`;
24+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { sql } from "@vercel/postgres";
3+
import { z } from "zod";
4+
import { verifySession } from "@/lib/auth/verify-session";
5+
import { uuidSchema } from "@/app/api/routes-f/_lib/schemas";
6+
import { validateBody } from "@/app/api/routes-f/_lib/validate";
7+
import { ensureAnalyticsSchema } from "./_lib/db";
8+
9+
const createWatchEventSchema = z.object({
10+
stream_id: uuidSchema,
11+
duration_seconds: z.number().int().min(1).max(86400),
12+
category: z.string().trim().min(1).max(80),
13+
});
14+
15+
export async function GET(req: NextRequest): Promise<NextResponse> {
16+
const session = await verifySession(req);
17+
if (!session.ok) {
18+
return session.response;
19+
}
20+
21+
try {
22+
await ensureAnalyticsSchema();
23+
24+
const [
25+
summaryResult,
26+
topCategoriesResult,
27+
topStreamsResult,
28+
dailyResult,
29+
weeklyResult,
30+
monthlyResult,
31+
] = await Promise.all([
32+
sql`
33+
SELECT
34+
COALESCE(SUM(duration_seconds), 0)::int AS total_watch_time,
35+
COUNT(*)::int AS sessions_count
36+
FROM route_f_watch_events
37+
WHERE user_id = ${session.userId}
38+
`,
39+
sql`
40+
SELECT
41+
category,
42+
COALESCE(SUM(duration_seconds), 0)::int AS watch_time,
43+
COUNT(*)::int AS sessions
44+
FROM route_f_watch_events
45+
WHERE user_id = ${session.userId}
46+
GROUP BY category
47+
ORDER BY watch_time DESC, sessions DESC, category ASC
48+
LIMIT 5
49+
`,
50+
sql`
51+
SELECT
52+
e.stream_id,
53+
u.username,
54+
u.avatar,
55+
COALESCE(SUM(e.duration_seconds), 0)::int AS watch_time,
56+
COUNT(*)::int AS sessions
57+
FROM route_f_watch_events e
58+
JOIN users u ON u.id = e.stream_id
59+
WHERE e.user_id = ${session.userId}
60+
GROUP BY e.stream_id, u.username, u.avatar
61+
ORDER BY watch_time DESC, sessions DESC, u.username ASC
62+
LIMIT 5
63+
`,
64+
sql`
65+
SELECT
66+
TO_CHAR(date_trunc('day', watched_at), 'YYYY-MM-DD') AS bucket,
67+
COALESCE(SUM(duration_seconds), 0)::int AS watch_time,
68+
COUNT(*)::int AS sessions
69+
FROM route_f_watch_events
70+
WHERE user_id = ${session.userId}
71+
GROUP BY date_trunc('day', watched_at)
72+
ORDER BY date_trunc('day', watched_at) DESC
73+
LIMIT 30
74+
`,
75+
sql`
76+
SELECT
77+
TO_CHAR(date_trunc('week', watched_at), 'YYYY-MM-DD') AS bucket,
78+
COALESCE(SUM(duration_seconds), 0)::int AS watch_time,
79+
COUNT(*)::int AS sessions
80+
FROM route_f_watch_events
81+
WHERE user_id = ${session.userId}
82+
GROUP BY date_trunc('week', watched_at)
83+
ORDER BY date_trunc('week', watched_at) DESC
84+
LIMIT 12
85+
`,
86+
sql`
87+
SELECT
88+
TO_CHAR(date_trunc('month', watched_at), 'YYYY-MM') AS bucket,
89+
COALESCE(SUM(duration_seconds), 0)::int AS watch_time,
90+
COUNT(*)::int AS sessions
91+
FROM route_f_watch_events
92+
WHERE user_id = ${session.userId}
93+
GROUP BY date_trunc('month', watched_at)
94+
ORDER BY date_trunc('month', watched_at) DESC
95+
LIMIT 12
96+
`,
97+
]);
98+
99+
const summary = summaryResult.rows[0] ?? {
100+
total_watch_time: 0,
101+
sessions_count: 0,
102+
};
103+
104+
return NextResponse.json({
105+
total_watch_time: summary.total_watch_time,
106+
sessions_count: summary.sessions_count,
107+
top_categories: topCategoriesResult.rows,
108+
top_streams: topStreamsResult.rows,
109+
watch_time_by_period: {
110+
day: dailyResult.rows,
111+
week: weeklyResult.rows,
112+
month: monthlyResult.rows,
113+
},
114+
});
115+
} catch (error) {
116+
console.error("[analytics] GET error:", error);
117+
return NextResponse.json(
118+
{ error: "Internal server error" },
119+
{ status: 500 }
120+
);
121+
}
122+
}
123+
124+
export async function POST(req: NextRequest): Promise<NextResponse> {
125+
const session = await verifySession(req);
126+
if (!session.ok) {
127+
return session.response;
128+
}
129+
130+
const bodyResult = await validateBody(req, createWatchEventSchema);
131+
if (bodyResult instanceof Response) {
132+
return bodyResult;
133+
}
134+
135+
const { stream_id, duration_seconds, category } = bodyResult.data;
136+
137+
try {
138+
await ensureAnalyticsSchema();
139+
140+
const { rows: streamRows } = await sql`
141+
SELECT id FROM users WHERE id = ${stream_id} LIMIT 1
142+
`;
143+
144+
if (streamRows.length === 0) {
145+
return NextResponse.json({ error: "Stream not found" }, { status: 404 });
146+
}
147+
148+
const { rows } = await sql`
149+
INSERT INTO route_f_watch_events (
150+
user_id,
151+
stream_id,
152+
duration_seconds,
153+
category
154+
)
155+
VALUES (
156+
${session.userId},
157+
${stream_id},
158+
${duration_seconds},
159+
${category}
160+
)
161+
RETURNING id, user_id, stream_id, duration_seconds, category, watched_at
162+
`;
163+
164+
return NextResponse.json(rows[0], { status: 201 });
165+
} catch (error) {
166+
console.error("[analytics] POST error:", error);
167+
return NextResponse.json(
168+
{ error: "Internal server error" },
169+
{ status: 500 }
170+
);
171+
}
172+
}

app/api/routes-f/announcements/[id]/route.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,33 +8,43 @@ import { ensureRoutesFSchema } from "../../_lib/schema";
88
*/
99
export async function DELETE(
1010
req: NextRequest,
11-
{ params }: { params: { id: string } }
11+
{ params }: { params: Promise<{ id: string }> }
1212
) {
1313
try {
1414
await ensureRoutesFSchema();
1515
const session = await verifySession(req);
1616
if (!session.ok) return session.response;
1717

18-
const { id } = params;
18+
const { id } = await params;
1919

2020
const { rows } = await sql`
2121
SELECT creator_id FROM announcements WHERE id = ${id}
2222
LIMIT 1
2323
`;
2424

2525
if (rows.length === 0) {
26-
return NextResponse.json({ error: "Announcement not found" }, { status: 404 });
26+
return NextResponse.json(
27+
{ error: "Announcement not found" },
28+
{ status: 404 }
29+
);
2730
}
2831

2932
const announcement = rows[0];
30-
const ownershipError = assertOwnership(session, null, announcement.creator_id);
33+
const ownershipError = assertOwnership(
34+
session,
35+
null,
36+
announcement.creator_id
37+
);
3138
if (ownershipError) return ownershipError;
3239

3340
await sql`DELETE FROM announcements WHERE id = ${id}`;
3441

3542
return NextResponse.json({ message: "Announcement deleted successfully" });
3643
} catch (error) {
3744
console.error("Announcement DELETE error:", error);
38-
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
45+
return NextResponse.json(
46+
{ error: "Internal server error" },
47+
{ status: 500 }
48+
);
3949
}
4050
}

0 commit comments

Comments
 (0)