diff --git a/app/[username]/watch/layout.tsx b/app/[username]/watch/layout.tsx new file mode 100644 index 00000000..69e0fd40 --- /dev/null +++ b/app/[username]/watch/layout.tsx @@ -0,0 +1,66 @@ +import type { Metadata } from "next"; +import { sql } from "@vercel/postgres"; +import React from "react"; + +const BASE = process.env.NEXT_PUBLIC_APP_URL || "https://streamfi.com"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ username: string }>; +}): Promise { + const { username } = await params; + + let streamTitle = `${username} is live`; + let playbackId: string | null = null; + + try { + const { rows } = await sql` + SELECT mux_playback_id, stream_title + FROM users + WHERE username = ${username} + LIMIT 1 + `; + if (rows[0]) { + streamTitle = rows[0].stream_title || streamTitle; + playbackId = rows[0].mux_playback_id; + } + } catch { + // fallback to defaults + } + + const title = `${streamTitle} — ${username} is live on StreamFi`; + const canonical = `${BASE}/${username}/watch`; + const ogImage = playbackId + ? `https://image.mux.com/${playbackId}/thumbnail.jpg` + : `${BASE}/Images/streamFi.png`; + + return { + title, + description: `Watch ${username} stream live on StreamFi`, + alternates: { + canonical, + }, + openGraph: { + title, + description: `Watch ${username} stream live on StreamFi`, + url: canonical, + images: [{ url: ogImage, width: 1280, height: 720, alt: title }], + type: "website", + }, + twitter: { + card: "summary_large_image", + title, + description: `Watch ${username} stream live on StreamFi`, + images: [ogImage], + }, + }; +} + +export default function WatchLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/app/[username]/watch/opengraph-image.tsx b/app/[username]/watch/opengraph-image.tsx new file mode 100644 index 00000000..2d11a720 --- /dev/null +++ b/app/[username]/watch/opengraph-image.tsx @@ -0,0 +1,158 @@ +import { ImageResponse } from "next/og"; +import { sql } from "@vercel/postgres"; + +export const runtime = "edge"; +export const alt = "Live stream on StreamFi"; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; + +export default async function Image({ + params, +}: { + params: Promise<{ username: string }>; +}) { + const { username } = await params; + + let playbackId: string | null = null; + let avatar: string | null = null; + let streamTitle = `${username} is live`; + + try { + const { rows } = await sql` + SELECT avatar, mux_playback_id, stream_title + FROM users + WHERE username = ${username} + LIMIT 1 + `; + if (rows[0]) { + avatar = rows[0].avatar; + playbackId = rows[0].mux_playback_id; + streamTitle = rows[0].stream_title || streamTitle; + } + } catch { + // use defaults + } + + const bgImage = playbackId + ? `https://image.mux.com/${playbackId}/thumbnail.jpg` + : null; + + return new ImageResponse( + ( +
+ {bgImage && ( + // eslint-disable-next-line @next/next/no-img-element + + )} + + {/* LIVE badge — top-left */} +
+ LIVE +
+ + {/* StreamFi wordmark — top-right */} +
+ StreamFi +
+ + {/* Bottom gradient overlay with streamer info */} +
+ {/* Avatar + username row */} +
+ {avatar && ( + // eslint-disable-next-line @next/next/no-img-element + + )} + + {username} + +
+ + {/* Stream title */} + + {streamTitle} + +
+ + {/* Purple bottom accent line */} +
+
+ ), + { ...size } + ); +} diff --git a/app/[username]/watch/page.tsx b/app/[username]/watch/page.tsx index 3d11a923..d5478a54 100644 --- a/app/[username]/watch/page.tsx +++ b/app/[username]/watch/page.tsx @@ -1,9 +1,10 @@ "use client"; -import { use, useEffect, useState, useRef } from "react"; +import { use, useEffect, useState, useRef, useCallback } from "react"; import { notFound } from "next/navigation"; import ViewStream from "@/components/stream/view-stream"; import { ViewStreamSkeleton } from "@/components/skeletons/ViewStreamSkeleton"; +import { AccessGate } from "@/components/stream/AccessGate"; import { toast } from "sonner"; interface PageProps { @@ -23,6 +24,12 @@ interface UserData { follower_count: number; is_following: boolean; stellar_address: string | null; + stream_access_type?: string; + stream_access_config?: { + asset_code: string; + asset_issuer: string; + min_balance: string; + } | null; } const WatchPage = ({ params }: PageProps) => { @@ -34,6 +41,10 @@ const WatchPage = ({ params }: PageProps) => { const [followLoading, setFollowLoading] = useState(false); const [loggedInUsername, setLoggedInUsername] = useState(null); + // Access control state + const [accessAllowed, setAccessAllowed] = useState(null); + const [accessChecking, setAccessChecking] = useState(false); + // Viewer tracking: one unique ID per page visit const viewerSessionId = useRef(null); const viewerPlaybackId = useRef(null); @@ -43,6 +54,35 @@ const WatchPage = ({ params }: PageProps) => { setLoggedInUsername(sessionStorage.getItem("username")); }, []); + const checkAccess = useCallback(async () => { + if (!userData) return; + // Public streams are always allowed — skip the network call + if (!userData.stream_access_type || userData.stream_access_type === "public") { + setAccessAllowed(true); + return; + } + setAccessChecking(true); + try { + const res = await fetch("/api/streams/access/check", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ streamerUsername: username }), + }); + const data = await res.json(); + setAccessAllowed(data.allowed === true); + } catch { + // On network failure, fail open rather than lock everyone out + setAccessAllowed(true); + } finally { + setAccessChecking(false); + } + }, [userData, username]); + + useEffect(() => { + checkAccess(); + }, [checkAccess]); + // Poll user/stream data every 5s useEffect(() => { let isInitialLoad = true; @@ -201,6 +241,26 @@ const WatchPage = ({ params }: PageProps) => { return notFound(); } + // Still waiting for access check result + if (accessAllowed === null || (accessChecking && accessAllowed === null)) { + return ; + } + + // Access denied — show gate + if (!accessAllowed && userData.stream_access_config) { + return ( +
+
+ +
+
+ ); + } + const isOwner = loggedInUsername?.toLowerCase() === username.toLowerCase(); const transformedUserData = { diff --git a/app/api/debug/migrate-access-control/route.ts b/app/api/debug/migrate-access-control/route.ts new file mode 100644 index 00000000..2cbebb40 --- /dev/null +++ b/app/api/debug/migrate-access-control/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; + +export async function GET() { + try { + const actions: string[] = []; + + // --- stream_access_config (1 row per streamer) --- + await sql` + CREATE TABLE IF NOT EXISTS stream_access_config ( + streamer_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + access_type VARCHAR(32) NOT NULL DEFAULT 'public', + config JSONB NOT NULL DEFAULT '{}'::jsonb, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ) + `; + actions.push("✅ Ensured table: stream_access_config"); + + // --- stream_access_grants (viewer grants, includes replay protection) --- + await sql` + CREATE TABLE IF NOT EXISTS stream_access_grants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + streamer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + viewer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + access_type VARCHAR(32) NOT NULL, + tx_hash VARCHAR(128), + amount_usdc VARCHAR(32), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + UNIQUE (streamer_id, viewer_id, access_type), + UNIQUE (tx_hash) + ) + `; + actions.push("✅ Ensured table: stream_access_grants"); + + await sql`ALTER TABLE stream_access_grants ADD COLUMN IF NOT EXISTS amount_usdc VARCHAR(32)`; + actions.push("✅ Ensured column: stream_access_grants.amount_usdc"); + + // Indexes for fast access checks + dashboards + await sql`CREATE INDEX IF NOT EXISTS idx_stream_access_grants_streamer ON stream_access_grants(streamer_id)`; + actions.push("✅ Ensured index: idx_stream_access_grants_streamer"); + + await sql`CREATE INDEX IF NOT EXISTS idx_stream_access_grants_viewer ON stream_access_grants(viewer_id)`; + actions.push("✅ Ensured index: idx_stream_access_grants_viewer"); + + await sql`CREATE INDEX IF NOT EXISTS idx_stream_access_grants_type ON stream_access_grants(access_type)`; + actions.push("✅ Ensured index: idx_stream_access_grants_type"); + + await sql`CREATE INDEX IF NOT EXISTS idx_stream_access_config_type ON stream_access_config(access_type)`; + actions.push("✅ Ensured index: idx_stream_access_config_type"); + + return NextResponse.json({ ok: true, actions }); + } catch (e) { + return NextResponse.json( + { ok: false, error: e instanceof Error ? e.message : String(e) }, + { status: 500 } + ); + } +} + diff --git a/app/api/fetch-username/route.ts b/app/api/fetch-username/route.ts index 59417485..582963e4 100644 --- a/app/api/fetch-username/route.ts +++ b/app/api/fetch-username/route.ts @@ -16,7 +16,13 @@ export async function GET(req: Request) { try { const result = wallet ? await sql`SELECT username FROM users WHERE wallet = ${wallet}` - : await sql`SELECT username FROM users WHERE email = ${email}`; + : await sql` + SELECT u.username + FROM users u + LEFT JOIN user_privacy p ON u.id = p.user_id + WHERE u.email = ${email} + AND (p.searchable_by_email IS TRUE OR p.searchable_by_email IS NULL) + `; if (result.rows.length === 0) { return withCorsResponse({ error: "User not found" }, 404); diff --git a/app/api/routes-f/.gitkeep b/app/api/routes-f/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/app/api/routes-f/_lib/__tests__/schemas.test.ts b/app/api/routes-f/_lib/__tests__/schemas.test.ts new file mode 100644 index 00000000..acee74e8 --- /dev/null +++ b/app/api/routes-f/_lib/__tests__/schemas.test.ts @@ -0,0 +1,246 @@ +/** + * Unit tests for shared routes-f Zod schemas. + * Covers valid and invalid inputs for each schema. + */ + +import { + stellarPublicKeySchema, + usernameSchema, + usdcAmountSchema, + paginationSchema, + periodSchema, + emailSchema, + urlSchema, + uuidSchema, +} from "../schemas"; + +// ── stellarPublicKeySchema ───────────────────────────────────────────────────── + +describe("stellarPublicKeySchema", () => { + it("accepts a valid Stellar public key", () => { + // 56 characters: G + 55 uppercase alphanumeric + const key = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN2"; + expect(stellarPublicKeySchema.safeParse(key).success).toBe(true); + }); + + it("rejects a key that does not start with G", () => { + const result = stellarPublicKeySchema.safeParse( + "BAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN" + ); + expect(result.success).toBe(false); + }); + + it("rejects a key that is too short", () => { + expect(stellarPublicKeySchema.safeParse("GAAZI4TCR3").success).toBe(false); + }); + + it("rejects a key with lowercase letters", () => { + const result = stellarPublicKeySchema.safeParse( + "gaazi4tcr3ty5ojhctjc2a4qsy6cjwjh5iajtgkin2er7lbnvkoccwn" + ); + expect(result.success).toBe(false); + }); + + it("rejects an empty string", () => { + expect(stellarPublicKeySchema.safeParse("").success).toBe(false); + }); +}); + +// ── usernameSchema ───────────────────────────────────────────────────────────── + +describe("usernameSchema", () => { + it("accepts a valid username", () => { + expect(usernameSchema.safeParse("alice_99").success).toBe(true); + }); + + it("accepts the minimum length (3 characters)", () => { + expect(usernameSchema.safeParse("abc").success).toBe(true); + }); + + it("accepts the maximum length (30 characters)", () => { + expect(usernameSchema.safeParse("a".repeat(30)).success).toBe(true); + }); + + it("rejects a username that is too short", () => { + const result = usernameSchema.safeParse("ab"); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toMatch(/at least 3/); + } + }); + + it("rejects a username that is too long", () => { + expect(usernameSchema.safeParse("a".repeat(31)).success).toBe(false); + }); + + it("rejects a username with special characters", () => { + expect(usernameSchema.safeParse("alice!").success).toBe(false); + }); + + it("rejects a username with spaces", () => { + expect(usernameSchema.safeParse("alice bob").success).toBe(false); + }); +}); + +// ── usdcAmountSchema ─────────────────────────────────────────────────────────── + +describe("usdcAmountSchema", () => { + it("accepts a whole number", () => { + expect(usdcAmountSchema.safeParse("100").success).toBe(true); + }); + + it("accepts a number with 1 decimal place", () => { + expect(usdcAmountSchema.safeParse("10.5").success).toBe(true); + }); + + it("accepts a number with 2 decimal places", () => { + expect(usdcAmountSchema.safeParse("9.99").success).toBe(true); + }); + + it("rejects a number with 3 decimal places", () => { + expect(usdcAmountSchema.safeParse("9.999").success).toBe(false); + }); + + it("rejects zero", () => { + const result = usdcAmountSchema.safeParse("0"); + expect(result.success).toBe(false); + }); + + it("rejects a negative number", () => { + expect(usdcAmountSchema.safeParse("-5.00").success).toBe(false); + }); + + it("rejects non-numeric strings", () => { + expect(usdcAmountSchema.safeParse("abc").success).toBe(false); + }); +}); + +// ── paginationSchema ─────────────────────────────────────────────────────────── + +describe("paginationSchema", () => { + it("applies default limit of 20 when omitted", () => { + const result = paginationSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(20); + } + }); + + it("coerces a string limit to a number", () => { + const result = paginationSchema.safeParse({ limit: "50" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.limit).toBe(50); + } + }); + + it("rejects limit below 1", () => { + expect(paginationSchema.safeParse({ limit: 0 }).success).toBe(false); + }); + + it("rejects limit above 100", () => { + expect(paginationSchema.safeParse({ limit: 101 }).success).toBe(false); + }); + + it("accepts an optional cursor", () => { + const result = paginationSchema.safeParse({ cursor: "abc123" }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.cursor).toBe("abc123"); + } + }); + + it("cursor is optional", () => { + const result = paginationSchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.cursor).toBeUndefined(); + } + }); +}); + +// ── periodSchema ─────────────────────────────────────────────────────────────── + +describe("periodSchema", () => { + const valid = ["7d", "30d", "90d", "all"] as const; + + valid.forEach(period => { + it(`accepts "${period}"`, () => { + expect(periodSchema.safeParse(period).success).toBe(true); + }); + }); + + it("rejects an unknown period", () => { + expect(periodSchema.safeParse("1y").success).toBe(false); + }); + + it("rejects an empty string", () => { + expect(periodSchema.safeParse("").success).toBe(false); + }); +}); + +// ── emailSchema ──────────────────────────────────────────────────────────────── + +describe("emailSchema", () => { + it("accepts a valid email", () => { + expect(emailSchema.safeParse("user@example.com").success).toBe(true); + }); + + it("rejects an email without @", () => { + expect(emailSchema.safeParse("userexample.com").success).toBe(false); + }); + + it("rejects an empty string", () => { + expect(emailSchema.safeParse("").success).toBe(false); + }); + + it("rejects an email over 255 characters", () => { + // 256 chars: 249 + "@" + "b" + ".co" + padding + const long = "a".repeat(251) + "@b.co"; + expect(emailSchema.safeParse(long).success).toBe(false); + }); +}); + +// ── urlSchema ────────────────────────────────────────────────────────────────── + +describe("urlSchema", () => { + it("accepts a valid https URL", () => { + expect(urlSchema.safeParse("https://example.com/path").success).toBe(true); + }); + + it("accepts a valid http URL", () => { + expect(urlSchema.safeParse("http://localhost:3000").success).toBe(true); + }); + + it("rejects a string without a protocol", () => { + expect(urlSchema.safeParse("example.com").success).toBe(false); + }); + + it("rejects an empty string", () => { + expect(urlSchema.safeParse("").success).toBe(false); + }); +}); + +// ── uuidSchema ───────────────────────────────────────────────────────────────── + +describe("uuidSchema", () => { + it("accepts a valid UUIDv4", () => { + expect( + uuidSchema.safeParse("550e8400-e29b-41d4-a716-446655440000").success + ).toBe(true); + }); + + it("rejects a UUID missing hyphens", () => { + expect( + uuidSchema.safeParse("550e8400e29b41d4a716446655440000").success + ).toBe(false); + }); + + it("rejects a string that is too short", () => { + expect(uuidSchema.safeParse("550e8400-e29b").success).toBe(false); + }); + + it("rejects an empty string", () => { + expect(uuidSchema.safeParse("").success).toBe(false); + }); +}); diff --git a/app/api/routes-f/_lib/__tests__/validate.test.ts b/app/api/routes-f/_lib/__tests__/validate.test.ts new file mode 100644 index 00000000..5b551add --- /dev/null +++ b/app/api/routes-f/_lib/__tests__/validate.test.ts @@ -0,0 +1,122 @@ +/** + * Unit tests for validateBody() and validateQuery() helpers. + */ + +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +import { z } from "zod"; +import { validateBody, validateQuery } from "../validate"; + +const testSchema = z.object({ + name: z.string().min(1), + age: z.number().int().positive(), +}); + +// ── validateBody ─────────────────────────────────────────────────────────────── + +describe("validateBody()", () => { + function makeRequest(body: unknown, contentType = "application/json") { + return new Request("http://localhost/test", { + method: "POST", + headers: { "Content-Type": contentType }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); + } + + it("returns { data } for a valid body", async () => { + const result = await validateBody( + makeRequest({ name: "Alice", age: 30 }), + testSchema + ); + expect(result).not.toBeInstanceOf(Response); + if (!(result instanceof Response)) { + expect(result.data).toEqual({ name: "Alice", age: 30 }); + } + }); + + it("returns a 400 Response for an invalid body", async () => { + const result = await validateBody( + makeRequest({ name: "", age: -1 }), + testSchema + ); + expect(result).toBeInstanceOf(Response); + if (result instanceof Response) { + expect(result.status).toBe(400); + const json = await result.json(); + expect(json.error).toBe("Validation failed"); + expect(Array.isArray(json.issues)).toBe(true); + expect(json.issues.length).toBeGreaterThan(0); + } + }); + + it("returns a 400 Response for malformed JSON", async () => { + const result = await validateBody( + makeRequest("{not json}", "text/plain"), + testSchema + ); + expect(result).toBeInstanceOf(Response); + if (result instanceof Response) { + expect(result.status).toBe(400); + const json = await result.json(); + expect(json.error).toBe("Invalid JSON body"); + } + }); + + it("includes per-field error messages", async () => { + const result = await validateBody( + makeRequest({ name: "", age: 0 }), + testSchema + ); + if (result instanceof Response) { + const json = await result.json(); + const fields = json.issues.map((i: { field: string }) => i.field); + expect(fields).toContain("name"); + expect(fields).toContain("age"); + } + }); +}); + +// ── validateQuery ────────────────────────────────────────────────────────────── + +describe("validateQuery()", () => { + const querySchema = z.object({ + limit: z.coerce.number().min(1).max(100).default(20), + q: z.string().optional(), + }); + + it("returns { data } for valid query params", () => { + const params = new URLSearchParams({ limit: "50", q: "hello" }); + const result = validateQuery(params, querySchema); + expect(result).not.toBeInstanceOf(Response); + if (!(result instanceof Response)) { + expect(result.data.limit).toBe(50); + expect(result.data.q).toBe("hello"); + } + }); + + it("applies default values for missing optional params", () => { + const params = new URLSearchParams(); + const result = validateQuery(params, querySchema); + expect(result).not.toBeInstanceOf(Response); + if (!(result instanceof Response)) { + expect(result.data.limit).toBe(20); + } + }); + + it("returns a 400 Response for invalid query params", () => { + const params = new URLSearchParams({ limit: "999" }); + const result = validateQuery(params, querySchema); + expect(result).toBeInstanceOf(Response); + if (result instanceof Response) { + expect(result.status).toBe(400); + } + }); +}); diff --git a/app/api/routes-f/_lib/schema.ts b/app/api/routes-f/_lib/schema.ts new file mode 100644 index 00000000..9ed42a0a --- /dev/null +++ b/app/api/routes-f/_lib/schema.ts @@ -0,0 +1,81 @@ +import { sql } from "@vercel/postgres"; + +/** + * Ensures all necessary tables for routes-f features exist. + */ +export async function ensureRoutesFSchema(): Promise { + // 1. Subscriptions Table + await sql` + CREATE TABLE IF NOT EXISTS subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + UNIQUE(user_id, creator_id, status) + ) + `; + + // 2. VOD Comments Table + await sql` + CREATE TABLE IF NOT EXISTS vod_comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + recording_id VARCHAR(255) NOT NULL, -- Mux playback ID or asset ID + timestamp_seconds FLOAT NOT NULL, + body TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + + // 3. Announcements Table + await sql` + CREATE TABLE IF NOT EXISTS announcements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + body VARCHAR(500) NOT NULL, + pinned BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + + // 4. User Privacy Table + await sql` + CREATE TABLE IF NOT EXISTS user_privacy ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + show_in_viewer_list BOOLEAN NOT NULL DEFAULT true, + show_watch_history BOOLEAN NOT NULL DEFAULT false, + show_following_list BOOLEAN NOT NULL DEFAULT true, + allow_collab_requests BOOLEAN NOT NULL DEFAULT true, + searchable_by_email BOOLEAN NOT NULL DEFAULT false, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + + // 5. Ensure indexes + await sql`CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id)`; + await sql`CREATE INDEX IF NOT EXISTS idx_subscriptions_creator ON subscriptions(creator_id)`; + await sql`CREATE INDEX IF NOT EXISTS idx_vod_comments_recording ON vod_comments(recording_id, timestamp_seconds)`; + await sql`CREATE INDEX IF NOT EXISTS idx_announcements_creator ON announcements(creator_id, created_at DESC)`; + + // 5. Accessibility Settings Table + await sql` + CREATE TABLE IF NOT EXISTS accessibility_settings ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + captions_enabled BOOLEAN NOT NULL DEFAULT true, + caption_font_size VARCHAR(10) NOT NULL DEFAULT 'medium', + high_contrast BOOLEAN NOT NULL DEFAULT false, + reduce_motion BOOLEAN NOT NULL DEFAULT false, + screen_reader_hints BOOLEAN NOT NULL DEFAULT true, + autoplay BOOLEAN NOT NULL DEFAULT false, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + await sql`CREATE INDEX IF NOT EXISTS idx_user_privacy_user ON user_privacy(user_id)`; + + // Add is_featured to users if it doesn't exist + await sql` + ALTER TABLE users ADD COLUMN IF NOT EXISTS is_featured BOOLEAN DEFAULT false + `; +} diff --git a/app/api/routes-f/_lib/schemas.ts b/app/api/routes-f/_lib/schemas.ts new file mode 100644 index 00000000..a8b0e9a7 --- /dev/null +++ b/app/api/routes-f/_lib/schemas.ts @@ -0,0 +1,53 @@ +/** + * Shared Zod schemas reused across routes-f endpoints. + * + * Import individual schemas or the full `schemas` object: + * import { usernameSchema, paginationSchema } from "@/app/api/routes-f/_lib/schemas"; + */ + +import { z } from "zod"; + +export const stellarPublicKeySchema = z + .string() + .regex(/^G[A-Z2-7]{55}$/, "Invalid Stellar public key"); + +export const usernameSchema = z + .string() + .min(3, "Username must be at least 3 characters") + .max(30, "Username must be at most 30 characters") + .regex( + /^[a-zA-Z0-9_]+$/, + "Username may only contain letters, numbers and underscores" + ); + +export const usdcAmountSchema = z + .string() + .regex( + /^\d+(\.\d{1,2})?$/, + "Amount must be a number with up to 2 decimal places" + ) + .refine(v => parseFloat(v) > 0, "Amount must be greater than 0"); + +export const paginationSchema = z.object({ + limit: z.coerce.number().min(1).max(100).default(20), + cursor: z.string().optional(), +}); + +export const periodSchema = z.enum(["7d", "30d", "90d", "all"]); + +export const emailSchema = z + .string() + .email("Invalid email address") + .max(255, "Email must be at most 255 characters"); + +export const urlSchema = z + .string() + .url("Invalid URL") + .max(2048, "URL must be at most 2048 characters"); + +export const uuidSchema = z + .string() + .regex( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + "Invalid UUID" + ); diff --git a/app/api/routes-f/_lib/session.ts b/app/api/routes-f/_lib/session.ts new file mode 100644 index 00000000..c63e86f5 --- /dev/null +++ b/app/api/routes-f/_lib/session.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; +import { verifySession, type VerifiedSession } from "@/lib/auth/verify-session"; + +export async function getOptionalSession( + req: NextRequest +): Promise | null> { + const hasSessionCookie = + req.cookies.has("privy_session") || + req.cookies.has("wallet_session") || + req.cookies.has("wallet"); + + if (!hasSessionCookie) { + return null; + } + + const session = await verifySession(req); + return session.ok ? session : null; +} diff --git a/app/api/routes-f/_lib/validate.ts b/app/api/routes-f/_lib/validate.ts new file mode 100644 index 00000000..7631a3cc --- /dev/null +++ b/app/api/routes-f/_lib/validate.ts @@ -0,0 +1,76 @@ +/** + * Shared request validation helpers for routes-f endpoints. + * + * Usage (body): + * const result = await validateBody(request, schema); + * if (result instanceof NextResponse) return result; // 400 + * const { data } = result; + * + * Usage (query params): + * const result = validateQuery(searchParams, schema); + * if (result instanceof NextResponse) return result; // 400 + * const { data } = result; + */ + +import { z } from "zod"; +import { NextResponse } from "next/server"; + +function formatIssues(issues: z.ZodIssue[]) { + return issues.map(issue => ({ + field: issue.path.join(".") || "body", + message: issue.message, + })); +} + +/** + * Parse and validate a JSON request body against a Zod schema. + * Returns { data } on success, or a 400 NextResponse with per-field errors. + */ +export async function validateBody( + request: Request, + schema: z.ZodSchema +): Promise<{ data: T } | NextResponse> { + let raw: unknown; + try { + raw = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const result = schema.safeParse(raw); + if (!result.success) { + return NextResponse.json( + { + error: "Validation failed", + issues: formatIssues(result.error.issues), + }, + { status: 400 } + ); + } + + return { data: result.data }; +} + +/** + * Validate URLSearchParams against a Zod schema. + * Returns { data } on success, or a 400 NextResponse with per-field errors. + */ +export function validateQuery( + searchParams: URLSearchParams, + schema: z.ZodSchema +): { data: T } | NextResponse { + const raw = Object.fromEntries(searchParams.entries()); + const result = schema.safeParse(raw); + + if (!result.success) { + return NextResponse.json( + { + error: "Validation failed", + issues: formatIssues(result.error.issues), + }, + { status: 400 } + ); + } + + return { data: result.data }; +} diff --git a/app/api/routes-f/accessibility/__tests__/route.test.ts b/app/api/routes-f/accessibility/__tests__/route.test.ts new file mode 100644 index 00000000..d0c3671b --- /dev/null +++ b/app/api/routes-f/accessibility/__tests__/route.test.ts @@ -0,0 +1,162 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => { + const resp = new Response(JSON.stringify(body), { + ...init, + headers: { + "Content-Type": "application/json", + ...(init?.headers || {}) + }, + }); + // Mock headers.set behavior if needed, + // but standard Response headers work for simple tests + return resp; + }, + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +jest.mock("@/app/api/routes-f/_lib/schema", () => ({ + ensureRoutesFSchema: jest.fn().mockResolvedValue(undefined), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { GET, PATCH } from "../route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +function makeRequest(method: string, path: string, body?: object) { + return new Request(`http://localhost${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) as any; +} + +describe("routes-f accessibility", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, "error").mockImplementation(() => { }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("returns 401 for unauthenticated requests", async () => { + verifySessionMock.mockResolvedValue({ + ok: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }), + }); + + const res = await GET(makeRequest("GET", "/api/routes-f/accessibility")); + expect(res.status).toBe(401); + }); + + it("returns default settings when none exist in DB", async () => { + verifySessionMock.mockResolvedValue({ + ok: true, + userId: "user-123", + }); + + sqlMock.mockResolvedValueOnce({ rows: [] }); + + const res = await GET(makeRequest("GET", "/api/routes-f/accessibility")); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.captions_enabled).toBe(true); + expect(res.headers.get("X-Accessibility-Captions")).toBe("on"); + expect(res.headers.get("X-Accessibility-Font-Size")).toBe("medium"); + }); + + it("returns stored settings from DB", async () => { + verifySessionMock.mockResolvedValue({ + ok: true, + userId: "user-123", + }); + + sqlMock.mockResolvedValueOnce({ + rows: [ + { + captions_enabled: false, + caption_font_size: "large", + high_contrast: true, + reduce_motion: true, + screen_reader_hints: false, + autoplay: true, + }, + ], + }); + + const res = await GET(makeRequest("GET", "/api/routes-f/accessibility")); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.captions_enabled).toBe(false); + expect(json.caption_font_size).toBe("large"); + expect(res.headers.get("X-Accessibility-Captions")).toBe("off"); + expect(res.headers.get("X-Accessibility-Font-Size")).toBe("large"); + expect(res.headers.get("X-Accessibility-High-Contrast")).toBe("on"); + }); + + it("updates settings via PATCH", async () => { + verifySessionMock.mockResolvedValue({ + ok: true, + userId: "user-123", + }); + + // Mock initial fetch for merge + sqlMock.mockResolvedValueOnce({ rows: [] }); + // Mock insert/update + sqlMock.mockResolvedValueOnce({ + rows: [ + { + captions_enabled: false, + caption_font_size: "medium", + high_contrast: false, + reduce_motion: false, + screen_reader_hints: true, + autoplay: false, + }, + ], + }); + + const res = await PATCH( + makeRequest("PATCH", "/api/routes-f/accessibility", { + captions_enabled: false, + }) + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.captions_enabled).toBe(false); + expect(res.headers.get("X-Accessibility-Captions")).toBe("off"); + }); + + it("validates caption_font_size in PATCH", async () => { + verifySessionMock.mockResolvedValue({ + ok: true, + userId: "user-123", + }); + + const res = await PATCH( + makeRequest("PATCH", "/api/routes-f/accessibility", { + caption_font_size: "huge", + }) + ); + + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error).toBe("Validation failed"); + }); +}); diff --git a/app/api/routes-f/accessibility/route.ts b/app/api/routes-f/accessibility/route.ts new file mode 100644 index 00000000..98c46a41 --- /dev/null +++ b/app/api/routes-f/accessibility/route.ts @@ -0,0 +1,140 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { ensureRoutesFSchema } from "@/app/api/routes-f/_lib/schema"; + +const accessibilitySchema = z.object({ + captions_enabled: z.boolean(), + caption_font_size: z.enum(["small", "medium", "large"]), + high_contrast: z.boolean(), + reduce_motion: z.boolean(), + screen_reader_hints: z.boolean(), + autoplay: z.boolean(), +}); + +const partialAccessibilitySchema = accessibilitySchema.partial(); + +type AccessibilitySettings = z.infer; + +const DEFAULT_SETTINGS: AccessibilitySettings = { + captions_enabled: true, + caption_font_size: "medium", + high_contrast: false, + reduce_motion: false, + screen_reader_hints: true, + autoplay: false, +}; + +function setAccessibilityHeaders(response: NextResponse, settings: AccessibilitySettings) { + response.headers.set("X-Accessibility-Captions", settings.captions_enabled ? "on" : "off"); + response.headers.set("X-Accessibility-Font-Size", settings.caption_font_size); + response.headers.set("X-Accessibility-High-Contrast", settings.high_contrast ? "on" : "off"); + response.headers.set("X-Accessibility-Reduce-Motion", settings.reduce_motion ? "on" : "off"); + response.headers.set("X-Accessibility-Reader-Hints", settings.screen_reader_hints ? "on" : "off"); + response.headers.set("X-Accessibility-Autoplay", settings.autoplay ? "on" : "off"); + return response; +} + +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + await ensureRoutesFSchema(); + + const { rows } = await sql` + SELECT + captions_enabled, + caption_font_size, + high_contrast, + reduce_motion, + screen_reader_hints, + autoplay + FROM accessibility_settings + WHERE user_id = ${session.userId} + `; + + const settings = rows.length > 0 ? (rows[0] as AccessibilitySettings) : DEFAULT_SETTINGS; + + const response = NextResponse.json(settings); + return setAccessibilityHeaders(response, settings); + } catch (error) { + console.error("[accessibility] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function PATCH(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, partialAccessibilitySchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const updates = bodyResult.data; + + try { + await ensureRoutesFSchema(); + + // Fetch current settings to merge or just use defaults for missing keys + const { rows: currentRows } = await sql` + SELECT * FROM accessibility_settings WHERE user_id = ${session.userId} + `; + + const current = currentRows.length > 0 ? (currentRows[0] as AccessibilitySettings) : DEFAULT_SETTINGS; + const merged = { ...current, ...updates }; + + const { rows } = await sql` + INSERT INTO accessibility_settings ( + user_id, + captions_enabled, + caption_font_size, + high_contrast, + reduce_motion, + screen_reader_hints, + autoplay, + updated_at + ) + VALUES ( + ${session.userId}, + ${merged.captions_enabled}, + ${merged.caption_font_size}, + ${merged.high_contrast}, + ${merged.reduce_motion}, + ${merged.screen_reader_hints}, + ${merged.autoplay}, + now() + ) + ON CONFLICT (user_id) DO UPDATE SET + captions_enabled = EXCLUDED.captions_enabled, + caption_font_size = EXCLUDED.caption_font_size, + high_contrast = EXCLUDED.high_contrast, + reduce_motion = EXCLUDED.reduce_motion, + screen_reader_hints = EXCLUDED.screen_reader_hints, + autoplay = EXCLUDED.autoplay, + updated_at = now() + RETURNING * + `; + + const updatedSettings = rows[0] as AccessibilitySettings; + const response = NextResponse.json(updatedSettings); + return setAccessibilityHeaders(response, updatedSettings); + } catch (error) { + console.error("[accessibility] PATCH error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/activity/_lib/db.ts b/app/api/routes-f/activity/_lib/db.ts new file mode 100644 index 00000000..9b437f3d --- /dev/null +++ b/app/api/routes-f/activity/_lib/db.ts @@ -0,0 +1,19 @@ +import { sql } from "@vercel/postgres"; + +export async function ensureActivitySchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS route_f_activity_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(30) NOT NULL, + actor_id UUID REFERENCES users(id) ON DELETE SET NULL, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_route_f_activity_user_feed + ON route_f_activity_events (user_id, created_at DESC) + `; +} diff --git a/app/api/routes-f/activity/daily/route.ts b/app/api/routes-f/activity/daily/route.ts new file mode 100644 index 00000000..6613148b --- /dev/null +++ b/app/api/routes-f/activity/daily/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { ensureActivitySchema } from "../_lib/db"; +import type { DailySummaryResponse } from "@/types/activity"; + +const dailyQuerySchema = z.object({ + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "date must be in YYYY-MM-DD format") + .refine( + (v) => !isNaN(new Date(`${v}T00:00:00Z`).getTime()), + "date must be a valid calendar date", + ), +}); + +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const queryResult = validateQuery( + req.nextUrl.searchParams, + dailyQuerySchema, + ); + if (queryResult instanceof NextResponse) { + return queryResult; + } + + const { date } = queryResult.data; + const start = `${date}T00:00:00Z`; + const end = `${date}T23:59:59.999Z`; + + try { + await ensureActivitySchema(); + + const { rows } = await sql` + SELECT + COUNT(*) FILTER (WHERE type = 'tip_received')::int + AS tips_received, + COALESCE(SUM((metadata->>'amount')::numeric) + FILTER (WHERE type = 'tip_received'), 0)::text + AS tips_received_xlm, + COUNT(*) FILTER (WHERE type = 'new_follower')::int + AS followers_gained, + COALESCE(SUM((metadata->>'duration_s')::int) + FILTER (WHERE type = 'stream_ended'), 0)::int + AS stream_duration_s, + COALESCE(MAX((metadata->>'peak_viewers')::int) + FILTER (WHERE type = 'stream_ended'), 0)::int + AS peak_viewers + FROM route_f_activity_events + WHERE user_id = ${session.userId} + AND created_at >= ${start} + AND created_at <= ${end} + `; + + const row = rows[0]; + + const body: DailySummaryResponse = { + date, + tips_received: row.tips_received ?? 0, + tips_received_xlm: row.tips_received_xlm ?? "0", + followers_gained: row.followers_gained ?? 0, + stream_duration_s: row.stream_duration_s ?? 0, + peak_viewers: row.peak_viewers ?? 0, + }; + + return NextResponse.json(body); + } catch (error) { + console.error("[activity/daily] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/app/api/routes-f/activity/route.ts b/app/api/routes-f/activity/route.ts new file mode 100644 index 00000000..a4a74e06 --- /dev/null +++ b/app/api/routes-f/activity/route.ts @@ -0,0 +1,134 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { ensureActivitySchema } from "./_lib/db"; +import { + FILTER_TO_TYPES, + type ActivityTypeFilter, + type ActivityEvent, + type ActivityFeedResponse, +} from "@/types/activity"; + +const VALID_FILTERS = Object.keys(FILTER_TO_TYPES) as ActivityTypeFilter[]; + +const activityQuerySchema = z.object({ + limit: z.coerce.number().min(1).max(100).default(20), + cursor: z.string().optional(), + type: z + .string() + .default("all") + .refine((v): v is ActivityTypeFilter => VALID_FILTERS.includes(v as ActivityTypeFilter), { + message: `type must be one of: ${VALID_FILTERS.join(", ")}`, + }), +}); + +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const queryResult = validateQuery( + req.nextUrl.searchParams, + activityQuerySchema, + ); + if (queryResult instanceof NextResponse) { + return queryResult; + } + + const { limit, cursor, type } = queryResult.data; + const typeFilter = FILTER_TO_TYPES[type as ActivityTypeFilter]; + + try { + await ensureActivitySchema(); + + // Fetch limit + 1 rows to detect whether there is a next page + const fetchLimit = limit + 1; + + let result; + + if (cursor && typeFilter) { + result = await sql` + SELECT + ae.id, ae.type, ae.metadata, ae.created_at, + u.username AS actor_username, + u.avatar AS actor_avatar + FROM route_f_activity_events ae + LEFT JOIN users u ON u.id = ae.actor_id + WHERE ae.user_id = ${session.userId} + AND ae.created_at < ${cursor} + AND ae.type = ANY(${typeFilter}) + ORDER BY ae.created_at DESC + LIMIT ${fetchLimit} + `; + } else if (cursor) { + result = await sql` + SELECT + ae.id, ae.type, ae.metadata, ae.created_at, + u.username AS actor_username, + u.avatar AS actor_avatar + FROM route_f_activity_events ae + LEFT JOIN users u ON u.id = ae.actor_id + WHERE ae.user_id = ${session.userId} + AND ae.created_at < ${cursor} + ORDER BY ae.created_at DESC + LIMIT ${fetchLimit} + `; + } else if (typeFilter) { + result = await sql` + SELECT + ae.id, ae.type, ae.metadata, ae.created_at, + u.username AS actor_username, + u.avatar AS actor_avatar + FROM route_f_activity_events ae + LEFT JOIN users u ON u.id = ae.actor_id + WHERE ae.user_id = ${session.userId} + AND ae.type = ANY(${typeFilter}) + ORDER BY ae.created_at DESC + LIMIT ${fetchLimit} + `; + } else { + result = await sql` + SELECT + ae.id, ae.type, ae.metadata, ae.created_at, + u.username AS actor_username, + u.avatar AS actor_avatar + FROM route_f_activity_events ae + LEFT JOIN users u ON u.id = ae.actor_id + WHERE ae.user_id = ${session.userId} + ORDER BY ae.created_at DESC + LIMIT ${fetchLimit} + `; + } + + const rows = result.rows; + const hasMore = rows.length > limit; + const pageRows = hasMore ? rows.slice(0, limit) : rows; + + const events: ActivityEvent[] = pageRows.map((row) => ({ + id: row.id, + type: row.type, + actor: + row.actor_username != null + ? { username: row.actor_username, avatar: row.actor_avatar } + : null, + metadata: row.metadata ?? null, + created_at: new Date(row.created_at).toISOString(), + })); + + const next_cursor = hasMore + ? new Date(pageRows[pageRows.length - 1].created_at).toISOString() + : null; + + const body: ActivityFeedResponse = { events, next_cursor }; + return NextResponse.json(body); + } catch (error) { + console.error("[activity] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/app/api/routes-f/alerts/__tests__/route.test.ts b/app/api/routes-f/alerts/__tests__/route.test.ts new file mode 100644 index 00000000..ef7344ab --- /dev/null +++ b/app/api/routes-f/alerts/__tests__/route.test.ts @@ -0,0 +1,139 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +jest.mock("../_lib/db", () => ({ + ensureAlertSchema: jest.fn().mockResolvedValue(undefined), + ALERT_TYPES: ["tip", "subscription", "gift", "raid", "follow"], + DEFAULT_ALERT_CONFIG: { + enabled_types: ["tip", "subscription", "gift", "raid", "follow"], + display_duration_ms: 5000, + sound_enabled: true, + custom_message_template: null, + }, +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { GET, PATCH } from "../route"; +import { POST } from "../test/route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +const AUTHED_SESSION = { + ok: true as const, + userId: "user-id", + wallet: null, + privyId: "did:privy:abc", + username: "alice", + email: "alice@example.com", +}; + +function makeRequest(method: string, path: string, body?: object) { + return new Request(`http://localhost${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; +} + +describe("routes-f alerts", () => { + let consoleSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + verifySessionMock.mockResolvedValue(AUTHED_SESSION); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it("returns the default alert config when none exists", async () => { + sqlMock.mockResolvedValueOnce({ rows: [] }); + + const res = await GET(makeRequest("GET", "/api/routes-f/alerts")); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.display_duration_ms).toBe(5000); + expect(json.enabled_types).toContain("tip"); + }); + + it("rejects an out-of-range display duration", async () => { + const res = await PATCH( + makeRequest("PATCH", "/api/routes-f/alerts", { + display_duration_ms: 20000, + }) + ); + + expect(res.status).toBe(400); + }); + + it("updates and returns the alert config", async () => { + sqlMock.mockResolvedValueOnce({ + rows: [ + { + enabled_types: ["tip", "follow"], + display_duration_ms: 4000, + sound_enabled: false, + custom_message_template: "{viewer} triggered {type}", + }, + ], + }); + + const res = await PATCH( + makeRequest("PATCH", "/api/routes-f/alerts", { + enabled_types: ["tip", "follow"], + display_duration_ms: 4000, + sound_enabled: false, + custom_message_template: "{viewer} triggered {type}", + }) + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.display_duration_ms).toBe(4000); + expect(json.sound_enabled).toBe(false); + }); + + it("stores a test alert event for polling", async () => { + sqlMock.mockResolvedValueOnce({ + rows: [ + { + id: "alert-event-1", + alert_type: "raid", + message: "Test viewer triggered a test raid", + payload: { actor_username: "Test viewer" }, + is_test: true, + created_at: "2026-03-27T00:00:00Z", + }, + ], + }); + + const res = await POST( + makeRequest("POST", "/api/routes-f/alerts/test", { + type: "raid", + }) + ); + const json = await res.json(); + + expect(res.status).toBe(201); + expect(json.delivery).toBe("stored_for_polling"); + expect(json.event.alert_type).toBe("raid"); + }); +}); diff --git a/app/api/routes-f/alerts/_lib/db.ts b/app/api/routes-f/alerts/_lib/db.ts new file mode 100644 index 00000000..69d29f7f --- /dev/null +++ b/app/api/routes-f/alerts/_lib/db.ts @@ -0,0 +1,49 @@ +import { sql } from "@vercel/postgres"; + +export const ALERT_TYPES = [ + "tip", + "subscription", + "gift", + "raid", + "follow", +] as const; + +export type AlertType = (typeof ALERT_TYPES)[number]; + +export const DEFAULT_ALERT_CONFIG = { + enabled_types: [...ALERT_TYPES], + display_duration_ms: 5000, + sound_enabled: true, + custom_message_template: null as string | null, +}; + +export async function ensureAlertSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS route_f_alert_configs ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + enabled_types TEXT[] NOT NULL DEFAULT ARRAY['tip', 'subscription', 'gift', 'raid', 'follow']::TEXT[], + display_duration_ms INT NOT NULL DEFAULT 5000, + sound_enabled BOOLEAN NOT NULL DEFAULT TRUE, + custom_message_template TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + + await sql` + CREATE TABLE IF NOT EXISTS route_f_alert_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + alert_type TEXT NOT NULL, + message TEXT NOT NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + is_test BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_route_f_alert_events_user_created + ON route_f_alert_events (user_id, created_at DESC) + `; +} diff --git a/app/api/routes-f/alerts/route.ts b/app/api/routes-f/alerts/route.ts new file mode 100644 index 00000000..1e01d1d5 --- /dev/null +++ b/app/api/routes-f/alerts/route.ts @@ -0,0 +1,173 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { + ALERT_TYPES, + DEFAULT_ALERT_CONFIG, + type AlertType, + ensureAlertSchema, +} from "./_lib/db"; + +const alertTypeSchema = z.enum(ALERT_TYPES); + +const updateAlertConfigSchema = z + .object({ + enabled_types: z.array(alertTypeSchema).max(ALERT_TYPES.length).optional(), + display_duration_ms: z.number().int().min(1000).max(15000).optional(), + sound_enabled: z.boolean().optional(), + custom_message_template: z.string().trim().max(280).nullable().optional(), + }) + .refine(body => Object.keys(body).length > 0, { + message: "At least one field is required", + path: ["body"], + }); + +function toPgTextArray(values: AlertType[]) { + return `{${values.map(value => `"${value}"`).join(",")}}`; +} + +function mapConfigRow(row?: Record) { + const duration = + typeof row?.display_duration_ms === "number" + ? row.display_duration_ms + : typeof row?.display_duration_ms === "string" + ? Number(row.display_duration_ms) + : null; + + return { + enabled_types: + (row?.enabled_types as AlertType[] | undefined) ?? + DEFAULT_ALERT_CONFIG.enabled_types, + display_duration_ms: + Number.isFinite(duration) && duration !== null + ? duration + : DEFAULT_ALERT_CONFIG.display_duration_ms, + sound_enabled: + typeof row?.sound_enabled === "boolean" + ? row.sound_enabled + : DEFAULT_ALERT_CONFIG.sound_enabled, + custom_message_template: + typeof row?.custom_message_template === "string" + ? row.custom_message_template + : null, + }; +} + +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { searchParams } = new URL(req.url); + const includeEvents = searchParams.get("include_events") === "true"; + + try { + await ensureAlertSchema(); + + const { rows } = await sql` + SELECT enabled_types, display_duration_ms, sound_enabled, custom_message_template + FROM route_f_alert_configs + WHERE user_id = ${session.userId} + LIMIT 1 + `; + + const config = mapConfigRow(rows[0]); + + if (!includeEvents) { + return NextResponse.json(config); + } + + const { rows: eventRows } = await sql` + SELECT id, alert_type, message, payload, is_test, created_at + FROM route_f_alert_events + WHERE user_id = ${session.userId} + ORDER BY created_at DESC + LIMIT 20 + `; + + return NextResponse.json({ + ...config, + recent_events: eventRows, + }); + } catch (error) { + console.error("[alerts] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function PATCH(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, updateAlertConfigSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { + enabled_types, + display_duration_ms, + sound_enabled, + custom_message_template, + } = bodyResult.data; + + try { + await ensureAlertSchema(); + + const nextEnabledTypes = + enabled_types ?? DEFAULT_ALERT_CONFIG.enabled_types; + const nextDuration = + display_duration_ms ?? DEFAULT_ALERT_CONFIG.display_duration_ms; + const nextSoundEnabled = + sound_enabled ?? DEFAULT_ALERT_CONFIG.sound_enabled; + const nextTemplate = + custom_message_template === undefined + ? DEFAULT_ALERT_CONFIG.custom_message_template + : custom_message_template; + + const { rows } = await sql` + INSERT INTO route_f_alert_configs ( + user_id, + enabled_types, + display_duration_ms, + sound_enabled, + custom_message_template, + updated_at + ) + VALUES ( + ${session.userId}, + ${toPgTextArray(nextEnabledTypes)}::text[], + ${nextDuration}, + ${nextSoundEnabled}, + ${nextTemplate}, + now() + ) + ON CONFLICT (user_id) DO UPDATE + SET enabled_types = COALESCE(${enabled_types ? toPgTextArray(enabled_types) : null}::text[], route_f_alert_configs.enabled_types), + display_duration_ms = COALESCE(${display_duration_ms ?? null}, route_f_alert_configs.display_duration_ms), + sound_enabled = COALESCE(${sound_enabled ?? null}, route_f_alert_configs.sound_enabled), + custom_message_template = CASE + WHEN ${custom_message_template !== undefined} THEN ${custom_message_template ?? null} + ELSE route_f_alert_configs.custom_message_template + END, + updated_at = now() + RETURNING enabled_types, display_duration_ms, sound_enabled, custom_message_template + `; + + return NextResponse.json(mapConfigRow(rows[0])); + } catch (error) { + console.error("[alerts] PATCH error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/alerts/test/route.ts b/app/api/routes-f/alerts/test/route.ts new file mode 100644 index 00000000..82aed915 --- /dev/null +++ b/app/api/routes-f/alerts/test/route.ts @@ -0,0 +1,85 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { ALERT_TYPES, type AlertType, ensureAlertSchema } from "../_lib/db"; + +const testAlertBodySchema = z.object({ + type: z.enum(ALERT_TYPES).default("tip"), + actor_username: z.string().trim().min(1).max(50).optional(), + custom_message: z.string().trim().min(1).max(280).optional(), + payload: z.record(z.unknown()).optional(), +}); + +function buildMessage( + type: AlertType, + actorUsername?: string, + customMessage?: string +) { + if (customMessage) { + return customMessage; + } + + const actor = actorUsername ?? "Test viewer"; + + switch (type) { + case "tip": + return `${actor} sent a test tip`; + case "subscription": + return `${actor} started a test subscription`; + case "gift": + return `${actor} sent a test gift`; + case "raid": + return `${actor} triggered a test raid`; + case "follow": + return `${actor} followed the channel in a test alert`; + } +} + +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, testAlertBodySchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const type = bodyResult.data.type ?? "tip"; + const { actor_username, custom_message, payload = {} } = bodyResult.data; + + try { + await ensureAlertSchema(); + + const message = buildMessage(type, actor_username, custom_message); + + const { rows } = await sql` + INSERT INTO route_f_alert_events (user_id, alert_type, message, payload, is_test) + VALUES ( + ${session.userId}, + ${type}, + ${message}, + ${JSON.stringify({ actor_username, ...payload })}, + TRUE + ) + RETURNING id, alert_type, message, payload, is_test, created_at + `; + + return NextResponse.json( + { + delivery: "stored_for_polling", + event: rows[0], + }, + { status: 201 } + ); + } catch (error) { + console.error("[alerts/test] POST error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/analytics/__tests__/route.test.ts b/app/api/routes-f/analytics/__tests__/route.test.ts new file mode 100644 index 00000000..50aca1de --- /dev/null +++ b/app/api/routes-f/analytics/__tests__/route.test.ts @@ -0,0 +1,142 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +jest.mock("../_lib/db", () => ({ + ensureAnalyticsSchema: jest.fn().mockResolvedValue(undefined), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { GET, POST } from "../route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; +const STREAM_ID = "550e8400-e29b-41d4-a716-446655440000"; + +function makeRequest(method: string, path: string, body?: object) { + return new Request(`http://localhost${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; +} + +describe("routes-f analytics", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("returns 401 for unauthenticated requests", async () => { + verifySessionMock.mockResolvedValue({ + ok: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }), + }); + + const res = await GET(makeRequest("GET", "/api/routes-f/analytics")); + + expect(res.status).toBe(401); + }); + + it("returns aggregated watch analytics", async () => { + verifySessionMock.mockResolvedValue({ + ok: true, + userId: "viewer-id", + wallet: null, + privyId: "did:privy:abc", + username: "viewer", + email: "viewer@example.com", + }); + + sqlMock + .mockResolvedValueOnce({ + rows: [{ total_watch_time: 3600, sessions_count: 4 }], + }) + .mockResolvedValueOnce({ + rows: [{ category: "Tech", watch_time: 1800, sessions: 2 }], + }) + .mockResolvedValueOnce({ + rows: [ + { + stream_id: STREAM_ID, + username: "alice", + avatar: null, + watch_time: 1800, + sessions: 2, + }, + ], + }) + .mockResolvedValueOnce({ + rows: [{ bucket: "2026-03-28", watch_time: 1200, sessions: 1 }], + }) + .mockResolvedValueOnce({ + rows: [{ bucket: "2026-03-24", watch_time: 2400, sessions: 3 }], + }) + .mockResolvedValueOnce({ + rows: [{ bucket: "2026-03", watch_time: 3600, sessions: 4 }], + }); + + const res = await GET(makeRequest("GET", "/api/routes-f/analytics")); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.total_watch_time).toBe(3600); + expect(json.top_streams).toHaveLength(1); + }); + + it("records a watch event", async () => { + verifySessionMock.mockResolvedValue({ + ok: true, + userId: "viewer-id", + wallet: null, + privyId: "did:privy:abc", + username: "viewer", + email: "viewer@example.com", + }); + + sqlMock + .mockResolvedValueOnce({ rows: [{ id: STREAM_ID }] }) + .mockResolvedValueOnce({ + rows: [ + { + id: "event-id", + user_id: "viewer-id", + stream_id: STREAM_ID, + duration_seconds: 120, + category: "Tech", + watched_at: "2026-03-28T00:00:00Z", + }, + ], + }); + + const res = await POST( + makeRequest("POST", "/api/routes-f/analytics", { + stream_id: STREAM_ID, + duration_seconds: 120, + category: "Tech", + }) + ); + + expect(res.status).toBe(201); + }); +}); diff --git a/app/api/routes-f/analytics/_lib/db.ts b/app/api/routes-f/analytics/_lib/db.ts new file mode 100644 index 00000000..b7536ec7 --- /dev/null +++ b/app/api/routes-f/analytics/_lib/db.ts @@ -0,0 +1,24 @@ +import { sql } from "@vercel/postgres"; + +export async function ensureAnalyticsSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS route_f_watch_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + stream_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + duration_seconds INTEGER NOT NULL, + category VARCHAR(80) NOT NULL, + watched_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_route_f_watch_events_user_time + ON route_f_watch_events (user_id, watched_at DESC) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_route_f_watch_events_stream_time + ON route_f_watch_events (stream_id, watched_at DESC) + `; +} diff --git a/app/api/routes-f/analytics/route.ts b/app/api/routes-f/analytics/route.ts new file mode 100644 index 00000000..1342c6a3 --- /dev/null +++ b/app/api/routes-f/analytics/route.ts @@ -0,0 +1,172 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { uuidSchema } from "@/app/api/routes-f/_lib/schemas"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { ensureAnalyticsSchema } from "./_lib/db"; + +const createWatchEventSchema = z.object({ + stream_id: uuidSchema, + duration_seconds: z.number().int().min(1).max(86400), + category: z.string().trim().min(1).max(80), +}); + +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + await ensureAnalyticsSchema(); + + const [ + summaryResult, + topCategoriesResult, + topStreamsResult, + dailyResult, + weeklyResult, + monthlyResult, + ] = await Promise.all([ + sql` + SELECT + COALESCE(SUM(duration_seconds), 0)::int AS total_watch_time, + COUNT(*)::int AS sessions_count + FROM route_f_watch_events + WHERE user_id = ${session.userId} + `, + sql` + SELECT + category, + COALESCE(SUM(duration_seconds), 0)::int AS watch_time, + COUNT(*)::int AS sessions + FROM route_f_watch_events + WHERE user_id = ${session.userId} + GROUP BY category + ORDER BY watch_time DESC, sessions DESC, category ASC + LIMIT 5 + `, + sql` + SELECT + e.stream_id, + u.username, + u.avatar, + COALESCE(SUM(e.duration_seconds), 0)::int AS watch_time, + COUNT(*)::int AS sessions + FROM route_f_watch_events e + JOIN users u ON u.id = e.stream_id + WHERE e.user_id = ${session.userId} + GROUP BY e.stream_id, u.username, u.avatar + ORDER BY watch_time DESC, sessions DESC, u.username ASC + LIMIT 5 + `, + sql` + SELECT + TO_CHAR(date_trunc('day', watched_at), 'YYYY-MM-DD') AS bucket, + COALESCE(SUM(duration_seconds), 0)::int AS watch_time, + COUNT(*)::int AS sessions + FROM route_f_watch_events + WHERE user_id = ${session.userId} + GROUP BY date_trunc('day', watched_at) + ORDER BY date_trunc('day', watched_at) DESC + LIMIT 30 + `, + sql` + SELECT + TO_CHAR(date_trunc('week', watched_at), 'YYYY-MM-DD') AS bucket, + COALESCE(SUM(duration_seconds), 0)::int AS watch_time, + COUNT(*)::int AS sessions + FROM route_f_watch_events + WHERE user_id = ${session.userId} + GROUP BY date_trunc('week', watched_at) + ORDER BY date_trunc('week', watched_at) DESC + LIMIT 12 + `, + sql` + SELECT + TO_CHAR(date_trunc('month', watched_at), 'YYYY-MM') AS bucket, + COALESCE(SUM(duration_seconds), 0)::int AS watch_time, + COUNT(*)::int AS sessions + FROM route_f_watch_events + WHERE user_id = ${session.userId} + GROUP BY date_trunc('month', watched_at) + ORDER BY date_trunc('month', watched_at) DESC + LIMIT 12 + `, + ]); + + const summary = summaryResult.rows[0] ?? { + total_watch_time: 0, + sessions_count: 0, + }; + + return NextResponse.json({ + total_watch_time: summary.total_watch_time, + sessions_count: summary.sessions_count, + top_categories: topCategoriesResult.rows, + top_streams: topStreamsResult.rows, + watch_time_by_period: { + day: dailyResult.rows, + week: weeklyResult.rows, + month: monthlyResult.rows, + }, + }); + } catch (error) { + console.error("[analytics] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, createWatchEventSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { stream_id, duration_seconds, category } = bodyResult.data; + + try { + await ensureAnalyticsSchema(); + + const { rows: streamRows } = await sql` + SELECT id FROM users WHERE id = ${stream_id} LIMIT 1 + `; + + if (streamRows.length === 0) { + return NextResponse.json({ error: "Stream not found" }, { status: 404 }); + } + + const { rows } = await sql` + INSERT INTO route_f_watch_events ( + user_id, + stream_id, + duration_seconds, + category + ) + VALUES ( + ${session.userId}, + ${stream_id}, + ${duration_seconds}, + ${category} + ) + RETURNING id, user_id, stream_id, duration_seconds, category, watched_at + `; + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + console.error("[analytics] POST error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/announcements/[id]/route.ts b/app/api/routes-f/announcements/[id]/route.ts new file mode 100644 index 00000000..5768bed0 --- /dev/null +++ b/app/api/routes-f/announcements/[id]/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession, assertOwnership } from "@/lib/auth/verify-session"; +import { ensureRoutesFSchema } from "../../_lib/schema"; + +/** + * DELETE /api/routes-f/announcements/[id] — creator removes own announcement + */ +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + await ensureRoutesFSchema(); + const session = await verifySession(req); + if (!session.ok) return session.response; + + const { id } = await params; + + const { rows } = await sql` + SELECT creator_id FROM announcements WHERE id = ${id} + LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json( + { error: "Announcement not found" }, + { status: 404 } + ); + } + + const announcement = rows[0]; + const ownershipError = assertOwnership( + session, + null, + announcement.creator_id + ); + if (ownershipError) return ownershipError; + + await sql`DELETE FROM announcements WHERE id = ${id}`; + + return NextResponse.json({ message: "Announcement deleted successfully" }); + } catch (error) { + console.error("Announcement DELETE error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/announcements/route.ts b/app/api/routes-f/announcements/route.ts new file mode 100644 index 00000000..148c47bf --- /dev/null +++ b/app/api/routes-f/announcements/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { ensureRoutesFSchema } from "../_lib/schema"; + +/** + * Creator Announcements endpoint. + */ + +// GET /api/routes-f/announcements — list announcements for authenticated user's followed creators (feed) +export async function GET(req: NextRequest) { + try { + await ensureRoutesFSchema(); + const session = await verifySession(req); + if (!session.ok) return session.response; + + // Get the creators the user is following + const { rows: userRows } = await sql` + SELECT following FROM users WHERE id = ${session.userId} + `; + + if (userRows.length === 0 || !userRows[0].following || userRows[0].following.length === 0) { + return NextResponse.json({ announcements: [] }); + } + + const following = userRows[0].following; + + // Fetch announcements from those creators + // Pinned float to top, followed by descending creation time + const { rows } = await sql` + SELECT a.id, u.username, u.avatar, a.body, a.pinned, a.created_at + FROM announcements a + JOIN users u ON a.creator_id = u.id + WHERE a.creator_id = ANY(${following}) + ORDER BY a.pinned DESC, a.created_at DESC + LIMIT 20 + `; + + return NextResponse.json({ announcements: rows }); + } catch (error) { + console.error("Announcements GET error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// POST /api/routes-f/announcements — creator posts an announcement +export async function POST(req: NextRequest) { + try { + await ensureRoutesFSchema(); + const session = await verifySession(req); + if (!session.ok) return session.response; + + const { body, pinned } = await req.json(); + + if (!body || body.length > 500) { + return NextResponse.json({ error: "Body must be between 1 and 500 characters" }, { status: 400 }); + } + + // Max 1 pinned announcement per creator + if (pinned) { + await sql` + UPDATE announcements SET pinned = false WHERE creator_id = ${session.userId} AND pinned = true + `; + } + + const { rows } = await sql` + INSERT INTO announcements (creator_id, body, pinned) + VALUES (${session.userId}, ${body}, ${pinned || false}) + RETURNING * + `; + + return NextResponse.json(rows[0]); + } catch (error) { + console.error("Announcements POST error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/audit/route.ts b/app/api/routes-f/audit/route.ts new file mode 100644 index 00000000..43f4c71c --- /dev/null +++ b/app/api/routes-f/audit/route.ts @@ -0,0 +1,126 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { z } from "zod"; + +async function ensureAuditTable() { + await sql` + CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + actor_id UUID REFERENCES users(id), + event_type TEXT NOT NULL, + metadata JSONB, + ip_address INET, + user_agent TEXT, + created_at TIMESTAMPTZ DEFAULT now() + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS audit_logs_user + ON audit_logs(user_id, created_at DESC) + `; + + await sql` + CREATE INDEX IF NOT EXISTS audit_logs_type + ON audit_logs(event_type, created_at DESC) + `; +} + +async function requireAdmin(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return { ok: false as const, response: session.response }; + } + + const { rows } = await sql` + SELECT role FROM users WHERE id = ${session.userId} LIMIT 1 + `; + if (rows[0]?.role !== "admin") { + return { + ok: false as const, + response: NextResponse.json({ error: "Forbidden" }, { status: 403 }), + }; + } + + return { ok: true as const, session }; +} + +const querySchema = z.object({ + user: z.string().optional(), + type: z.string().optional(), + from: z.string().optional(), + to: z.string().optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), + cursor: z.string().optional(), +}); + +export async function GET(req: NextRequest): Promise { + const auth = await requireAdmin(req); + if (!auth.ok) { + return auth.response; + } + + const result = validateQuery(new URL(req.url).searchParams, querySchema); + if (result instanceof NextResponse) return result; + const { user, type, from, to, limit, cursor } = result.data; + const pageLimit = limit ?? 50; + + await ensureAuditTable(); + + const fromDate = from ? new Date(from).toISOString() : null; + const toDate = to ? new Date(to).toISOString() : null; + const cursorDate = cursor ? new Date(cursor).toISOString() : null; + + const { rows: events } = await sql` + SELECT + al.id, + al.event_type, + al.metadata, + al.ip_address, + al.user_agent, + al.created_at, + u.username AS subject_username, + a.username AS actor_username + FROM audit_logs al + LEFT JOIN users u ON u.id = al.user_id + LEFT JOIN users a ON a.id = al.actor_id + WHERE (${user ?? null}::text IS NULL OR u.username = ${user ?? null}) + AND (${type ?? null}::text IS NULL OR al.event_type = ${type ?? null}) + AND (${fromDate ?? null}::timestamptz IS NULL OR al.created_at >= ${fromDate ?? null}) + AND (${toDate ?? null}::timestamptz IS NULL OR al.created_at <= ${toDate ?? null}) + AND (${cursorDate ?? null}::timestamptz IS NULL OR al.created_at < ${cursorDate ?? null}) + ORDER BY al.created_at DESC + LIMIT ${pageLimit + 1} + `; + + const hasMore = events.length > pageLimit; + const page = hasMore ? events.slice(0, pageLimit) : events; + const nextCursor = hasMore ? page[page.length - 1].created_at : null; + + const { rows: countRows } = await sql` + SELECT COUNT(*) AS total + FROM audit_logs al + LEFT JOIN users u ON u.id = al.user_id + WHERE (${user ?? null}::text IS NULL OR u.username = ${user ?? null}) + AND (${type ?? null}::text IS NULL OR al.event_type = ${type ?? null}) + AND (${fromDate ?? null}::timestamptz IS NULL OR al.created_at >= ${fromDate ?? null}) + AND (${toDate ?? null}::timestamptz IS NULL OR al.created_at <= ${toDate ?? null}) + `; + + return NextResponse.json({ + events: page.map(e => ({ + id: e.id, + event_type: e.event_type, + user: e.subject_username ? { username: e.subject_username } : null, + actor: e.actor_username ? { username: e.actor_username } : null, + metadata: e.metadata, + ip_address: e.ip_address, + created_at: e.created_at, + })), + total: Number(countRows[0]?.total ?? 0), + next_cursor: nextCursor, + }); +} diff --git a/app/api/routes-f/blocklist/route.ts b/app/api/routes-f/blocklist/route.ts new file mode 100644 index 00000000..038f2e6f --- /dev/null +++ b/app/api/routes-f/blocklist/route.ts @@ -0,0 +1,147 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +const blockActionSchema = z.object({ + target_id: z.string().trim().min(1, "target_id is required"), + action: z.enum(["block", "mute"]), + duration_minutes: z.number().int().positive().nullable().optional(), +}); + +async function ensureBlocklistTable(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS user_blocklist ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id TEXT NOT NULL, + target_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('block', 'mute')), + duration_minutes INT, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, target_id, action) + ) + `; +} + +/** + * GET /api/routes-f/blocklist + * List blocked/muted users for the authenticated user. + */ +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + await ensureBlocklistTable(); + + const { rows } = await sql` + SELECT target_id, action, duration_minutes, expires_at, created_at + FROM user_blocklist + WHERE user_id = ${session.userId} + AND (expires_at IS NULL OR expires_at > NOW()) + ORDER BY created_at DESC + `; + + return NextResponse.json({ blocklist: rows }); +} + +/** + * POST /api/routes-f/blocklist + * Block or mute a user. + */ +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const parsed = await validateBody(req, blockActionSchema); + if (parsed instanceof NextResponse) { + return parsed; + } + const { data } = parsed; + + if (data.target_id === session.userId) { + return NextResponse.json( + { error: "You cannot block or mute yourself" }, + { status: 400 } + ); + } + + await ensureBlocklistTable(); + + const expiresAt = + data.action === "mute" && data.duration_minutes + ? new Date(Date.now() + data.duration_minutes * 60 * 1000).toISOString() + : null; + + await sql` + INSERT INTO user_blocklist (user_id, target_id, action, duration_minutes, expires_at) + VALUES ( + ${session.userId}, + ${data.target_id}, + ${data.action}, + ${data.duration_minutes ?? null}, + ${expiresAt} + ) + ON CONFLICT (user_id, target_id, action) + DO UPDATE SET + duration_minutes = EXCLUDED.duration_minutes, + expires_at = EXCLUDED.expires_at, + created_at = NOW() + `; + + return NextResponse.json( + { + message: `User ${data.action === "block" ? "blocked" : "muted"} successfully`, + target_id: data.target_id, + action: data.action, + expires_at: expiresAt, + }, + { status: 201 } + ); +} + +/** + * DELETE /api/routes-f/blocklist + * Unblock or unmute a user. Expects ?target_id=xxx&action=block|mute + */ +export async function DELETE(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { searchParams } = new URL(req.url); + const targetId = searchParams.get("target_id"); + const action = searchParams.get("action"); + + if (!targetId) { + return NextResponse.json( + { error: "target_id query parameter is required" }, + { status: 400 } + ); + } + + await ensureBlocklistTable(); + + if (action && (action === "block" || action === "mute")) { + await sql` + DELETE FROM user_blocklist + WHERE user_id = ${session.userId} + AND target_id = ${targetId} + AND action = ${action} + `; + } else { + await sql` + DELETE FROM user_blocklist + WHERE user_id = ${session.userId} + AND target_id = ${targetId} + `; + } + + return NextResponse.json({ message: "Entry removed successfully" }); +} diff --git a/app/api/routes-f/clips/captions/_lib/db.ts b/app/api/routes-f/clips/captions/_lib/db.ts new file mode 100644 index 00000000..2964549b --- /dev/null +++ b/app/api/routes-f/clips/captions/_lib/db.ts @@ -0,0 +1,53 @@ +import { sql } from "@vercel/postgres"; + +export async function ensureCaptionsSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS route_f_clip_captions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + clip_id UUID NOT NULL REFERENCES stream_recordings(id) ON DELETE CASCADE, + language VARCHAR(10) NOT NULL, + label VARCHAR(255) NOT NULL, + vtt_content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(clip_id, language) + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_route_f_clip_captions_clip + ON route_f_clip_captions (clip_id) + `; +} + +/** + * Validates WebVTT content format + */ +export function validateWebVTT(content: string): boolean { + const lines = content.trim().split("\n"); + if (lines.length < 2) return false; + + // Must start with WEBVTT + if (!lines[0].startsWith("WEBVTT")) return false; + + // Check for at least one cue + let hasCue = false; + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + // Cue timestamp format: HH:MM:SS.mmm --> HH:MM:SS.mmm + if (line.includes("-->")) { + hasCue = true; + break; + } + } + + return hasCue; +} + +/** + * Validates BCP-47 language tag + */ +export function validateLanguageTag(tag: string): boolean { + // Simple BCP-47 validation: language-region format + // Examples: en, en-US, es, fr-CA + return /^[a-z]{2}(-[A-Z]{2})?$/.test(tag); +} diff --git a/app/api/routes-f/clips/captions/route.ts b/app/api/routes-f/clips/captions/route.ts new file mode 100644 index 00000000..a2f65fc2 --- /dev/null +++ b/app/api/routes-f/clips/captions/route.ts @@ -0,0 +1,217 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { uuidSchema } from "@/app/api/routes-f/_lib/schemas"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { + ensureCaptionsSchema, + validateWebVTT, + validateLanguageTag, +} from "./_lib/db"; + +const listCaptionsQuerySchema = z.object({ + clip_id: uuidSchema, +}); + +const uploadCaptionSchema = z.object({ + clip_id: uuidSchema, + language: z.string().trim().min(2).max(10), + label: z.string().trim().min(1).max(255), + vtt_content: z.string().trim().min(1), +}); + +export async function GET(req: NextRequest): Promise { + const { searchParams } = new URL(req.url); + const queryResult = validateQuery(searchParams, listCaptionsQuerySchema); + if (queryResult instanceof Response) { + return queryResult; + } + + try { + await ensureCaptionsSchema(); + + const { clip_id } = queryResult.data; + + const { rows } = await sql` + SELECT id, language, label, created_at + FROM route_f_clip_captions + WHERE clip_id = ${clip_id} + ORDER BY created_at ASC + `; + + return NextResponse.json({ + captions: rows.map(row => ({ + id: row.id, + language: row.language, + label: row.label, + created_at: row.created_at, + })), + }); + } catch (error) { + console.error("[clips/captions] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, uploadCaptionSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + try { + await ensureCaptionsSchema(); + + const { clip_id, language, label, vtt_content } = bodyResult.data; + + // Validate language tag + if (!validateLanguageTag(language)) { + return NextResponse.json( + { + error: "Validation failed", + issues: [ + { + field: "language", + message: "Language must be a valid BCP-47 tag (e.g., en, es, fr, en-US)", + }, + ], + }, + { status: 400 } + ); + } + + // Validate WebVTT content + if (!validateWebVTT(vtt_content)) { + return NextResponse.json( + { + error: "Validation failed", + issues: [ + { + field: "vtt_content", + message: "Content must be valid WebVTT format (must start with WEBVTT and contain at least one cue)", + }, + ], + }, + { status: 400 } + ); + } + + // Check clip exists and user owns it + const { rows: clipRows } = await sql` + SELECT id FROM stream_recordings + WHERE id = ${clip_id} AND user_id = ${session.userId} + LIMIT 1 + `; + + if (clipRows.length === 0) { + return NextResponse.json( + { error: "Clip not found or unauthorized" }, + { status: 404 } + ); + } + + // Check caption count for this clip + const { rows: countRows } = await sql` + SELECT COUNT(*)::int as count FROM route_f_clip_captions + WHERE clip_id = ${clip_id} + `; + + if (countRows[0].count >= 5) { + return NextResponse.json( + { + error: "Validation failed", + issues: [ + { + field: "clip_id", + message: "Maximum 5 caption tracks per clip", + }, + ], + }, + { status: 400 } + ); + } + + // Upsert caption + const { rows } = await sql` + INSERT INTO route_f_clip_captions (clip_id, language, label, vtt_content, created_at) + VALUES (${clip_id}, ${language}, ${label}, ${vtt_content}, now()) + ON CONFLICT (clip_id, language) DO UPDATE SET + label = EXCLUDED.label, + vtt_content = EXCLUDED.vtt_content + RETURNING id, language, label, created_at + `; + + return NextResponse.json( + { + id: rows[0].id, + language: rows[0].language, + label: rows[0].label, + created_at: rows[0].created_at, + }, + { status: 201 } + ); + } catch (error) { + console.error("[clips/captions] POST error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function DELETE(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { pathname } = new URL(req.url); + const captionId = pathname.split("/").pop(); + + if (!captionId || !uuidSchema.safeParse(captionId).success) { + return NextResponse.json( + { error: "Invalid caption ID" }, + { status: 400 } + ); + } + + try { + await ensureCaptionsSchema(); + + // Verify ownership + const { rows: captionRows } = await sql` + SELECT cc.id FROM route_f_clip_captions cc + JOIN stream_recordings sr ON cc.clip_id = sr.id + WHERE cc.id = ${captionId} AND sr.user_id = ${session.userId} + LIMIT 1 + `; + + if (captionRows.length === 0) { + return NextResponse.json( + { error: "Caption not found or unauthorized" }, + { status: 404 } + ); + } + + await sql` + DELETE FROM route_f_clip_captions WHERE id = ${captionId} + `; + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[clips/captions] DELETE error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/clips/reactions/__tests__/route.test.ts b/app/api/routes-f/clips/reactions/__tests__/route.test.ts new file mode 100644 index 00000000..1e7e2d74 --- /dev/null +++ b/app/api/routes-f/clips/reactions/__tests__/route.test.ts @@ -0,0 +1,116 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +jest.mock("../_lib/db", () => ({ + ensureClipReactionSchema: jest.fn().mockResolvedValue(undefined), + REACTION_EMOJIS: ["🔥", "❤️", "😂", "👏", "💜", "😮", "💯", "🎉"], +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { GET, POST, DELETE } from "../route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; +const CLIP_ID = "550e8400-e29b-41d4-a716-446655440000"; + +function makeRequest(method: string, path: string, body?: object) { + return new Request(`http://localhost${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; +} + +describe("routes-f clip reactions", () => { + beforeEach(() => { + jest.clearAllMocks(); + verifySessionMock.mockResolvedValue({ + ok: true, + userId: "user-id", + wallet: null, + privyId: "did:privy:abc", + username: "alice", + email: "alice@example.com", + }); + jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("returns grouped reactions for a clip", async () => { + sqlMock.mockResolvedValueOnce({ + rows: [ + { emoji: "🔥", count: 2 }, + { emoji: "🎉", count: 1 }, + ], + }); + + const res = await GET( + makeRequest("GET", `/api/routes-f/clips/reactions?clip_id=${CLIP_ID}`) + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.total_count).toBe(3); + expect(json.breakdown).toHaveLength(2); + }); + + it("rejects unsupported emoji", async () => { + const res = await POST( + makeRequest("POST", "/api/routes-f/clips/reactions", { + clip_id: CLIP_ID, + emoji: "🙂", + }) + ); + + expect(res.status).toBe(400); + }); + + it("rejects duplicate reactions for the same emoji", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [{ id: CLIP_ID }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await POST( + makeRequest("POST", "/api/routes-f/clips/reactions", { + clip_id: CLIP_ID, + emoji: "🔥", + }) + ); + + expect(res.status).toBe(409); + }); + + it("removes a reaction and returns the new summary", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ emoji: "🔥", count: 1 }] }); + + const res = await DELETE( + makeRequest("DELETE", "/api/routes-f/clips/reactions", { + clip_id: CLIP_ID, + emoji: "🔥", + }) + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.total_count).toBe(1); + }); +}); diff --git a/app/api/routes-f/clips/reactions/_lib/db.ts b/app/api/routes-f/clips/reactions/_lib/db.ts new file mode 100644 index 00000000..d37e42d0 --- /dev/null +++ b/app/api/routes-f/clips/reactions/_lib/db.ts @@ -0,0 +1,32 @@ +import { sql } from "@vercel/postgres"; + +export const REACTION_EMOJIS = [ + "🔥", + "❤️", + "😂", + "👏", + "💜", + "😮", + "💯", + "🎉", +] as const; + +export type ReactionEmoji = (typeof REACTION_EMOJIS)[number]; + +export async function ensureClipReactionSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS route_f_clip_reactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + clip_id UUID NOT NULL REFERENCES stream_recordings(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (clip_id, user_id, emoji) + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_route_f_clip_reactions_clip + ON route_f_clip_reactions (clip_id, created_at DESC) + `; +} diff --git a/app/api/routes-f/clips/reactions/route.ts b/app/api/routes-f/clips/reactions/route.ts new file mode 100644 index 00000000..7ddbf871 --- /dev/null +++ b/app/api/routes-f/clips/reactions/route.ts @@ -0,0 +1,139 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { uuidSchema } from "@/app/api/routes-f/_lib/schemas"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { REACTION_EMOJIS, ensureClipReactionSchema } from "./_lib/db"; + +const reactionBodySchema = z.object({ + clip_id: uuidSchema, + emoji: z.enum(REACTION_EMOJIS), +}); + +const reactionQuerySchema = z.object({ + clip_id: uuidSchema, +}); + +async function getReactionSummary(clipId: string) { + const { rows } = await sql` + SELECT emoji, COUNT(*)::int AS count + FROM route_f_clip_reactions + WHERE clip_id = ${clipId} + GROUP BY emoji + ORDER BY emoji ASC + `; + + return { + total_count: rows.reduce((total, row) => total + Number(row.count), 0), + breakdown: rows, + }; +} + +export async function GET(req: NextRequest): Promise { + const { searchParams } = new URL(req.url); + const queryResult = validateQuery(searchParams, reactionQuerySchema); + if (queryResult instanceof Response) { + return queryResult; + } + + try { + await ensureClipReactionSchema(); + + return NextResponse.json( + await getReactionSummary(queryResult.data.clip_id) + ); + } catch (error) { + console.error("[clips/reactions] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, reactionBodySchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { clip_id, emoji } = bodyResult.data; + + try { + await ensureClipReactionSchema(); + + const { rows: clipRows } = await sql` + SELECT id + FROM stream_recordings + WHERE id = ${clip_id} + LIMIT 1 + `; + + if (clipRows.length === 0) { + return NextResponse.json({ error: "Clip not found" }, { status: 404 }); + } + + const { rows } = await sql` + INSERT INTO route_f_clip_reactions (clip_id, user_id, emoji) + VALUES (${clip_id}, ${session.userId}, ${emoji}) + ON CONFLICT (clip_id, user_id, emoji) DO NOTHING + RETURNING id + `; + + if (rows.length === 0) { + return NextResponse.json( + { error: "Reaction already exists for this emoji" }, + { status: 409 } + ); + } + + return NextResponse.json(await getReactionSummary(clip_id), { + status: 201, + }); + } catch (error) { + console.error("[clips/reactions] POST error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function DELETE(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, reactionBodySchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { clip_id, emoji } = bodyResult.data; + + try { + await ensureClipReactionSchema(); + + await sql` + DELETE FROM route_f_clip_reactions + WHERE clip_id = ${clip_id} + AND user_id = ${session.userId} + AND emoji = ${emoji} + `; + + return NextResponse.json(await getReactionSummary(clip_id)); + } catch (error) { + console.error("[clips/reactions] DELETE error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/clips/submit/[id]/route.ts b/app/api/routes-f/clips/submit/[id]/route.ts new file mode 100644 index 00000000..46d4f4e4 --- /dev/null +++ b/app/api/routes-f/clips/submit/[id]/route.ts @@ -0,0 +1,103 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +const reviewSchema = z.object({ + status: z.enum(["approved", "rejected"]), + reason: z.string().max(500).optional(), +}); + +async function ensureSubmissionsTable() { + await sql` + CREATE TABLE IF NOT EXISTS clip_submissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + clip_id UUID NOT NULL, + submitter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + creator_note TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + reason TEXT, + reviewed_by UUID REFERENCES users(id) ON DELETE SET NULL, + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (clip_id) + ) + `; +} + +async function assertAdmin(userId: string): Promise { + const { rows } = await sql` + SELECT is_admin FROM users WHERE id = ${userId} LIMIT 1 + `; + return rows.length > 0 && rows[0].is_admin === true; +} + +/** + * PATCH /api/routes-f/clips/submit/[id] + * Admin approves or rejects a clip submission. + * Approved clips are marked featured in the clips table. + */ +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const { id } = await params; + + const bodyResult = await validateBody(req, reviewSchema); + if (bodyResult instanceof Response) return bodyResult; + + const { status, reason } = bodyResult.data; + + try { + await ensureSubmissionsTable(); + + const isAdmin = await assertAdmin(session.userId); + if (!isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { rows: existing } = await sql` + SELECT id, clip_id, status FROM clip_submissions WHERE id = ${id} LIMIT 1 + `; + + if (existing.length === 0) { + return NextResponse.json({ error: "Submission not found" }, { status: 404 }); + } + + if (existing[0].status !== "pending") { + return NextResponse.json( + { error: "Submission has already been reviewed" }, + { status: 409 } + ); + } + + const { rows } = await sql` + UPDATE clip_submissions + SET + status = ${status}, + reason = ${reason ?? null}, + reviewed_by = ${session.userId}, + reviewed_at = now() + WHERE id = ${id} + RETURNING id, clip_id, status, reason, reviewed_by, reviewed_at + `; + + // If approved, mark the clip as featured + if (status === "approved") { + await sql` + UPDATE clips SET is_featured = true WHERE id = ${existing[0].clip_id} + `.catch(() => { + // clips table may not have is_featured yet — non-fatal + }); + } + + return NextResponse.json(rows[0]); + } catch (error) { + console.error("[routes-f clips/submit/:id PATCH]", error); + return NextResponse.json({ error: "Failed to review submission" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/clips/submit/admin/route.ts b/app/api/routes-f/clips/submit/admin/route.ts new file mode 100644 index 00000000..ceeb6725 --- /dev/null +++ b/app/api/routes-f/clips/submit/admin/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +async function ensureSubmissionsTable() { + await sql` + CREATE TABLE IF NOT EXISTS clip_submissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + clip_id UUID NOT NULL, + submitter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + creator_note TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + reason TEXT, + reviewed_by UUID REFERENCES users(id) ON DELETE SET NULL, + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (clip_id) + ) + `; +} + +async function assertAdmin(userId: string): Promise { + const { rows } = await sql` + SELECT is_admin FROM users WHERE id = ${userId} LIMIT 1 + `; + return rows.length > 0 && rows[0].is_admin === true; +} + +/** GET /api/routes-f/clips/submit/admin — admin lists all pending submissions */ +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + try { + await ensureSubmissionsTable(); + + const isAdmin = await assertAdmin(session.userId); + if (!isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { searchParams } = new URL(req.url); + const status = searchParams.get("status") ?? "pending"; + + const { rows } = await sql` + SELECT + cs.id, + cs.clip_id, + cs.submitter_id, + cs.creator_note, + cs.status, + cs.reason, + cs.reviewed_by, + cs.reviewed_at, + cs.created_at, + u.username AS submitter_username + FROM clip_submissions cs + LEFT JOIN users u ON u.id = cs.submitter_id + WHERE cs.status = ${status} + ORDER BY cs.created_at ASC + `; + + return NextResponse.json({ submissions: rows }); + } catch (error) { + console.error("[routes-f clips/submit/admin GET]", error); + return NextResponse.json({ error: "Failed to fetch submissions" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/clips/submit/route.ts b/app/api/routes-f/clips/submit/route.ts new file mode 100644 index 00000000..4fefb92e --- /dev/null +++ b/app/api/routes-f/clips/submit/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +const submitClipSchema = z.object({ + clip_id: z.string().uuid(), + creator_note: z.string().max(500).optional(), +}); + +async function ensureSubmissionsTable() { + await sql` + CREATE TABLE IF NOT EXISTS clip_submissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + clip_id UUID NOT NULL, + submitter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + creator_note TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + reason TEXT, + reviewed_by UUID REFERENCES users(id) ON DELETE SET NULL, + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (clip_id) + ) + `; + await sql` + CREATE INDEX IF NOT EXISTS idx_clip_submissions_submitter + ON clip_submissions (submitter_id, created_at DESC) + `; + await sql` + CREATE INDEX IF NOT EXISTS idx_clip_submissions_status + ON clip_submissions (status, created_at DESC) + `; +} + +/** POST /api/routes-f/clips/submit — submit a clip for featured review */ +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const bodyResult = await validateBody(req, submitClipSchema); + if (bodyResult instanceof Response) return bodyResult; + + const { clip_id, creator_note } = bodyResult.data; + + try { + await ensureSubmissionsTable(); + + // Check for existing submission (one per clip) + const { rows: existing } = await sql` + SELECT id, status FROM clip_submissions WHERE clip_id = ${clip_id} LIMIT 1 + `; + + if (existing.length > 0) { + return NextResponse.json( + { error: "This clip has already been submitted", status: existing[0].status }, + { status: 409 } + ); + } + + const { rows } = await sql` + INSERT INTO clip_submissions (clip_id, submitter_id, creator_note) + VALUES (${clip_id}, ${session.userId}, ${creator_note ?? null}) + RETURNING id, clip_id, submitter_id, creator_note, status, created_at + `; + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + console.error("[routes-f clips/submit POST]", error); + return NextResponse.json({ error: "Failed to submit clip" }, { status: 500 }); + } +} + +/** GET /api/routes-f/clips/submit — list own submitted clips and their review status */ +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + try { + await ensureSubmissionsTable(); + + const { rows } = await sql` + SELECT id, clip_id, creator_note, status, reason, reviewed_at, created_at + FROM clip_submissions + WHERE submitter_id = ${session.userId} + ORDER BY created_at DESC + `; + + return NextResponse.json({ submissions: rows }); + } catch (error) { + console.error("[routes-f clips/submit GET]", error); + return NextResponse.json({ error: "Failed to fetch submissions" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/clips/trending/route.ts b/app/api/routes-f/clips/trending/route.ts new file mode 100644 index 00000000..1a14530b --- /dev/null +++ b/app/api/routes-f/clips/trending/route.ts @@ -0,0 +1,162 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { validateQuery } from "@/app/api/routes-f/_lib/validate"; + +/** + * GET /api/routes-f/clips/trending + * Returns top clips ranked by a gravity score: views / (hours_since_created + 2)^1.5 + * Query params: ?category=&period=24h|7d|30d&limit=20&cursor= + */ + +const trendingQuerySchema = z.object({ + category: z.string().optional(), + period: z.enum(["24h", "7d", "30d"]).default("7d"), + limit: z.coerce.number().min(1).max(100).default(20), + cursor: z.string().optional(), +}); + +export async function GET(req: NextRequest): Promise { + const { searchParams } = new URL(req.url); + const queryResult = validateQuery(searchParams, trendingQuerySchema); + if (queryResult instanceof Response) return queryResult; + + const { category, period, limit, cursor } = queryResult.data; + const since = new Date( + Date.now() - + (period === "24h" + ? 24 * 60 * 60 * 1000 + : period === "30d" + ? 30 * 24 * 60 * 60 * 1000 + : 7 * 24 * 60 * 60 * 1000) + ).toISOString(); + + try { + // Gravity score: views / (hours_since_created + 2)^1.5 + // Cursor is the last returned clip id for pagination + let rows: Record[]; + + if (category) { + if (cursor) { + const { rows: r } = await sql` + SELECT + r.id, + r.playback_id, + r.title, + u.username AS creator_username, + r.view_count, + r.duration, + r.created_at, + (r.view_count::float / + POWER( + EXTRACT(EPOCH FROM (NOW() - r.created_at)) / 3600.0 + 2, + 1.5 + ) + ) AS score + FROM stream_recordings r + JOIN users u ON u.id = r.user_id + WHERE r.status = 'ready' + AND r.created_at >= ${since} + AND r.category = ${category} + AND r.id < ${cursor} + ORDER BY score DESC, r.id DESC + LIMIT ${limit} + `; + rows = r; + } else { + const { rows: r } = await sql` + SELECT + r.id, + r.playback_id, + r.title, + u.username AS creator_username, + r.view_count, + r.duration, + r.created_at, + (r.view_count::float / + POWER( + EXTRACT(EPOCH FROM (NOW() - r.created_at)) / 3600.0 + 2, + 1.5 + ) + ) AS score + FROM stream_recordings r + JOIN users u ON u.id = r.user_id + WHERE r.status = 'ready' + AND r.created_at >= ${since} + AND r.category = ${category} + ORDER BY score DESC, r.id DESC + LIMIT ${limit} + `; + rows = r; + } + } else { + if (cursor) { + const { rows: r } = await sql` + SELECT + r.id, + r.playback_id, + r.title, + u.username AS creator_username, + r.view_count, + r.duration, + r.created_at, + (r.view_count::float / + POWER( + EXTRACT(EPOCH FROM (NOW() - r.created_at)) / 3600.0 + 2, + 1.5 + ) + ) AS score + FROM stream_recordings r + JOIN users u ON u.id = r.user_id + WHERE r.status = 'ready' + AND r.created_at >= ${since} + AND r.id < ${cursor} + ORDER BY score DESC, r.id DESC + LIMIT ${limit} + `; + rows = r; + } else { + const { rows: r } = await sql` + SELECT + r.id, + r.playback_id, + r.title, + u.username AS creator_username, + r.view_count, + r.duration, + r.created_at, + (r.view_count::float / + POWER( + EXTRACT(EPOCH FROM (NOW() - r.created_at)) / 3600.0 + 2, + 1.5 + ) + ) AS score + FROM stream_recordings r + JOIN users u ON u.id = r.user_id + WHERE r.status = 'ready' + AND r.created_at >= ${since} + ORDER BY score DESC, r.id DESC + LIMIT ${limit} + `; + rows = r; + } + } + + const nextCursor = + rows.length === limit ? (rows[rows.length - 1].id as string) : null; + + return NextResponse.json({ + period, + category: category ?? null, + clips: rows.map(({ score: _score, ...clip }) => clip), + next_cursor: nextCursor, + }); + } catch (error) { + console.error("[clips/trending] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + diff --git a/app/api/routes-f/collab/[id]/route.ts b/app/api/routes-f/collab/[id]/route.ts new file mode 100644 index 00000000..775090f1 --- /dev/null +++ b/app/api/routes-f/collab/[id]/route.ts @@ -0,0 +1,128 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { ensureCollabSchema } from "../_lib/db"; + +const collabPatchSchema = z.object({ + status: z.enum(["accepted", "declined"]), +}); + +type RouteContext = { params: Promise<{ id: string }> }; + +/** + * PATCH /api/routes-f/collab/[id] + * Accept or decline a collab request. + */ +export async function PATCH( + req: NextRequest, + { params }: RouteContext +): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const { id } = await params; + const parsed = await validateBody(req, collabPatchSchema); + if (parsed instanceof Response) return parsed; + const { status } = parsed.data; + + try { + await ensureCollabSchema(); + + // 1. Fetch request and check authorization + const { rows } = await sql` + SELECT sender_id, receiver_id, status FROM collab_requests WHERE id = ${id} + `; + + if (rows.length === 0) { + return NextResponse.json({ error: "Request not found" }, { status: 404 }); + } + + const request = rows[0]; + + // Only the receiver can accept or decline + if (request.receiver_id !== session.userId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + // Can only update if currently pending + if (request.status !== "pending") { + return NextResponse.json( + { error: `Cannot update request with status: ${request.status}` }, + { status: 400 } + ); + } + + // 2. Update status + const { rows: updatedRows } = await sql` + UPDATE collab_requests + SET status = ${status}, updated_at = NOW() + WHERE id = ${id} + RETURNING * + `; + + return NextResponse.json(updatedRows[0]); + } catch (error) { + console.error("[collab:PATCH] Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/routes-f/collab/[id] + * Cancel a pending collab request. + */ +export async function DELETE( + req: NextRequest, + { params }: RouteContext +): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const { id } = await params; + + try { + await ensureCollabSchema(); + + // 1. Fetch request and check authorization + const { rows } = await sql` + SELECT sender_id, status FROM collab_requests WHERE id = ${id} + `; + + if (rows.length === 0) { + return NextResponse.json({ error: "Request not found" }, { status: 404 }); + } + + const request = rows[0]; + + // Only the sender can cancel + if (request.sender_id !== session.userId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + // Can only cancel if currently pending + if (request.status !== "pending") { + return NextResponse.json( + { error: `Cannot cancel request with status: ${request.status}` }, + { status: 400 } + ); + } + + // 2. Delete request + await sql`DELETE FROM collab_requests WHERE id = ${id}`; + + return NextResponse.json({ + message: "Collaboration request cancelled successfully", + }); + } catch (error) { + console.error("[collab:DELETE] Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/collab/__tests__/route.test.ts b/app/api/routes-f/collab/__tests__/route.test.ts new file mode 100644 index 00000000..eafe917e --- /dev/null +++ b/app/api/routes-f/collab/__tests__/route.test.ts @@ -0,0 +1,169 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +jest.mock("../_lib/db", () => ({ + ensureCollabSchema: jest.fn().mockResolvedValue(undefined), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { ensureCollabSchema } from "../_lib/db"; +import { GET, POST } from "../route"; +import { PATCH, DELETE } from "../[id]/route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +function makeRequest(method: string, path: string, body?: object) { + return new Request(`http://localhost${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; +} + +describe("Creator Collaboration Requests API", () => { + const SENDER_ID = "sender-uuid"; + const RECEIVER_ID = "receiver-uuid"; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, "error").mockImplementation(() => { }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("GET /api/routes-f/collab", () => { + it("returns 401 for unauthenticated", async () => { + verifySessionMock.mockResolvedValueOnce({ ok: false, response: new Response(null, { status: 401 }) }); + const res = await GET(makeRequest("GET", "/api/routes-f/collab")); + expect(res.status).toBe(401); + }); + + it("lists incoming and outgoing requests", async () => { + verifySessionMock.mockResolvedValueOnce({ ok: true, userId: SENDER_ID }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "req-1", direction: "outgoing" }] }); + + const res = await GET(makeRequest("GET", "/api/routes-f/collab")); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.requests).toHaveLength(1); + }); + }); + + describe("POST /api/routes-f/collab", () => { + const payload = { target_username: "bob", message: "Let's collab!" }; + + it("sends a request successfully", async () => { + verifySessionMock.mockResolvedValueOnce({ ok: true, userId: SENDER_ID }); + + // 1. Resolve receiver + sqlMock.mockResolvedValueOnce({ rows: [{ id: RECEIVER_ID }] }); + // 2. Block check + sqlMock.mockResolvedValueOnce({ rows: [] }); + // 3. Max pending check + sqlMock.mockResolvedValueOnce({ rows: [{ count: 2 }] }); + // 4. Existing check + sqlMock.mockResolvedValueOnce({ rows: [] }); + // 5. Insert + sqlMock.mockResolvedValueOnce({ rows: [{ id: "new-req-id" }] }); + + const res = await POST(makeRequest("POST", "/api/routes-f/collab", payload)); + expect(res.status).toBe(201); + }); + + it("returns 404 if target user not found", async () => { + verifySessionMock.mockResolvedValueOnce({ ok: true, userId: SENDER_ID }); + sqlMock.mockResolvedValueOnce({ rows: [] }); + + const res = await POST(makeRequest("POST", "/api/routes-f/collab", payload)); + expect(res.status).toBe(404); + }); + + it("returns 403 if blocked by receiver", async () => { + verifySessionMock.mockResolvedValueOnce({ ok: true, userId: SENDER_ID }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: RECEIVER_ID }] }); // resolve + sqlMock.mockResolvedValueOnce({ rows: [{ 1: 1 }] }); // block exists + + const res = await POST(makeRequest("POST", "/api/routes-f/collab", payload)); + expect(res.status).toBe(403); + }); + + it("returns 429 if goal of 5 pending requests reached", async () => { + verifySessionMock.mockResolvedValueOnce({ ok: true, userId: SENDER_ID }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: RECEIVER_ID }] }); // resolve + sqlMock.mockResolvedValueOnce({ rows: [] }); // block check + sqlMock.mockResolvedValueOnce({ rows: [{ count: 5 }] }); // count check + + const res = await POST(makeRequest("POST", "/api/routes-f/collab", payload)); + expect(res.status).toBe(429); + expect(await res.json()).toEqual({ error: "Maximum of 5 pending requests reached" }); + }); + + it("returns 400 for past proposed_date", async () => { + verifySessionMock.mockResolvedValueOnce({ ok: true, userId: SENDER_ID }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: RECEIVER_ID }] }); // resolve + + const yesterday = new Date(Date.now() - 86400000).toISOString(); + const res = await POST(makeRequest("POST", "/api/routes-f/collab", { ...payload, proposed_date: yesterday })); + + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "proposed_date must be in the future" }); + }); + }); + + describe("PATCH /api/routes-f/collab/[id]", () => { + it("accepts a request successfully", async () => { + verifySessionMock.mockResolvedValueOnce({ ok: true, userId: RECEIVER_ID }); + sqlMock.mockResolvedValueOnce({ rows: [{ sender_id: SENDER_ID, receiver_id: RECEIVER_ID, status: "pending" }] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "req-id", status: "accepted" }] }); + + const res = await PATCH(makeRequest("PATCH", "/api/routes-f/collab/req-id", { status: "accepted" }), { params: { id: "req-id" } } as any); + expect(res.status).toBe(200); + expect((await res.json()).status).toBe("accepted"); + }); + + it("returns 403 if sender tries to accept their own request", async () => { + verifySessionMock.mockResolvedValueOnce({ ok: true, userId: SENDER_ID }); + sqlMock.mockResolvedValueOnce({ rows: [{ sender_id: SENDER_ID, receiver_id: RECEIVER_ID, status: "pending" }] }); + + const res = await PATCH(makeRequest("PATCH", "/api/routes-f/collab/req-id", { status: "accepted" }), { params: { id: "req-id" } } as any); + expect(res.status).toBe(403); + }); + }); + + describe("DELETE /api/routes-f/collab/[id]", () => { + it("cancels a pending request successfully", async () => { + verifySessionMock.mockResolvedValueOnce({ ok: true, userId: SENDER_ID }); + sqlMock.mockResolvedValueOnce({ rows: [{ sender_id: SENDER_ID, status: "pending" }] }); + sqlMock.mockResolvedValueOnce({ rows: [] }); + + const res = await DELETE(makeRequest("DELETE", "/api/routes-f/collab/req-id"), { params: { id: "req-id" } } as any); + expect(res.status).toBe(200); + }); + + it("returns 400 if trying to cancel an already accepted request", async () => { + verifySessionMock.mockResolvedValueOnce({ ok: true, userId: SENDER_ID }); + sqlMock.mockResolvedValueOnce({ rows: [{ sender_id: SENDER_ID, status: "accepted" }] }); + + const res = await DELETE(makeRequest("DELETE", "/api/routes-f/collab/req-id"), { params: { id: "req-id" } } as any); + expect(res.status).toBe(400); + }); + }); +}); diff --git a/app/api/routes-f/collab/_lib/db.ts b/app/api/routes-f/collab/_lib/db.ts new file mode 100644 index 00000000..1b1f9ee8 --- /dev/null +++ b/app/api/routes-f/collab/_lib/db.ts @@ -0,0 +1,25 @@ +import { sql } from "@vercel/postgres"; + +/** + * Ensures the collab_requests table existence. + */ +export async function ensureCollabSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS collab_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + receiver_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'accepted', 'declined')), + message TEXT NOT NULL, + proposed_date TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- Prevent duplicate pending requests between same pair + UNIQUE(sender_id, receiver_id, status) + ) + `; + + await sql`CREATE INDEX IF NOT EXISTS idx_collab_sender ON collab_requests(sender_id)`; + await sql`CREATE INDEX IF NOT EXISTS idx_collab_receiver ON collab_requests(receiver_id)`; +} diff --git a/app/api/routes-f/collab/route.ts b/app/api/routes-f/collab/route.ts new file mode 100644 index 00000000..4a77e098 --- /dev/null +++ b/app/api/routes-f/collab/route.ts @@ -0,0 +1,126 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { ensureCollabSchema } from "./_lib/db"; + +const collabRequestSchema = z.object({ + target_username: z.string().trim().min(1, "target_username is required"), + message: z.string().trim().min(1, "message is required").max(500), + proposed_date: z.string().datetime().optional().nullable(), +}); + +/** + * GET /api/routes-f/collab + * List incoming and outgoing collab requests. + */ +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + await ensureCollabSchema(); + + try { + const { rows } = await sql` + SELECT + c.id, + c.status, + c.message, + c.proposed_date, + c.created_at, + u_sender.username AS sender_username, + u_sender.avatar AS sender_avatar, + u_receiver.username AS receiver_username, + u_receiver.avatar AS receiver_avatar, + CASE WHEN c.sender_id = ${session.userId} THEN 'outgoing' ELSE 'incoming' END AS direction + FROM collab_requests c + JOIN users u_sender ON c.sender_id = u_sender.id + JOIN users u_receiver ON c.receiver_id = u_receiver.id + WHERE c.sender_id = ${session.userId} OR c.receiver_id = ${session.userId} + ORDER BY c.created_at DESC + `; + + return NextResponse.json({ requests: rows }); + } catch (error) { + console.error("[collab:GET] Error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +/** + * POST /api/routes-f/collab + * Send a collab request. + */ +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const parsed = await validateBody(req, collabRequestSchema); + if (parsed instanceof Response) return parsed; + const { target_username, message, proposed_date } = parsed.data; + + try { + await ensureCollabSchema(); + + // 1. Resolve receiver ID + const { rows: receiverRows } = await sql` + SELECT id FROM users WHERE LOWER(username) = LOWER(${target_username}) + `; + if (receiverRows.length === 0) { + return NextResponse.json({ error: "Target user not found" }, { status: 404 }); + } + const receiverId = receiverRows[0].id; + + if (receiverId === session.userId) { + return NextResponse.json({ error: "You cannot collab with yourself" }, { status: 400 }); + } + + // 2. Validate proposed_date is in the future + if (proposed_date) { + if (new Date(proposed_date) <= new Date()) { + return NextResponse.json({ error: "proposed_date must be in the future" }, { status: 400 }); + } + } + + // 3. Check for blocks + const { rows: blockRows } = await sql` + SELECT 1 FROM user_blocklist + WHERE user_id = ${receiverId} AND target_id = ${session.userId} AND action = 'block' + `; + if (blockRows.length > 0) { + return NextResponse.json({ error: "You cannot send a request to this user" }, { status: 403 }); + } + + // 4. Check for max 5 pending outgoing requests + const { rows: pendingRows } = await sql` + SELECT COUNT(*)::int AS count FROM collab_requests + WHERE sender_id = ${session.userId} AND status = 'pending' + `; + if (pendingRows[0].count >= 5) { + return NextResponse.json({ error: "Maximum of 5 pending requests reached" }, { status: 429 }); + } + + // 5. Check for existing pending request between these two + const { rows: existingRows } = await sql` + SELECT id FROM collab_requests + WHERE (sender_id = ${session.userId} AND receiver_id = ${receiverId} AND status = 'pending') + OR (sender_id = ${receiverId} AND receiver_id = ${session.userId} AND status = 'pending') + `; + if (existingRows.length > 0) { + return NextResponse.json({ error: "A pending request already exists between you" }, { status: 409 }); + } + + // 6. Create request + const { rows } = await sql` + INSERT INTO collab_requests (sender_id, receiver_id, message, proposed_date) + VALUES (${session.userId}, ${receiverId}, ${message}, ${proposed_date}) + RETURNING * + `; + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + console.error("[collab:POST] Error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/collections/[id]/items/[itemId]/route.ts b/app/api/routes-f/collections/[id]/items/[itemId]/route.ts new file mode 100644 index 00000000..08352f92 --- /dev/null +++ b/app/api/routes-f/collections/[id]/items/[itemId]/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { uuidSchema } from "@/app/api/routes-f/_lib/schemas"; +import { ensureCollectionsSchema } from "../../../_lib/db"; + +interface RouteParams { + params: + | Promise<{ id: string; itemId: string }> + | { id: string; itemId: string }; +} + +function validateId(id: string, label: string): NextResponse | null { + const result = uuidSchema.safeParse(id); + if (!result.success) { + return NextResponse.json({ error: `Invalid ${label}` }, { status: 400 }); + } + return null; +} + +export async function DELETE( + req: NextRequest, + context: RouteParams +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { id, itemId } = await context.params; + + const collectionIdError = validateId(id, "collection id"); + if (collectionIdError) { + return collectionIdError; + } + + const itemIdError = validateId(itemId, "item id"); + if (itemIdError) { + return itemIdError; + } + + try { + await ensureCollectionsSchema(); + + const { rows: ownerRows } = await sql` + SELECT id + FROM route_f_collections + WHERE id = ${id} + AND user_id = ${session.userId} + LIMIT 1 + `; + + if (ownerRows.length === 0) { + return NextResponse.json( + { error: "Collection not found" }, + { status: 404 } + ); + } + + const { rows } = await sql` + DELETE FROM route_f_collection_items + WHERE collection_id = ${id} + AND item_id = ${itemId} + RETURNING item_id + `; + + if (rows.length === 0) { + return NextResponse.json( + { error: "Collection item not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ item_id: rows[0].item_id, deleted: true }); + } catch (error) { + console.error("[collections/[id]/items/[itemId]] DELETE error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/collections/[id]/items/route.ts b/app/api/routes-f/collections/[id]/items/route.ts new file mode 100644 index 00000000..7fb8503b --- /dev/null +++ b/app/api/routes-f/collections/[id]/items/route.ts @@ -0,0 +1,116 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { uuidSchema } from "@/app/api/routes-f/_lib/schemas"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { COLLECTION_ITEM_TYPES, ensureCollectionsSchema } from "../../_lib/db"; + +interface RouteParams { + params: Promise<{ id: string }> | { id: string }; +} + +const addCollectionItemSchema = z.object({ + item_id: uuidSchema, + item_type: z.enum(COLLECTION_ITEM_TYPES), +}); + +function validateId(id: string): NextResponse | null { + const result = uuidSchema.safeParse(id); + if (!result.success) { + return NextResponse.json( + { error: "Invalid collection id" }, + { status: 400 } + ); + } + return null; +} + +export async function POST( + req: NextRequest, + context: RouteParams +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { id } = await context.params; + + const idError = validateId(id); + if (idError) { + return idError; + } + + const bodyResult = await validateBody(req, addCollectionItemSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { item_id, item_type } = bodyResult.data; + + try { + await ensureCollectionsSchema(); + + const { rows: ownerRows } = await sql` + SELECT id + FROM route_f_collections + WHERE id = ${id} + AND user_id = ${session.userId} + LIMIT 1 + `; + + if (ownerRows.length === 0) { + return NextResponse.json( + { error: "Collection not found" }, + { status: 404 } + ); + } + + const { rows: countRows } = await sql` + SELECT COUNT(*)::int AS item_count + FROM route_f_collection_items + WHERE collection_id = ${id} + `; + + if (Number(countRows[0]?.item_count ?? 0) >= 50) { + return NextResponse.json( + { error: "Collections may only contain 50 items" }, + { status: 409 } + ); + } + + const { rows: itemRows } = await sql` + SELECT id, title, playback_id, status + FROM stream_recordings + WHERE id = ${item_id} + LIMIT 1 + `; + + if (itemRows.length === 0) { + return NextResponse.json({ error: "Item not found" }, { status: 404 }); + } + + const { rows } = await sql` + INSERT INTO route_f_collection_items (collection_id, item_id, item_type) + VALUES (${id}, ${item_id}, ${item_type}) + ON CONFLICT (collection_id, item_id, item_type) DO NOTHING + RETURNING collection_id, item_id, item_type, created_at + `; + + if (rows.length === 0) { + return NextResponse.json( + { error: "Item already exists in this collection" }, + { status: 409 } + ); + } + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + console.error("[collections/[id]/items] POST error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/collections/[id]/route.ts b/app/api/routes-f/collections/[id]/route.ts new file mode 100644 index 00000000..c180e744 --- /dev/null +++ b/app/api/routes-f/collections/[id]/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { uuidSchema } from "@/app/api/routes-f/_lib/schemas"; +import { ensureCollectionsSchema } from "../_lib/db"; + +interface RouteParams { + params: Promise<{ id: string }> | { id: string }; +} + +function validateId(id: string): NextResponse | null { + const result = uuidSchema.safeParse(id); + if (!result.success) { + return NextResponse.json( + { error: "Invalid collection id" }, + { status: 400 } + ); + } + return null; +} + +export async function DELETE( + req: NextRequest, + context: RouteParams +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { id } = await context.params; + + const idError = validateId(id); + if (idError) { + return idError; + } + + try { + await ensureCollectionsSchema(); + + const { rows } = await sql` + DELETE FROM route_f_collections + WHERE id = ${id} + AND user_id = ${session.userId} + RETURNING id + `; + + if (rows.length === 0) { + return NextResponse.json( + { error: "Collection not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ id: rows[0].id, deleted: true }); + } catch (error) { + console.error("[collections/[id]] DELETE error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/collections/__tests__/route.test.ts b/app/api/routes-f/collections/__tests__/route.test.ts new file mode 100644 index 00000000..0d278ee8 --- /dev/null +++ b/app/api/routes-f/collections/__tests__/route.test.ts @@ -0,0 +1,206 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +jest.mock("../_lib/db", () => ({ + ensureCollectionsSchema: jest.fn().mockResolvedValue(undefined), + COLLECTION_VISIBILITIES: ["public", "private"], + COLLECTION_ITEM_TYPES: ["clip", "recording"], +})); + +jest.mock("../../collections/_lib/db", () => ({ + ensureCollectionsSchema: jest.fn().mockResolvedValue(undefined), + COLLECTION_VISIBILITIES: ["public", "private"], + COLLECTION_ITEM_TYPES: ["clip", "recording"], +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { GET, POST } from "../route"; +import { DELETE as DELETE_COLLECTION } from "../[id]/route"; +import { POST as ADD_ITEM } from "../[id]/items/route"; +import { DELETE as DELETE_ITEM } from "../[id]/items/[itemId]/route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; +const COLLECTION_ID = "550e8400-e29b-41d4-a716-446655440000"; +const ITEM_ID = "550e8400-e29b-41d4-a716-446655440001"; + +function makeRequest(method: string, path: string, body?: object) { + return new Request(`http://localhost${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; +} + +describe("routes-f collections", () => { + beforeEach(() => { + jest.clearAllMocks(); + verifySessionMock.mockResolvedValue({ + ok: true, + userId: "user-id", + wallet: null, + privyId: "did:privy:abc", + username: "alice", + email: "alice@example.com", + }); + jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("lists public collections for a creator profile", async () => { + sqlMock.mockResolvedValueOnce({ + rows: [ + { + id: COLLECTION_ID, + name: "Best moments", + visibility: "public", + username: "alice", + item_count: 4, + }, + ], + }); + + const res = await GET( + makeRequest("GET", "/api/routes-f/collections?creator=alice") + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.collections).toHaveLength(1); + expect(json.collections[0].visibility).toBe("public"); + }); + + it("rejects creating more than 20 collections", async () => { + sqlMock.mockResolvedValueOnce({ rows: [{ collection_count: 20 }] }); + + const res = await POST( + makeRequest("POST", "/api/routes-f/collections", { + name: "Playlist", + visibility: "private", + }) + ); + + expect(res.status).toBe(409); + }); + + it("creates a collection", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [{ collection_count: 2 }] }) + .mockResolvedValueOnce({ + rows: [ + { + id: COLLECTION_ID, + user_id: "user-id", + name: "Playlist", + visibility: "public", + created_at: "2026-03-27T00:00:00Z", + updated_at: "2026-03-27T00:00:00Z", + }, + ], + }); + + const res = await POST( + makeRequest("POST", "/api/routes-f/collections", { + name: "Playlist", + visibility: "public", + }) + ); + + expect(res.status).toBe(201); + }); + + it("rejects adding items once a collection has 50 items", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [{ id: COLLECTION_ID }] }) + .mockResolvedValueOnce({ rows: [{ item_count: 50 }] }); + + const res = await ADD_ITEM( + makeRequest("POST", `/api/routes-f/collections/${COLLECTION_ID}/items`, { + item_id: ITEM_ID, + item_type: "clip", + }), + { params: { id: COLLECTION_ID } } + ); + + expect(res.status).toBe(409); + }); + + it("adds an item to a collection", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [{ id: COLLECTION_ID }] }) + .mockResolvedValueOnce({ rows: [{ item_count: 3 }] }) + .mockResolvedValueOnce({ + rows: [ + { id: ITEM_ID, title: "Clip", playback_id: "p", status: "ready" }, + ], + }) + .mockResolvedValueOnce({ + rows: [ + { + collection_id: COLLECTION_ID, + item_id: ITEM_ID, + item_type: "clip", + created_at: "2026-03-27T00:00:00Z", + }, + ], + }); + + const res = await ADD_ITEM( + makeRequest("POST", `/api/routes-f/collections/${COLLECTION_ID}/items`, { + item_id: ITEM_ID, + item_type: "clip", + }), + { params: { id: COLLECTION_ID } } + ); + + expect(res.status).toBe(201); + }); + + it("removes an item from a collection", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [{ id: COLLECTION_ID }] }) + .mockResolvedValueOnce({ rows: [{ item_id: ITEM_ID }] }); + + const res = await DELETE_ITEM( + makeRequest( + "DELETE", + `/api/routes-f/collections/${COLLECTION_ID}/items/${ITEM_ID}` + ), + { params: { id: COLLECTION_ID, itemId: ITEM_ID } } + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.deleted).toBe(true); + }); + + it("deletes a collection", async () => { + sqlMock.mockResolvedValueOnce({ rows: [{ id: COLLECTION_ID }] }); + + const res = await DELETE_COLLECTION( + makeRequest("DELETE", `/api/routes-f/collections/${COLLECTION_ID}`), + { params: { id: COLLECTION_ID } } + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.deleted).toBe(true); + }); +}); diff --git a/app/api/routes-f/collections/_lib/db.ts b/app/api/routes-f/collections/_lib/db.ts new file mode 100644 index 00000000..158cbec8 --- /dev/null +++ b/app/api/routes-f/collections/_lib/db.ts @@ -0,0 +1,37 @@ +import { sql } from "@vercel/postgres"; + +export const COLLECTION_VISIBILITIES = ["public", "private"] as const; +export const COLLECTION_ITEM_TYPES = ["clip", "recording"] as const; + +export async function ensureCollectionsSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS route_f_collections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(120) NOT NULL, + visibility TEXT NOT NULL DEFAULT 'private', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + + await sql` + CREATE TABLE IF NOT EXISTS route_f_collection_items ( + collection_id UUID NOT NULL REFERENCES route_f_collections(id) ON DELETE CASCADE, + item_id UUID NOT NULL REFERENCES stream_recordings(id) ON DELETE CASCADE, + item_type TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (collection_id, item_id, item_type) + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_route_f_collections_user_visibility + ON route_f_collections (user_id, visibility, created_at DESC) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_route_f_collection_items_collection + ON route_f_collection_items (collection_id, created_at DESC) + `; +} diff --git a/app/api/routes-f/collections/route.ts b/app/api/routes-f/collections/route.ts new file mode 100644 index 00000000..a3d449d6 --- /dev/null +++ b/app/api/routes-f/collections/route.ts @@ -0,0 +1,123 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { usernameSchema } from "@/app/api/routes-f/_lib/schemas"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { COLLECTION_VISIBILITIES, ensureCollectionsSchema } from "./_lib/db"; + +const createCollectionSchema = z.object({ + name: z.string().trim().min(1).max(120), + visibility: z.enum(COLLECTION_VISIBILITIES), +}); + +const publicCollectionsQuerySchema = z.object({ + creator: usernameSchema.optional(), +}); + +export async function GET(req: NextRequest): Promise { + const { searchParams } = new URL(req.url); + const queryResult = validateQuery(searchParams, publicCollectionsQuerySchema); + if (queryResult instanceof Response) { + return queryResult; + } + + try { + await ensureCollectionsSchema(); + + if (queryResult.data.creator) { + const { rows } = await sql` + SELECT + c.id, + c.name, + c.visibility, + c.created_at, + c.updated_at, + u.username, + COUNT(ci.item_id)::int AS item_count + FROM route_f_collections c + JOIN users u ON u.id = c.user_id + LEFT JOIN route_f_collection_items ci ON ci.collection_id = c.id + WHERE LOWER(u.username) = LOWER(${queryResult.data.creator}) + AND c.visibility = 'public' + GROUP BY c.id, u.username + ORDER BY c.created_at DESC + `; + + return NextResponse.json({ collections: rows }); + } + + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { rows } = await sql` + SELECT + c.id, + c.name, + c.visibility, + c.created_at, + c.updated_at, + COUNT(ci.item_id)::int AS item_count + FROM route_f_collections c + LEFT JOIN route_f_collection_items ci ON ci.collection_id = c.id + WHERE c.user_id = ${session.userId} + GROUP BY c.id + ORDER BY c.created_at DESC + `; + + return NextResponse.json({ collections: rows }); + } catch (error) { + console.error("[collections] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, createCollectionSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { name, visibility } = bodyResult.data; + + try { + await ensureCollectionsSchema(); + + const { rows: countRows } = await sql` + SELECT COUNT(*)::int AS collection_count + FROM route_f_collections + WHERE user_id = ${session.userId} + `; + + if (Number(countRows[0]?.collection_count ?? 0) >= 20) { + return NextResponse.json( + { error: "Users may only have 20 collections" }, + { status: 409 } + ); + } + + const { rows } = await sql` + INSERT INTO route_f_collections (user_id, name, visibility) + VALUES (${session.userId}, ${name}, ${visibility}) + RETURNING id, user_id, name, visibility, created_at, updated_at + `; + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + console.error("[collections] POST error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/comments/[id]/route.ts b/app/api/routes-f/comments/[id]/route.ts new file mode 100644 index 00000000..457b4f6f --- /dev/null +++ b/app/api/routes-f/comments/[id]/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { ensureRoutesFSchema } from "../../_lib/schema"; + +/** + * DELETE /api/routes-f/comments/[id] — remove own comment or creator-delete any comment on their recording. + */ +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + await ensureRoutesFSchema(); + const session = await verifySession(req); + if (!session.ok) return session.response; + + const { id } = await params; + + // Fetch comment and associated recording creator + const { rows } = await sql` + SELECT c.user_id, c.recording_id, r.user_id as creator_id + FROM vod_comments c + LEFT JOIN stream_recordings r ON (c.recording_id = r.id::text OR c.recording_id = r.playback_id) + WHERE c.id = ${id} + LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ error: "Comment not found" }, { status: 404 }); + } + + const comment = rows[0]; + + // Authorized if either user is the comment author OR user is the recording creator + const isAuthor = session.userId === comment.user_id; + const isCreator = session.userId === comment.creator_id; + + if (!isAuthor && !isCreator) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + await sql`DELETE FROM vod_comments WHERE id = ${id}`; + + return NextResponse.json({ message: "Comment deleted successfully" }); + } catch (error) { + console.error("Comment DELETE error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/comments/route.ts b/app/api/routes-f/comments/route.ts new file mode 100644 index 00000000..12c2e6c3 --- /dev/null +++ b/app/api/routes-f/comments/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { ensureRoutesFSchema } from "../_lib/schema"; + +/** + * VOD Comments endpoint. + */ + +// GET /api/routes-f/comments?recording_id= — list comments sorted by timestamp +export async function GET(req: NextRequest) { + try { + await ensureRoutesFSchema(); + const { searchParams } = new URL(req.url); + const recordingId = searchParams.get("recording_id"); + + if (!recordingId) { + return NextResponse.json({ error: "recording_id is required" }, { status: 400 }); + } + + const { rows } = await sql` + SELECT c.id, u.username, u.avatar, c.timestamp_seconds, c.body, c.created_at + FROM vod_comments c + JOIN users u ON c.user_id = u.id + WHERE c.recording_id = ${recordingId} + ORDER BY c.timestamp_seconds ASC + `; + + return NextResponse.json({ comments: rows }); + } catch (error) { + console.error("Comments GET error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// POST /api/routes-f/comments — add a comment +export async function POST(req: NextRequest) { + try { + await ensureRoutesFSchema(); + const session = await verifySession(req); + if (!session.ok) return session.response; // 401 for unauthenticated + + const { recording_id, timestamp_seconds, body } = await req.json(); + + if (!recording_id || timestamp_seconds === undefined || !body) { + return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); + } + + // Check recording duration + const rec = await sql` + SELECT duration FROM stream_recordings WHERE id::text = ${recording_id} OR playback_id = ${recording_id} + LIMIT 1 + `; + + if (rec.rows.length > 0 && timestamp_seconds > rec.rows[0].duration) { + return NextResponse.json({ error: "Timestamp exceeds recording duration" }, { status: 400 }); + } + + const { rows } = await sql` + INSERT INTO vod_comments (user_id, recording_id, timestamp_seconds, body) + VALUES (${session.userId}, ${recording_id}, ${timestamp_seconds}, ${body}) + RETURNING * + `; + + return NextResponse.json(rows[0]); + } catch (error) { + console.error("Comments POST error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/conflicts/__tests__/check.test.ts b/app/api/routes-f/conflicts/__tests__/check.test.ts new file mode 100644 index 00000000..031f4a4f --- /dev/null +++ b/app/api/routes-f/conflicts/__tests__/check.test.ts @@ -0,0 +1,108 @@ +/** + * Tests for POST /api/routes-f/conflicts/check + */ + +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/app/api/routes-f/conflicts/_lib/reserved", () => ({ + classifyRestriction: jest.fn(), +})); + +import { sql } from "@vercel/postgres"; +import { classifyRestriction } from "../_lib/reserved"; +import { POST } from "../check/route"; + +const sqlMock = sql as unknown as jest.Mock; +const classifyMock = classifyRestriction as jest.Mock; + +function makeRequest(body: object) { + return new Request("http://localhost/api/routes-f/conflicts/check", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) as unknown as import("next/server").NextRequest; +} + +describe("POST /api/routes-f/conflicts/check", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns 400 for missing username", async () => { + const res = await POST(makeRequest({})); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error).toBe("Validation failed"); + }); + + it("returns 400 for a username that is too short", async () => { + const res = await POST(makeRequest({ username: "ab" })); + expect(res.status).toBe(400); + }); + + it("returns available: true when username is free and unrestricted", async () => { + classifyMock.mockResolvedValue(null); + sqlMock.mockResolvedValue({ rows: [] }); + + const res = await POST(makeRequest({ username: "alice99" })); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.available).toBe(true); + }); + + it("returns available: false with reason 'taken' when username exists in DB", async () => { + classifyMock.mockResolvedValue(null); + // First sql call: check availability in users table → taken + // Subsequent calls: suggestion availability checks → all free + sqlMock + .mockResolvedValueOnce({ rows: [{ id: "user-1" }] }) // username exists + .mockResolvedValue({ rows: [] }); // suggestions are free + + const res = await POST(makeRequest({ username: "alice" })); + const json = await res.json(); + expect(json.available).toBe(false); + expect(json.reason).toBe("taken"); + expect(Array.isArray(json.suggestions)).toBe(true); + }); + + it("returns available: false with reason 'reserved' for reserved words", async () => { + classifyMock.mockResolvedValue("reserved"); + sqlMock.mockResolvedValue({ rows: [] }); // suggestions are free + + const res = await POST(makeRequest({ username: "admin" })); + const json = await res.json(); + expect(json.available).toBe(false); + expect(json.reason).toBe("reserved"); + }); + + it("returns available: false with reason 'banned' for banned words", async () => { + classifyMock.mockResolvedValue("banned"); + sqlMock.mockResolvedValue({ rows: [] }); // suggestions are free + + const res = await POST(makeRequest({ username: "badword" })); + const json = await res.json(); + expect(json.available).toBe(false); + expect(json.reason).toBe("banned"); + }); + + it("returns 500 on database error", async () => { + classifyMock.mockRejectedValue(new Error("DB down")); + + const consoleSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + const res = await POST(makeRequest({ username: "alice99" })); + expect(res.status).toBe(500); + consoleSpy.mockRestore(); + }); +}); diff --git a/app/api/routes-f/conflicts/__tests__/resolve.test.ts b/app/api/routes-f/conflicts/__tests__/resolve.test.ts new file mode 100644 index 00000000..1137fe79 --- /dev/null +++ b/app/api/routes-f/conflicts/__tests__/resolve.test.ts @@ -0,0 +1,178 @@ +/** + * Tests for POST /api/routes-f/conflicts/resolve + */ + +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +jest.mock("../_lib/disputes", () => ({ + ensureDisputesTable: jest.fn().mockResolvedValue(undefined), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { POST } from "../resolve/route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +const ADMIN_SESSION = { + ok: true as const, + userId: "admin-id", + wallet: null, + privyId: "did:privy:admin", + username: "admin", + email: "admin@streamfi.xyz", +}; + +const VALID_UUID = "550e8400-e29b-41d4-a716-446655440000"; + +function makeRequest(body: object) { + return new Request("http://localhost/api/routes-f/conflicts/resolve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) as unknown as import("next/server").NextRequest; +} + +describe("POST /api/routes-f/conflicts/resolve", () => { + let consoleSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + verifySessionMock.mockResolvedValue(ADMIN_SESSION); + // Default admin check: is admin + sqlMock.mockResolvedValueOnce({ rows: [{ 1: 1 }] }); + }); + + afterEach(() => consoleSpy.mockRestore()); + + it("returns 401 when not authenticated", async () => { + verifySessionMock.mockResolvedValue({ + ok: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }), + }); + const res = await POST( + makeRequest({ + claimed_username: "alice", + claimant_user_id: VALID_UUID, + reason: "r", + action: "deny", + }) + ); + expect(res.status).toBe(401); + }); + + it("returns 403 when caller is not admin", async () => { + // Override: not an admin + sqlMock.mockReset(); + sqlMock.mockResolvedValueOnce({ rows: [] }); // no admin row + const res = await POST( + makeRequest({ + claimed_username: "alice", + claimant_user_id: VALID_UUID, + reason: "r", + action: "deny", + }) + ); + expect(res.status).toBe(403); + }); + + it("returns 400 for missing required fields", async () => { + const res = await POST(makeRequest({ claimed_username: "alice" })); + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error).toBe("Validation failed"); + }); + + it("returns 400 for invalid action", async () => { + const res = await POST( + makeRequest({ + claimed_username: "alice", + claimant_user_id: VALID_UUID, + reason: "r", + action: "steal", + }) + ); + expect(res.status).toBe(400); + }); + + it("records denied dispute and returns action: deny", async () => { + // admin check ✓ (already mocked in beforeEach) + // claimant exists + sqlMock.mockResolvedValueOnce({ + rows: [{ id: VALID_UUID, username: "claimant" }], + }); + // INSERT dispute + sqlMock.mockResolvedValueOnce({ rows: [] }); + + const res = await POST( + makeRequest({ + claimed_username: "alice", + claimant_user_id: VALID_UUID, + reason: "Not a valid claim", + action: "deny", + }) + ); + const json = await res.json(); + expect(json.action).toBe("deny"); + expect(json.username).toBe("alice"); + }); + + it("transfers username and renames holder atomically", async () => { + // claimant exists + sqlMock.mockResolvedValueOnce({ + rows: [{ id: VALID_UUID, username: "claimant" }], + }); + // holder exists + sqlMock.mockResolvedValueOnce({ + rows: [{ id: "holder-id", username: "alice" }], + }); + // BEGIN, UPDATE holder, UPDATE claimant, INSERT dispute, COMMIT + sqlMock.mockResolvedValue({ rows: [] }); + + const res = await POST( + makeRequest({ + claimed_username: "alice", + claimant_user_id: VALID_UUID, + reason: "Original creator", + action: "transfer", + }) + ); + const json = await res.json(); + expect(json.action).toBe("transfer"); + expect(json.username).toBe("alice"); + expect(json.previous_holder_renamed_to).toBe("alice_"); + }); + + it("returns 404 when claimant user does not exist", async () => { + // claimant not found + sqlMock.mockResolvedValueOnce({ rows: [] }); + + const res = await POST( + makeRequest({ + claimed_username: "alice", + claimant_user_id: VALID_UUID, + reason: "claim", + action: "transfer", + }) + ); + expect(res.status).toBe(404); + }); +}); diff --git a/app/api/routes-f/conflicts/_lib/disputes.ts b/app/api/routes-f/conflicts/_lib/disputes.ts new file mode 100644 index 00000000..328e5309 --- /dev/null +++ b/app/api/routes-f/conflicts/_lib/disputes.ts @@ -0,0 +1,22 @@ +/** + * DB helpers for username disputes. + */ + +import { sql } from "@vercel/postgres"; + +export async function ensureDisputesTable(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS username_disputes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + claimed_username TEXT NOT NULL, + claimant_user_id UUID NOT NULL REFERENCES users(id), + reason TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'resolved', 'denied')), + resolved_by UUID REFERENCES users(id), + resolved_action TEXT CHECK (resolved_action IN ('transfer', 'deny')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + resolved_at TIMESTAMPTZ + ) + `; +} diff --git a/app/api/routes-f/conflicts/_lib/reserved.ts b/app/api/routes-f/conflicts/_lib/reserved.ts new file mode 100644 index 00000000..9aac990d --- /dev/null +++ b/app/api/routes-f/conflicts/_lib/reserved.ts @@ -0,0 +1,78 @@ +/** + * Reserved and blocked username lists. + * + * Static lists cover well-known platform routes and brand terms. + * The runtime `isBlocklisted()` helper also checks the DB for dynamically + * managed offensive terms stored in the `username_blocklist` table. + */ + +import { sql } from "@vercel/postgres"; + +/** Platform route names that cannot be used as usernames. */ +const RESERVED_WORDS = new Set([ + "admin", + "api", + "dashboard", + "settings", + "explore", + "browse", + "onboarding", + "login", + "logout", + "signup", + "register", + "profile", + "search", + "help", + "support", + "official", + "streamfi", + "about", + "contact", + "terms", + "privacy", + "status", + "404", + "500", +]); + +/** + * Returns 'reserved' if the username matches a platform route or brand term, + * 'banned' if it appears in the DB blocklist, or null if it is clean. + */ +export async function classifyRestriction( + username: string +): Promise<"reserved" | "banned" | null> { + const lower = username.toLowerCase(); + + if (RESERVED_WORDS.has(lower)) { + return "reserved"; + } + + try { + await ensureBlocklistTable(); + const { rows } = await sql` + SELECT 1 FROM username_blocklist + WHERE word = ${lower} + LIMIT 1 + `; + if (rows.length > 0) { + return "banned"; + } + } catch (err) { + console.error("[conflicts] Error checking blocklist:", err); + // Fail open — do not block a username because of a DB error + } + + return null; +} + +/** Ensures the DB-backed blocklist table exists. */ +async function ensureBlocklistTable(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS username_blocklist ( + word TEXT PRIMARY KEY, + added_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; +} diff --git a/app/api/routes-f/conflicts/check/route.ts b/app/api/routes-f/conflicts/check/route.ts new file mode 100644 index 00000000..649057ea --- /dev/null +++ b/app/api/routes-f/conflicts/check/route.ts @@ -0,0 +1,115 @@ +/** + * POST /api/routes-f/conflicts/check + * + * Public endpoint — no auth required. + * + * Check whether a username is available and return up to 4 alternative + * suggestions when it is not. + * + * Request body: { "username": "alice" } + * + * Response (available): + * { "available": true } + * + * Response (unavailable): + * { "available": false, "reason": "taken" | "reserved" | "banned", + * "suggestions": ["alice_streams", "alice_tv", "thealice", "alice42"] } + */ + +import { randomInt } from "crypto"; +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { usernameSchema } from "@/app/api/routes-f/_lib/schemas"; +import { classifyRestriction } from "../_lib/reserved"; + +const checkBodySchema = z.object({ + username: usernameSchema, +}); + +/** Returns true when the username does not exist in users table and is not restricted. */ +async function isAvailable(username: string): Promise { + const restriction = await classifyRestriction(username); + if (restriction !== null) { + return false; + } + + const { rows } = await sql` + SELECT 1 FROM users WHERE LOWER(username) = LOWER(${username}) LIMIT 1 + `; + return rows.length === 0; +} + +/** Generates up to 4 available username suggestions for a taken/restricted username. */ +async function buildSuggestions(base: string): Promise { + const randomTwoDigit = () => String(randomInt(10, 100)); + + const candidates = [ + `${base}_streams`, + `${base}_tv`, + `the${base}`, + `${base}${randomTwoDigit()}`, + ]; + + const available: string[] = []; + for (const candidate of candidates) { + // Skip if the candidate itself is too long for the username schema + if (candidate.length > 30) { + continue; + } + try { + if (await isAvailable(candidate)) { + available.push(candidate); + } + } catch { + // DB error on a suggestion — skip silently + } + } + + return available; +} + +export async function POST(req: NextRequest): Promise { + const bodyResult = await validateBody(req, checkBodySchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { username } = bodyResult.data; + + try { + // 1. Check static/DB restrictions first + const restriction = await classifyRestriction(username); + if (restriction !== null) { + const suggestions = await buildSuggestions(username); + return NextResponse.json({ + available: false, + reason: restriction, + suggestions, + }); + } + + // 2. Check database availability + const { rows } = await sql` + SELECT 1 FROM users WHERE LOWER(username) = LOWER(${username}) LIMIT 1 + `; + + if (rows.length > 0) { + const suggestions = await buildSuggestions(username); + return NextResponse.json({ + available: false, + reason: "taken", + suggestions, + }); + } + + return NextResponse.json({ available: true }); + } catch (err) { + console.error("[conflicts/check] DB error:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/conflicts/resolve/route.ts b/app/api/routes-f/conflicts/resolve/route.ts new file mode 100644 index 00000000..2580b4bb --- /dev/null +++ b/app/api/routes-f/conflicts/resolve/route.ts @@ -0,0 +1,162 @@ +/** + * POST /api/routes-f/conflicts/resolve + * + * Admin-only: resolve a username dispute. + * Requires the caller to be an admin (checked via users.is_admin flag). + * + * Request body: + * { + * "claimed_username": "alice", + * "claimant_user_id": "uuid", + * "reason": "Original creator migrating from Twitch with 50k followers", + * "action": "transfer" | "deny" + * } + * + * On "transfer": + * - Rename the current username holder to "{username}_" (atomically, in a DB transaction) + * - Assign the username to the claimant + * - Mark dispute as resolved + * + * On "deny": + * - Mark dispute as denied; no username changes occur + */ + +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { usernameSchema, uuidSchema } from "@/app/api/routes-f/_lib/schemas"; +import { ensureDisputesTable } from "../_lib/disputes"; + +const resolveBodySchema = z.object({ + claimed_username: usernameSchema, + claimant_user_id: uuidSchema, + reason: z.string().min(1, "reason is required").max(1000), + action: z.enum(["transfer", "deny"]), +}); + +export async function POST(req: NextRequest): Promise { + // 1. Auth + admin check + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + const adminCheck = await sql` + SELECT 1 FROM users WHERE id = ${session.userId} AND is_admin = TRUE LIMIT 1 + `; + if (adminCheck.rows.length === 0) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + } catch (err) { + console.error("[conflicts/resolve] DB error checking admin:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + + // 2. Validate body + const bodyResult = await validateBody(req, resolveBodySchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { claimed_username, claimant_user_id, reason, action } = + bodyResult.data; + + try { + await ensureDisputesTable(); + + // 3. Verify claimant exists + const { rows: claimantRows } = await sql` + SELECT id, username FROM users WHERE id = ${claimant_user_id} LIMIT 1 + `; + if (claimantRows.length === 0) { + return NextResponse.json( + { error: "Claimant user not found" }, + { status: 404 } + ); + } + + if (action === "deny") { + // Record the denied dispute and return + await sql` + INSERT INTO username_disputes + (claimed_username, claimant_user_id, reason, status, resolved_by, resolved_action, resolved_at) + VALUES + (${claimed_username}, ${claimant_user_id}, ${reason}, 'denied', ${session.userId}, 'deny', now()) + `; + + return NextResponse.json({ action: "deny", username: claimed_username }); + } + + // 4. Transfer: find the current holder + const { rows: holderRows } = await sql` + SELECT id, username FROM users + WHERE LOWER(username) = LOWER(${claimed_username}) + LIMIT 1 + `; + + if (holderRows.length === 0) { + // Username is already free — just assign it to the claimant + await sql` + UPDATE users SET username = ${claimed_username} WHERE id = ${claimant_user_id} + `; + await sql` + INSERT INTO username_disputes + (claimed_username, claimant_user_id, reason, status, resolved_by, resolved_action, resolved_at) + VALUES + (${claimed_username}, ${claimant_user_id}, ${reason}, 'resolved', ${session.userId}, 'transfer', now()) + `; + return NextResponse.json({ + action: "transfer", + username: claimed_username, + }); + } + + const holderId: string = holderRows[0].id; + const holderUsername: string = holderRows[0].username; + const renamedUsername = `${holderUsername}_`; + + // 5. Atomic transfer using a transaction + // @vercel/postgres does not expose BEGIN/COMMIT directly, so we use + // a transaction via sql template tag chaining. + await sql`BEGIN`; + try { + // Rename the current holder + await sql` + UPDATE users SET username = ${renamedUsername} WHERE id = ${holderId} + `; + // Assign to claimant + await sql` + UPDATE users SET username = ${claimed_username} WHERE id = ${claimant_user_id} + `; + // Record dispute resolution + await sql` + INSERT INTO username_disputes + (claimed_username, claimant_user_id, reason, status, resolved_by, resolved_action, resolved_at) + VALUES + (${claimed_username}, ${claimant_user_id}, ${reason}, 'resolved', ${session.userId}, 'transfer', now()) + `; + await sql`COMMIT`; + } catch (txErr) { + await sql`ROLLBACK`; + throw txErr; + } + + return NextResponse.json({ + action: "transfer", + username: claimed_username, + previous_holder_renamed_to: renamedUsername, + }); + } catch (err) { + console.error("[conflicts/resolve] DB error:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/conflicts/route.ts b/app/api/routes-f/conflicts/route.ts new file mode 100644 index 00000000..719234de --- /dev/null +++ b/app/api/routes-f/conflicts/route.ts @@ -0,0 +1,73 @@ +/** + * GET /api/routes-f/conflicts + * + * Admin-only: list open username disputes. + * Requires the caller to be an admin (checked via users.is_admin flag). + * + * Query params: limit (1-100, default 20), cursor (dispute id for pagination) + * + * Response: + * { "disputes": [...], "next_cursor": "uuid" | null } + */ + +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { paginationSchema } from "@/app/api/routes-f/_lib/schemas"; +import { ensureDisputesTable } from "./_lib/disputes"; + +export async function GET(req: NextRequest): Promise { + // 1. Auth + admin check + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const adminCheck = await sql` + SELECT 1 FROM users WHERE id = ${session.userId} AND is_admin = TRUE LIMIT 1 + `; + if (adminCheck.rows.length === 0) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + // 2. Validate query params + const { searchParams } = new URL(req.url); + const queryResult = validateQuery(searchParams, paginationSchema); + if (queryResult instanceof Response) { + return queryResult; + } + + const { limit, cursor } = queryResult.data; + + // 3. Fetch disputes + try { + await ensureDisputesTable(); + + const { rows } = cursor + ? await sql` + SELECT id, claimed_username, claimant_user_id, reason, status, created_at + FROM username_disputes + WHERE status = 'open' AND id > ${cursor} + ORDER BY created_at ASC + LIMIT ${limit} + ` + : await sql` + SELECT id, claimed_username, claimant_user_id, reason, status, created_at + FROM username_disputes + WHERE status = 'open' + ORDER BY created_at ASC + LIMIT ${limit} + `; + + const nextCursor = rows.length === limit ? rows[rows.length - 1].id : null; + + return NextResponse.json({ disputes: rows, next_cursor: nextCursor }); + } catch (err) { + console.error("[conflicts] DB error listing disputes:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/content/rules/route.ts b/app/api/routes-f/content/rules/route.ts new file mode 100644 index 00000000..83c207c0 --- /dev/null +++ b/app/api/routes-f/content/rules/route.ts @@ -0,0 +1,128 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +const severityEnum = z.enum(["low", "medium", "high"]); + +const ruleSchema = z.object({ + id: z.string().trim().min(1).max(100), + title: z.string().trim().min(1).max(200), + description: z.string().trim().min(1).max(2000), + severity: severityEnum, +}); + +const patchRulesSchema = z.object({ + rules: z.array(ruleSchema).min(1).max(100), +}); + +const DEFAULT_RULES = [ + { + id: "no-harassment", + title: "No Harassment or Hate Speech", + description: "Content that targets individuals or groups with harassment, threats, or hate speech is strictly prohibited.", + severity: "high", + }, + { + id: "no-explicit-content", + title: "No Explicit or Adult Content", + description: "Sexually explicit content or nudity is not permitted on the platform.", + severity: "high", + }, + { + id: "no-spam", + title: "No Spam or Misleading Content", + description: "Repetitive, misleading, or deceptive content that degrades the viewer experience is not allowed.", + severity: "medium", + }, + { + id: "no-copyright", + title: "Respect Copyright", + description: "Do not stream or share content you do not have rights to broadcast.", + severity: "medium", + }, + { + id: "no-self-harm", + title: "No Self-Harm or Dangerous Activities", + description: "Content that promotes or depicts self-harm, dangerous stunts, or illegal activities is prohibited.", + severity: "high", + }, +]; + +async function ensureContentRulesSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS route_f_content_rules ( + id SERIAL PRIMARY KEY, + rules JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; +} + +async function isAdmin(userId: string): Promise { + const { rows } = await sql` + SELECT 1 FROM users WHERE id = ${userId} AND is_admin = TRUE LIMIT 1 + `; + return rows.length > 0; +} + +/** + * GET /api/routes-f/content/rules + * Public endpoint — returns current platform content moderation rules. + */ +export async function GET(_req: NextRequest): Promise { + try { + await ensureContentRulesSchema(); + + const { rows } = await sql` + SELECT rules, updated_at FROM route_f_content_rules ORDER BY id DESC LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ + rules: DEFAULT_RULES.map(r => ({ ...r, updated_at: new Date().toISOString() })), + }); + } + + return NextResponse.json({ rules: rows[0].rules, updated_at: rows[0].updated_at }); + } catch (error) { + console.error("[routes-f content/rules GET]", error); + return NextResponse.json({ error: "Failed to fetch content rules" }, { status: 500 }); + } +} + +/** + * PATCH /api/routes-f/content/rules + * Admin-only — update platform content moderation rules. + */ +export async function PATCH(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const admin = await isAdmin(session.userId); + if (!admin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const bodyResult = await validateBody(req, patchRulesSchema); + if (bodyResult instanceof Response) return bodyResult; + + const now = new Date().toISOString(); + const rules = bodyResult.data.rules.map((r: z.infer) => ({ ...r, updated_at: now })); + + try { + await ensureContentRulesSchema(); + + const { rows } = await sql` + INSERT INTO route_f_content_rules (rules, updated_at) + VALUES (${JSON.stringify(rules)}::jsonb, NOW()) + RETURNING rules, updated_at + `; + + return NextResponse.json({ rules: rows[0].rules, updated_at: rows[0].updated_at }); + } catch (error) { + console.error("[routes-f content/rules PATCH]", error); + return NextResponse.json({ error: "Failed to update content rules" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/creator/bio/_lib/db.ts b/app/api/routes-f/creator/bio/_lib/db.ts new file mode 100644 index 00000000..043634f1 --- /dev/null +++ b/app/api/routes-f/creator/bio/_lib/db.ts @@ -0,0 +1,20 @@ +import { sql } from "@vercel/postgres"; + +export async function ensureCreatorBioSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS route_f_creator_bios ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + bio_text TEXT, + social_links JSONB DEFAULT '[]'::jsonb, + schedule_text TEXT, + banner_url TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_route_f_creator_bios_user + ON route_f_creator_bios (user_id) + `; +} diff --git a/app/api/routes-f/creator/bio/route.ts b/app/api/routes-f/creator/bio/route.ts new file mode 100644 index 00000000..8d99865b --- /dev/null +++ b/app/api/routes-f/creator/bio/route.ts @@ -0,0 +1,127 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { usernameSchema, urlSchema } from "@/app/api/routes-f/_lib/schemas"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { ensureCreatorBioSchema } from "./_lib/db"; + +const socialLinkSchema = z.object({ + label: z.string().trim().min(1).max(50), + url: urlSchema, +}); + +const updateBioSchema = z.object({ + bio_text: z.string().trim().max(500).optional(), + social_links: z.array(socialLinkSchema).max(5).optional(), + schedule_text: z.string().trim().max(200).optional(), + banner_url: urlSchema.optional(), +}); + +const getBioQuerySchema = z.object({ + username: usernameSchema, +}); + +export async function GET(req: NextRequest): Promise { + const { searchParams } = new URL(req.url); + const queryResult = validateQuery(searchParams, getBioQuerySchema); + if (queryResult instanceof Response) { + return queryResult; + } + + try { + await ensureCreatorBioSchema(); + + const { rows: userRows } = await sql` + SELECT id FROM users WHERE username = ${queryResult.data.username} LIMIT 1 + `; + + if (userRows.length === 0) { + return NextResponse.json( + { error: "Creator not found" }, + { status: 404 } + ); + } + + const userId = userRows[0].id; + + const { rows } = await sql` + SELECT bio_text, social_links, schedule_text, banner_url, updated_at + FROM route_f_creator_bios + WHERE user_id = ${userId} + LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ + bio_text: null, + social_links: [], + schedule_text: null, + banner_url: null, + updated_at: null, + }); + } + + const bio = rows[0]; + return NextResponse.json({ + bio_text: bio.bio_text ?? null, + social_links: bio.social_links ?? [], + schedule_text: bio.schedule_text ?? null, + banner_url: bio.banner_url ?? null, + updated_at: bio.updated_at, + }); + } catch (error) { + console.error("[creator/bio] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function PATCH(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, updateBioSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + try { + await ensureCreatorBioSchema(); + + const { bio_text, social_links, schedule_text, banner_url } = + bodyResult.data; + + // Upsert bio record + const { rows } = await sql` + INSERT INTO route_f_creator_bios (user_id, bio_text, social_links, schedule_text, banner_url, updated_at) + VALUES (${session.userId}, ${bio_text ?? null}, ${JSON.stringify(social_links ?? [])}, ${schedule_text ?? null}, ${banner_url ?? null}, now()) + ON CONFLICT (user_id) DO UPDATE SET + bio_text = COALESCE(EXCLUDED.bio_text, route_f_creator_bios.bio_text), + social_links = COALESCE(EXCLUDED.social_links, route_f_creator_bios.social_links), + schedule_text = COALESCE(EXCLUDED.schedule_text, route_f_creator_bios.schedule_text), + banner_url = COALESCE(EXCLUDED.banner_url, route_f_creator_bios.banner_url), + updated_at = now() + RETURNING bio_text, social_links, schedule_text, banner_url, updated_at + `; + + const updated = rows[0]; + return NextResponse.json({ + bio_text: updated.bio_text ?? null, + social_links: updated.social_links ?? [], + schedule_text: updated.schedule_text ?? null, + banner_url: updated.banner_url ?? null, + updated_at: updated.updated_at, + }); + } catch (error) { + console.error("[creator/bio] PATCH error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} 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..408a1ed1 --- /dev/null +++ b/app/api/routes-f/creator/dashboard/__tests__/route.test.ts @@ -0,0 +1,102 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { + "Content-Type": "application/json", + ...(init?.headers || {}), + }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { GET } from "../route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +function makeRequest(path: string) { + return new Request(`http://localhost${path}`) as unknown as import("next/server").NextRequest; +} + +describe("Creator Dashboard Summary API", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, "error").mockImplementation(() => { }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("returns 401 for unauthenticated requests", async () => { + verifySessionMock.mockResolvedValueOnce({ + ok: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }) + }); + + const res = await GET(makeRequest("/api/routes-f/creator/dashboard")); + expect(res.status).toBe(401); + }); + + it("returns aggregated dashboard metrics in parallel", async () => { + verifySessionMock.mockResolvedValueOnce({ ok: true, userId: "creator-123" }); + + // Mock the 5 parallel queries + sqlMock + .mockResolvedValueOnce({ rows: [{ count: 150 }] }) // followers + .mockResolvedValueOnce({ rows: [{ total: "1250.50" }] }) // earnings + .mockResolvedValueOnce({ rows: [{ count: 12 }] }) // streams this month + .mockResolvedValueOnce({ rows: [{ avg: 45.678 }] }) // avg viewers + .mockResolvedValueOnce({ rows: [{ count: 25 }] }); // subscribers + + const res = await GET(makeRequest("/api/routes-f/creator/dashboard")); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data).toEqual({ + follower_count: 150, + total_earnings: "1250.50", + streams_this_month: 12, + avg_viewer_count: 45.68, + subscriber_count: 25, + pending_payouts: "0.00", + generated_at: expect.any(String), + }); + + expect(sqlMock).toHaveBeenCalledTimes(5); + expect(res.headers.get("Cache-Control")).toContain("public"); + expect(res.headers.get("Cache-Control")).toContain("s-maxage=60"); + }); + + it("handles missing data by returning defaults", async () => { + verifySessionMock.mockResolvedValueOnce({ ok: true, userId: "creator-456" }); + + sqlMock.mockResolvedValue({ rows: [] }); + + const res = await GET(makeRequest("/api/routes-f/creator/dashboard")); + const data = await res.json(); + + expect(res.status).toBe(200); + expect(data.follower_count).toBe(0); + expect(data.total_earnings).toBe("0.00"); + expect(data.avg_viewer_count).toBe(0); + }); + + it("returns 500 on database error", async () => { + verifySessionMock.mockResolvedValueOnce({ ok: true, userId: "creator-789" }); + sqlMock.mockRejectedValueOnce(new Error("DB Down")); + + const res = await GET(makeRequest("/api/routes-f/creator/dashboard")); + expect(res.status).toBe(500); + }); +}); 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..19473227 --- /dev/null +++ b/app/api/routes-f/creator/dashboard/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +/** + * GET /api/routes-f/creator/dashboard + * Returns a summary of key stats for the authenticated creator. + */ +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const userId = session.userId; + + try { + // Parallel queries for all dashboard metrics + const [ + followerResult, + earningsResult, + streamsMonthResult, + avgViewersResult, + subscriberResult, + ] = await Promise.all([ + // 1. Follower Count + sql`SELECT COUNT(*)::int AS count FROM user_follows WHERE followee_id = ${userId}`, + + // 2. Total Earnings (USDC) from gift_transactions + sql`SELECT COALESCE(SUM(amount_usdc), 0)::numeric(20,2)::text AS total FROM gift_transactions WHERE creator_id = ${userId}`, + + // 3. Streams This Month (Count of sessions started in current month) + sql`SELECT COUNT(*)::int AS count FROM stream_sessions WHERE user_id = ${userId} AND started_at >= date_trunc('month', now())`, + + // 4. Avg Viewer Count (Average of peak_viewers across all sessions) + sql`SELECT COALESCE(AVG(peak_viewers), 0)::float AS avg FROM stream_sessions WHERE user_id = ${userId}`, + + // 5. Subscriber Count (Active subscriptions) + sql`SELECT COUNT(*)::int AS count FROM subscriptions WHERE creator_id = ${userId} AND status = 'active' AND expires_at > NOW()`, + ]); + + const dashboardData = { + follower_count: followerResult.rows[0]?.count ?? 0, + total_earnings: earningsResult.rows[0]?.total ?? "0.00", + streams_this_month: streamsMonthResult.rows[0]?.count ?? 0, + avg_viewer_count: Math.round((avgViewersResult.rows[0]?.avg ?? 0) * 100) / 100, + subscriber_count: subscriberResult.rows[0]?.count ?? 0, + pending_payouts: "0.00", // Placeholder until payout logic is implemented + generated_at: new Date().toISOString(), + }; + + return NextResponse.json(dashboardData, { + headers: { + "Cache-Control": "public, s-maxage=60, stale-while-revalidate=30", + }, + }); + } catch (error) { + console.error("[creator dashboard] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/creator/team/[id]/route.ts b/app/api/routes-f/creator/team/[id]/route.ts new file mode 100644 index 00000000..80e423a2 --- /dev/null +++ b/app/api/routes-f/creator/team/[id]/route.ts @@ -0,0 +1,134 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { uuidSchema } from "@/app/api/routes-f/_lib/schemas"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +interface RouteParams { + params: Promise<{ id: string }> | { id: string }; +} + +const TEAM_ROLES = ["moderator", "editor"] as const; + +const updateTeamRoleSchema = z.object({ + role: z.enum(TEAM_ROLES), +}); + +async function ensureCreatorTeamSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS route_f_creator_team_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + member_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('moderator', 'editor')), + status TEXT NOT NULL DEFAULT 'invited' CHECK (status IN ('invited', 'active')), + invited_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (creator_id, member_id) + ) + `; +} + +function validateId(id: string): NextResponse | null { + const result = uuidSchema.safeParse(id); + if (!result.success) { + return NextResponse.json({ error: "Invalid team member id" }, { status: 400 }); + } + return null; +} + +export async function PATCH( + req: NextRequest, + context: RouteParams +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { id } = await context.params; + + const idError = validateId(id); + if (idError) { + return idError; + } + + const bodyResult = await validateBody(req, updateTeamRoleSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + try { + await ensureCreatorTeamSchema(); + + const { rows } = await sql` + UPDATE route_f_creator_team_members + SET role = ${bodyResult.data.role}, updated_at = NOW() + WHERE id = ${id} + AND creator_id = ${session.userId} + RETURNING id, creator_id, member_id, role, status, invited_at, updated_at + `; + + if (rows.length === 0) { + return NextResponse.json( + { error: "Team member not found" }, + { status: 404 } + ); + } + + return NextResponse.json(rows[0]); + } catch (error) { + console.error("[routes-f creator/team/[id] PATCH]", error); + return NextResponse.json( + { error: "Failed to update team member role" }, + { status: 500 } + ); + } +} + +export async function DELETE( + req: NextRequest, + context: RouteParams +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { id } = await context.params; + + const idError = validateId(id); + if (idError) { + return idError; + } + + try { + await ensureCreatorTeamSchema(); + + const { rows } = await sql` + DELETE FROM route_f_creator_team_members + WHERE id = ${id} + AND creator_id = ${session.userId} + RETURNING id + `; + + if (rows.length === 0) { + return NextResponse.json( + { error: "Team member not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ + id: rows[0].id, + deleted: true, + }); + } catch (error) { + console.error("[routes-f creator/team/[id] DELETE]", error); + return NextResponse.json( + { error: "Failed to remove team member" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/creator/team/__tests__/route.test.ts b/app/api/routes-f/creator/team/__tests__/route.test.ts new file mode 100644 index 00000000..65a838db --- /dev/null +++ b/app/api/routes-f/creator/team/__tests__/route.test.ts @@ -0,0 +1,186 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { GET, POST } from "../route"; +import { PATCH, DELETE } from "../[id]/route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +const AUTHED_SESSION = { + ok: true as const, + userId: "creator-id", + wallet: null, + privyId: "did:privy:abc", + username: "creator", + email: "creator@example.com", +}; + +const TEAM_ID = "550e8400-e29b-41d4-a716-446655440000"; + +function makeRequest(method: string, path: string, body?: object) { + return new Request(`http://localhost${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; +} + +describe("routes-f creator/team", () => { + beforeEach(() => { + jest.clearAllMocks(); + verifySessionMock.mockResolvedValue(AUTHED_SESSION); + }); + + it("lists creator team members", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ + rows: [ + { + id: TEAM_ID, + member_id: "member-id", + username: "moduser", + avatar: null, + role: "moderator", + status: "invited", + invited_at: "2026-03-28T00:00:00Z", + updated_at: "2026-03-28T00:00:00Z", + }, + ], + }); + + const res = await GET(makeRequest("GET", "/api/routes-f/creator/team")); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.count).toBe(1); + expect(json.team[0].role).toBe("moderator"); + }); + + it("enforces max 10 team members", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ team_size: 10 }] }); + + const res = await POST( + makeRequest("POST", "/api/routes-f/creator/team", { + username: "moduser", + role: "moderator", + }) + ); + + expect(res.status).toBe(409); + }); + + it("requires invited user to exist", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ team_size: 2 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await POST( + makeRequest("POST", "/api/routes-f/creator/team", { + username: "ghostuser", + role: "editor", + }) + ); + const json = await res.json(); + + expect(res.status).toBe(404); + expect(json.error).toMatch(/does not exist/i); + }); + + it("invites an existing user as a team member", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ team_size: 1 }] }) + .mockResolvedValueOnce({ + rows: [{ id: "member-id", username: "moduser", avatar: null }], + }) + .mockResolvedValueOnce({ + rows: [ + { + id: TEAM_ID, + creator_id: "creator-id", + member_id: "member-id", + role: "moderator", + status: "invited", + invited_at: "2026-03-28T00:00:00Z", + updated_at: "2026-03-28T00:00:00Z", + }, + ], + }); + + const res = await POST( + makeRequest("POST", "/api/routes-f/creator/team", { + username: "moduser", + role: "moderator", + }) + ); + const json = await res.json(); + + expect(res.status).toBe(201); + expect(json.role).toBe("moderator"); + expect(json.member.username).toBe("moduser"); + }); + + it("updates a team member role", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ + rows: [ + { + id: TEAM_ID, + creator_id: "creator-id", + member_id: "member-id", + role: "editor", + status: "invited", + invited_at: "2026-03-28T00:00:00Z", + updated_at: "2026-03-28T00:00:00Z", + }, + ], + }); + + const res = await PATCH( + makeRequest("PATCH", `/api/routes-f/creator/team/${TEAM_ID}`, { + role: "editor", + }), + { params: { id: TEAM_ID } } + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.role).toBe("editor"); + }); + + it("deletes a team member", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ id: TEAM_ID }] }); + + const res = await DELETE( + makeRequest("DELETE", `/api/routes-f/creator/team/${TEAM_ID}`), + { params: { id: TEAM_ID } } + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.deleted).toBe(true); + }); +}); diff --git a/app/api/routes-f/creator/team/route.ts b/app/api/routes-f/creator/team/route.ts new file mode 100644 index 00000000..73ca904a --- /dev/null +++ b/app/api/routes-f/creator/team/route.ts @@ -0,0 +1,160 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { usernameSchema } from "@/app/api/routes-f/_lib/schemas"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +const TEAM_ROLES = ["moderator", "editor"] as const; + +const inviteTeamMemberSchema = z.object({ + username: usernameSchema, + role: z.enum(TEAM_ROLES), +}); + +async function ensureCreatorTeamSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS route_f_creator_team_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + member_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('moderator', 'editor')), + status TEXT NOT NULL DEFAULT 'invited' CHECK (status IN ('invited', 'active')), + invited_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (creator_id, member_id) + ) + `; +} + +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + await ensureCreatorTeamSchema(); + + const { rows } = await sql` + SELECT + tm.id, + tm.member_id, + u.username, + u.avatar, + tm.role, + tm.status, + tm.invited_at, + tm.updated_at + FROM route_f_creator_team_members tm + JOIN users u ON u.id = tm.member_id + WHERE tm.creator_id = ${session.userId} + ORDER BY tm.invited_at DESC + `; + + return NextResponse.json({ + team: rows, + count: rows.length, + }); + } catch (error) { + console.error("[routes-f creator/team GET]", error); + return NextResponse.json( + { error: "Failed to fetch creator team" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, inviteTeamMemberSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { username, role } = bodyResult.data; + + try { + await ensureCreatorTeamSchema(); + + const { rows: sizeRows } = await sql` + SELECT COUNT(*)::int AS team_size + FROM route_f_creator_team_members + WHERE creator_id = ${session.userId} + `; + + if (Number(sizeRows[0]?.team_size ?? 0) >= 10) { + return NextResponse.json( + { error: "A creator can have at most 10 team members" }, + { status: 409 } + ); + } + + const { rows: userRows } = await sql` + SELECT id, username, avatar + FROM users + WHERE LOWER(username) = LOWER(${username}) + LIMIT 1 + `; + + if (userRows.length === 0) { + return NextResponse.json( + { error: "Invited user does not exist" }, + { status: 404 } + ); + } + + const invitedUser = userRows[0]; + if (String(invitedUser.id) === session.userId) { + return NextResponse.json( + { error: "You cannot invite yourself" }, + { status: 400 } + ); + } + + const { rows } = await sql` + INSERT INTO route_f_creator_team_members ( + creator_id, + member_id, + role, + status + ) + VALUES ( + ${session.userId}, + ${invitedUser.id}, + ${role}, + 'invited' + ) + ON CONFLICT (creator_id, member_id) DO NOTHING + RETURNING id, creator_id, member_id, role, status, invited_at, updated_at + `; + + if (rows.length === 0) { + return NextResponse.json( + { error: "User is already in the creator team" }, + { status: 409 } + ); + } + + return NextResponse.json( + { + ...rows[0], + member: { + username: invitedUser.username, + avatar: invitedUser.avatar, + }, + }, + { status: 201 } + ); + } catch (error) { + console.error("[routes-f creator/team POST]", error); + return NextResponse.json( + { error: "Failed to invite team member" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/credits/admin/route.ts b/app/api/routes-f/credits/admin/route.ts new file mode 100644 index 00000000..edaa438d --- /dev/null +++ b/app/api/routes-f/credits/admin/route.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { ensureCreditsSchema } from "../route"; + +const issueVoucherSchema = z.object({ + amount_usd: z.number().positive(), + max_redemptions: z.number().int().min(1).default(1), + expires_at: z.string().datetime().optional(), +}); + +async function isAdmin(userId: string): Promise { + const { rows } = await sql` + SELECT 1 FROM users WHERE id = ${userId} AND is_admin = TRUE LIMIT 1 + `; + return rows.length > 0; +} + +function generateCode(): string { + const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + return Array.from({ length: 12 }, () => chars[Math.floor(Math.random() * chars.length)]).join(""); +} + +/** + * GET /api/routes-f/credits/admin + * Admin — list all issued voucher codes and their redemption status. + */ +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + if (!(await isAdmin(session.userId))) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + await ensureCreditsSchema(); + + const { rows } = await sql` + SELECT + v.id, + v.code, + v.amount_usd, + v.max_redemptions, + v.redemption_count, + v.expires_at, + v.created_at, + COALESCE( + json_agg( + json_build_object('user_id', r.user_id, 'redeemed_at', r.redeemed_at) + ) FILTER (WHERE r.user_id IS NOT NULL), + '[]' + ) AS redemptions + FROM route_f_vouchers v + LEFT JOIN route_f_voucher_redemptions r ON r.voucher_id = v.id + GROUP BY v.id + ORDER BY v.created_at DESC + `; + + return NextResponse.json({ vouchers: rows }); + } catch (error) { + console.error("[routes-f credits/admin GET]", error); + return NextResponse.json({ error: "Failed to fetch vouchers" }, { status: 500 }); + } +} + +/** + * POST /api/routes-f/credits/admin + * Admin — issue a new voucher code. + */ +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + if (!(await isAdmin(session.userId))) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const bodyResult = await validateBody(req, issueVoucherSchema); + if (bodyResult instanceof Response) return bodyResult; + + const { amount_usd, max_redemptions, expires_at } = bodyResult.data; + + if (expires_at && new Date(expires_at) <= new Date()) { + return NextResponse.json({ error: "expires_at must be in the future" }, { status: 400 }); + } + + try { + await ensureCreditsSchema(); + + // Retry on rare code collision + let rows: Record[] = []; + for (let attempt = 0; attempt < 5; attempt++) { + const code = generateCode(); + try { + const result = await sql` + INSERT INTO route_f_vouchers (code, amount_usd, max_redemptions, expires_at) + VALUES (${code}, ${amount_usd}, ${max_redemptions}, ${expires_at ?? null}) + RETURNING id, code, amount_usd, max_redemptions, redemption_count, expires_at, created_at + `; + rows = result.rows; + break; + } catch (e: unknown) { + const err = e as { code?: string }; + if (err?.code === "23505" && attempt < 4) continue; // unique violation, retry + throw e; + } + } + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + console.error("[routes-f credits/admin POST]", error); + return NextResponse.json({ error: "Failed to issue voucher" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/credits/redeem/route.ts b/app/api/routes-f/credits/redeem/route.ts new file mode 100644 index 00000000..2c520e7d --- /dev/null +++ b/app/api/routes-f/credits/redeem/route.ts @@ -0,0 +1,95 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { ensureCreditsSchema } from "../route"; + +const redeemSchema = z.object({ + code: z.string().trim().min(1).max(100), +}); + +/** + * POST /api/routes-f/credits/redeem + * Redeem a voucher code to add credits to the authenticated user's balance. + */ +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const bodyResult = await validateBody(req, redeemSchema); + if (bodyResult instanceof Response) return bodyResult; + + const { code } = bodyResult.data; + + try { + await ensureCreditsSchema(); + + // 1. Look up voucher + const { rows: voucherRows } = await sql` + SELECT id, amount_usd, max_redemptions, redemption_count, expires_at + FROM route_f_vouchers + WHERE UPPER(code) = UPPER(${code}) + LIMIT 1 + `; + + if (voucherRows.length === 0) { + return NextResponse.json({ error: "Invalid voucher code" }, { status: 404 }); + } + + const voucher = voucherRows[0]; + + // 2. Check expiry + if (voucher.expires_at && new Date(voucher.expires_at) < new Date()) { + return NextResponse.json({ error: "Voucher has expired" }, { status: 410 }); + } + + // 3. Check redemption limit + if (voucher.redemption_count >= voucher.max_redemptions) { + return NextResponse.json({ error: "Voucher has reached its redemption limit" }, { status: 409 }); + } + + // 4. Check if user already redeemed this voucher + const { rows: alreadyRedeemed } = await sql` + SELECT 1 FROM route_f_voucher_redemptions + WHERE voucher_id = ${voucher.id} AND user_id = ${session.userId} + LIMIT 1 + `; + + if (alreadyRedeemed.length > 0) { + return NextResponse.json({ error: "You have already redeemed this voucher" }, { status: 409 }); + } + + // 5. Record redemption, increment counter, and credit user — all in one transaction + await sql` + INSERT INTO route_f_voucher_redemptions (voucher_id, user_id) + VALUES (${voucher.id}, ${session.userId}) + `; + + await sql` + UPDATE route_f_vouchers + SET redemption_count = redemption_count + 1 + WHERE id = ${voucher.id} + `; + + const { rows: creditRows } = await sql` + INSERT INTO route_f_credits (user_id, balance_usd, expires_at, updated_at) + VALUES (${session.userId}, ${voucher.amount_usd}, ${voucher.expires_at ?? null}, NOW()) + ON CONFLICT (user_id) DO UPDATE SET + balance_usd = route_f_credits.balance_usd + EXCLUDED.balance_usd, + expires_at = GREATEST(route_f_credits.expires_at, EXCLUDED.expires_at), + updated_at = NOW() + RETURNING balance_usd, expires_at + `; + + return NextResponse.json({ + success: true, + credited_usd: voucher.amount_usd, + new_balance_usd: creditRows[0].balance_usd, + expires_at: creditRows[0].expires_at, + }); + } catch (error) { + console.error("[routes-f credits/redeem POST]", error); + return NextResponse.json({ error: "Failed to redeem voucher" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/credits/route.ts b/app/api/routes-f/credits/route.ts new file mode 100644 index 00000000..1022e7d2 --- /dev/null +++ b/app/api/routes-f/credits/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +export async function ensureCreditsSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS route_f_vouchers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code TEXT UNIQUE NOT NULL, + amount_usd NUMERIC(10,2) NOT NULL, + max_redemptions INT NOT NULL DEFAULT 1, + redemption_count INT NOT NULL DEFAULT 0, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; + + await sql` + CREATE TABLE IF NOT EXISTS route_f_credits ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + balance_usd NUMERIC(10,2) NOT NULL DEFAULT 0, + expires_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; + + await sql` + CREATE TABLE IF NOT EXISTS route_f_voucher_redemptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + voucher_id UUID NOT NULL REFERENCES route_f_vouchers(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + redeemed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (voucher_id, user_id) + ) + `; +} + +/** + * GET /api/routes-f/credits + * Returns the authenticated user's credit balance and expiry. + */ +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + try { + await ensureCreditsSchema(); + + const { rows } = await sql` + SELECT balance_usd, expires_at, updated_at + FROM route_f_credits + WHERE user_id = ${session.userId} + LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ balance_usd: "0.00", expires_at: null }); + } + + return NextResponse.json(rows[0]); + } catch (error) { + console.error("[routes-f credits GET]", error); + return NextResponse.json({ error: "Failed to fetch credits" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/deps/route.ts b/app/api/routes-f/deps/route.ts new file mode 100644 index 00000000..7001b8bb --- /dev/null +++ b/app/api/routes-f/deps/route.ts @@ -0,0 +1,250 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; + +type DepStatus = "healthy" | "degraded" | "unhealthy" | "unknown"; + +interface DependencyResult { + name: string; + status: DepStatus; + latency_ms: number | null; + detail?: string; +} + +const CHECK_TIMEOUT_MS = 3000; + +function isAuthorized(req: NextRequest): boolean { + const internalKey = req.headers.get("x-internal-key"); + const expectedKey = process.env.INTERNAL_API_KEY; + + if (internalKey && expectedKey && internalKey === expectedKey) { + return true; + } + + const sessionCookie = + req.cookies.get("privy_session")?.value ?? + req.cookies.get("wallet_session")?.value; + + return !!sessionCookie; +} + +async function withTimeout( + promise: Promise, + ms: number +): Promise< + { result: T; elapsed: number } | { error: string; elapsed: number } +> { + const start = Date.now(); + try { + const result = await Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout")), ms) + ), + ]); + return { result, elapsed: Date.now() - start }; + } catch (err) { + return { + error: err instanceof Error ? err.message : String(err), + elapsed: Date.now() - start, + }; + } +} + +async function checkPostgres(): Promise { + const outcome = await withTimeout(sql`SELECT 1 AS ok`, CHECK_TIMEOUT_MS); + + if ("error" in outcome) { + return { + name: "Vercel Postgres", + status: outcome.error === "Timeout" ? "degraded" : "unhealthy", + latency_ms: outcome.elapsed, + detail: outcome.error, + }; + } + + return { + name: "Vercel Postgres", + status: "healthy", + latency_ms: outcome.elapsed, + detail: "Connected", + }; +} + +async function checkRedis(): Promise { + const url = process.env.UPSTASH_REDIS_REST_URL; + const token = process.env.UPSTASH_REDIS_REST_TOKEN; + + if (!url || !token) { + return { + name: "Upstash Redis", + status: "unknown", + latency_ms: null, + detail: "Redis credentials not configured", + }; + } + + const outcome = await withTimeout( + fetch(`${url}/PING`, { + headers: { Authorization: `Bearer ${token}` }, + }).then(res => res.json()), + CHECK_TIMEOUT_MS + ); + + if ("error" in outcome) { + return { + name: "Upstash Redis", + status: outcome.error === "Timeout" ? "degraded" : "unhealthy", + latency_ms: outcome.elapsed, + detail: outcome.error, + }; + } + + return { + name: "Upstash Redis", + status: "healthy", + latency_ms: outcome.elapsed, + }; +} + +async function checkMux(): Promise { + const tokenId = process.env.MUX_TOKEN_ID; + const tokenSecret = process.env.MUX_TOKEN_SECRET; + + if (!tokenId || !tokenSecret) { + return { + name: "Mux", + status: "unknown", + latency_ms: null, + detail: "Mux credentials not configured", + }; + } + + const credentials = Buffer.from(`${tokenId}:${tokenSecret}`).toString( + "base64" + ); + + const outcome = await withTimeout( + fetch("https://api.mux.com/video/v1/live-streams?limit=1", { + headers: { Authorization: `Basic ${credentials}` }, + }), + CHECK_TIMEOUT_MS + ); + + if ("error" in outcome) { + return { + name: "Mux", + status: outcome.error === "Timeout" ? "degraded" : "degraded", + latency_ms: outcome.elapsed, + detail: outcome.error, + }; + } + + const status: DepStatus = outcome.elapsed > 1000 ? "degraded" : "healthy"; + const detail = + outcome.elapsed > 1000 + ? "API responding but latency elevated" + : "Connected"; + + return { + name: "Mux", + status, + latency_ms: outcome.elapsed, + detail, + }; +} + +async function checkStellarHorizon(): Promise { + const horizonUrl = + process.env.NEXT_PUBLIC_STELLAR_HORIZON_URL ?? + "https://horizon-testnet.stellar.org"; + + const outcome = await withTimeout( + fetch(horizonUrl).then(res => res.json()), + CHECK_TIMEOUT_MS + ); + + if ("error" in outcome) { + return { + name: "Stellar Horizon", + status: outcome.error === "Timeout" ? "degraded" : "degraded", + latency_ms: outcome.elapsed, + detail: outcome.error, + }; + } + + const ledger = (outcome.result as Record) + ?.history_latest_ledger; + + return { + name: "Stellar Horizon", + status: "healthy", + latency_ms: outcome.elapsed, + detail: ledger ? `Latest ledger: ${ledger}` : "Connected", + }; +} + +async function checkTransak(): Promise { + const apiKey = process.env.TRANSAK_API_KEY; + + if (!apiKey) { + return { + name: "Transak", + status: "unknown", + latency_ms: null, + detail: "API key not configured", + }; + } + + return { + name: "Transak", + status: "healthy", + latency_ms: 0, + detail: "API key configured", + }; +} + +function computeOverallStatus(deps: DependencyResult[]): DepStatus { + const criticalNames = new Set(["Vercel Postgres", "Upstash Redis"]); + + const hasUnhealthyCritical = deps.some( + d => criticalNames.has(d.name) && d.status === "unhealthy" + ); + if (hasUnhealthyCritical) { + return "unhealthy"; + } + + const hasDegraded = deps.some( + d => d.status === "degraded" || d.status === "unhealthy" + ); + if (hasDegraded) { + return "degraded"; + } + + return "healthy"; +} + +/** + * GET /api/routes-f/deps + * Dependency health check. Requires X-Internal-Key or admin session. + */ +export async function GET(req: NextRequest): Promise { + if (!isAuthorized(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const dependencies = await Promise.all([ + checkPostgres(), + checkRedis(), + checkMux(), + checkStellarHorizon(), + checkTransak(), + ]); + + const overall = computeOverallStatus(dependencies); + + return NextResponse.json({ + checked_at: new Date().toISOString(), + overall, + dependencies, + }); +} diff --git a/app/api/routes-f/devices/[id]/route.ts b/app/api/routes-f/devices/[id]/route.ts new file mode 100644 index 00000000..0ecf8ecb --- /dev/null +++ b/app/api/routes-f/devices/[id]/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +async function ensureDeviceTable() { + await sql` + CREATE TABLE IF NOT EXISTS push_notification_devices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + token_ciphertext TEXT NOT NULL, + token_iv TEXT NOT NULL, + token_tag TEXT NOT NULL, + platform TEXT NOT NULL CHECK (platform IN ('web', 'ios', 'android')), + name TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { id } = await params; + + try { + await ensureDeviceTable(); + + const { rowCount } = await sql` + DELETE FROM push_notification_devices + WHERE id = ${id} + AND user_id = ${session.userId} + `; + + if ((rowCount ?? 0) === 0) { + return NextResponse.json({ error: "Device not found" }, { status: 404 }); + } + + return NextResponse.json({ message: "Device unregistered" }); + } catch (error) { + console.error("[routes-f devices/:id DELETE]", error); + return NextResponse.json( + { error: "Failed to unregister device" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/devices/route.ts b/app/api/routes-f/devices/route.ts new file mode 100644 index 00000000..25333cd5 --- /dev/null +++ b/app/api/routes-f/devices/route.ts @@ -0,0 +1,203 @@ +import { createCipheriv, createHash, createHmac, randomBytes } from "crypto"; +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +const platformSchema = z.enum(["web", "ios", "android"]); + +const registerDeviceSchema = z.object({ + token: z.string().min(8).max(4096), + platform: platformSchema, + name: z.string().trim().min(1).max(80).optional(), +}); + +const MAX_DEVICES_PER_USER = 5; + +function resolveEncryptionKey(): Buffer { + const raw = + process.env.PUSH_TOKEN_ENCRYPTION_KEY ?? + process.env.STELLAR_ENCRYPTION_KEY ?? + process.env.SESSION_SECRET; + + if (!raw) { + throw new Error( + "Missing PUSH_TOKEN_ENCRYPTION_KEY (or STELLAR_ENCRYPTION_KEY / SESSION_SECRET fallback)" + ); + } + + if (/^[0-9a-fA-F]{64}$/.test(raw)) { + return Buffer.from(raw, "hex"); + } + + return createHash("sha256").update(raw).digest(); +} + +function encryptToken(token: string, key: Buffer) { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ciphertext = Buffer.concat([ + cipher.update(token, "utf8"), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + + return { + token_ciphertext: ciphertext.toString("base64"), + token_iv: iv.toString("base64"), + token_tag: tag.toString("base64"), + }; +} + +function hashToken(token: string, key: Buffer): string { + return createHmac("sha256", key).update(token).digest("hex"); +} + +async function ensureDeviceTable() { + await sql` + CREATE TABLE IF NOT EXISTS push_notification_devices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + token_ciphertext TEXT NOT NULL, + token_iv TEXT NOT NULL, + token_tag TEXT NOT NULL, + platform TEXT NOT NULL CHECK (platform IN ('web', 'ios', 'android')), + name TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_push_devices_user_updated + ON push_notification_devices (user_id, updated_at DESC) + `; +} + +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + await ensureDeviceTable(); + + const { rows } = await sql` + SELECT id, platform, name, created_at, updated_at, last_seen_at + FROM push_notification_devices + WHERE user_id = ${session.userId} + ORDER BY updated_at DESC + LIMIT ${MAX_DEVICES_PER_USER} + `; + + return NextResponse.json({ devices: rows }); + } catch (error) { + console.error("[routes-f devices GET]", error); + return NextResponse.json( + { error: "Failed to list registered devices" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, registerDeviceSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { token, platform, name } = bodyResult.data; + + try { + await ensureDeviceTable(); + + const key = resolveEncryptionKey(); + const tokenHash = hashToken(token, key); + + const { rows: existingRows } = await sql` + SELECT id, user_id + FROM push_notification_devices + WHERE token_hash = ${tokenHash} + LIMIT 1 + `; + + const existing = existingRows[0]; + const isNewToken = !existing; + + if (isNewToken) { + const { rows: countRows } = await sql` + SELECT COUNT(*)::int AS total + FROM push_notification_devices + WHERE user_id = ${session.userId} + `; + + const total = Number(countRows[0]?.total ?? 0); + if (total >= MAX_DEVICES_PER_USER) { + return NextResponse.json( + { error: "Device limit reached (max 5)" }, + { status: 400 } + ); + } + } + + const encrypted = encryptToken(token, key); + + const { rows } = await sql` + INSERT INTO push_notification_devices ( + user_id, + token_hash, + token_ciphertext, + token_iv, + token_tag, + platform, + name, + updated_at, + last_seen_at + ) + VALUES ( + ${session.userId}, + ${tokenHash}, + ${encrypted.token_ciphertext}, + ${encrypted.token_iv}, + ${encrypted.token_tag}, + ${platform}, + ${name ?? null}, + NOW(), + NOW() + ) + ON CONFLICT (token_hash) DO UPDATE SET + user_id = EXCLUDED.user_id, + token_ciphertext = EXCLUDED.token_ciphertext, + token_iv = EXCLUDED.token_iv, + token_tag = EXCLUDED.token_tag, + platform = EXCLUDED.platform, + name = EXCLUDED.name, + updated_at = NOW(), + last_seen_at = NOW() + RETURNING id, user_id, platform, name, created_at, updated_at, last_seen_at + `; + + return NextResponse.json( + { + device: rows[0], + upserted: true, + }, + { status: isNewToken ? 201 : 200 } + ); + } catch (error) { + console.error("[routes-f devices POST]", error); + return NextResponse.json( + { error: "Failed to register push device" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/discovery/route.ts b/app/api/routes-f/discovery/route.ts new file mode 100644 index 00000000..6b8b44fb --- /dev/null +++ b/app/api/routes-f/discovery/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { ensureRoutesFSchema } from "../_lib/schema"; + +/** + * GET /api/routes-f/discovery + * Query params: ?type=trending|rising|featured&limit=20&cursor= + */ +export async function GET(req: NextRequest) { + try { + await ensureRoutesFSchema(); + const { searchParams } = new URL(req.url); + const type = searchParams.get("type") || "trending"; + const limit = Math.min(parseInt(searchParams.get("limit") || "20"), 50); + const cursor = searchParams.get("cursor") || null; + + let rows: any[] = []; + if (type === "trending") { + // Trending: Scored by viewer count + recency (score decay) + const res = await sql` + SELECT + id, username, avatar, is_live, current_viewers, stream_started_at, + (current_viewers * EXP(-0.1 * EXTRACT(EPOCH FROM (NOW() - stream_started_at)) / 3600)) as score + FROM users + WHERE is_live = true + ORDER BY score DESC, id DESC + LIMIT ${limit} + `; + rows = res.rows; + + // Simple cursor filtering after query for composite scores since it's hard to filter on calculated score in one go with the cursor being an ID + if (cursor) { + const cursorIndex = rows.findIndex((r: any) => r.id === cursor); + if (cursorIndex !== -1) { + rows = rows.slice(cursorIndex + 1); + } + } + } else if (type === "rising") { + // Rising: highest viewer growth in last 60 min + const res = await sql` + SELECT + u.id, u.username, u.avatar, u.is_live, u.current_viewers, + (SELECT COUNT(*)::int + FROM stream_viewers sv + JOIN stream_sessions ss ON sv.stream_session_id = ss.id + WHERE ss.user_id = u.id AND ss.ended_at IS NULL AND sv.joined_at > NOW() - INTERVAL '60 minutes') as growth_score + FROM users u + WHERE u.is_live = true + ORDER BY growth_score DESC, u.id DESC + LIMIT ${limit} + `; + rows = res.rows; + + if (cursor) { + const cursorIndex = rows.findIndex((r: any) => r.id === cursor); + if (cursorIndex !== -1) { + rows = rows.slice(cursorIndex + 1); + } + } + } else if (type === "featured") { + // Featured: manually curated creators + const res = await sql` + SELECT id, username, avatar, is_live, current_viewers, is_featured + FROM users + WHERE is_featured = true + ORDER BY created_at DESC, id DESC + LIMIT ${limit} + `; + rows = res.rows; + + if (cursor) { + const cursorIndex = rows.findIndex((r: any) => r.id === cursor); + if (cursorIndex !== -1) { + rows = rows.slice(cursorIndex + 1); + } + } + } else { + return NextResponse.json({ error: "Invalid type" }, { status: 400 }); + } + + const nextCursor = rows.length === limit ? rows[rows.length - 1].id : null; + + return NextResponse.json({ + data: rows, + next_cursor: nextCursor + }); + } catch (error) { + console.error("Discovery error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/drops/[id]/draw/route.ts b/app/api/routes-f/drops/[id]/draw/route.ts new file mode 100644 index 00000000..8d3ee88d --- /dev/null +++ b/app/api/routes-f/drops/[id]/draw/route.ts @@ -0,0 +1,159 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +type Winner = { + viewer_id: string; + username: string | null; + avatar: string | null; +}; + +function secureRandomInt(maxExclusive: number): number { + if (!Number.isInteger(maxExclusive) || maxExclusive <= 0) { + throw new Error("maxExclusive must be a positive integer"); + } + + const maxUint32 = 0x100000000; + const threshold = maxUint32 - (maxUint32 % maxExclusive); + + const buffer = new Uint32Array(1); + let value = 0; + do { + crypto.getRandomValues(buffer); + value = buffer[0]; + } while (value >= threshold); + + return value % maxExclusive; +} + +function pickWinners(items: T[], count: number): T[] { + const mutable = [...items]; + + for (let i = mutable.length - 1; i > 0; i -= 1) { + const j = secureRandomInt(i + 1); + [mutable[i], mutable[j]] = [mutable[j], mutable[i]]; + } + + return mutable.slice(0, Math.max(0, count)); +} + +async function ensureDropTables() { + await sql` + CREATE TABLE IF NOT EXISTS stream_drops ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stream_id UUID NOT NULL REFERENCES stream_sessions(id) ON DELETE CASCADE, + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + reward TEXT NOT NULL, + eligible_viewers TEXT NOT NULL CHECK (eligible_viewers IN ('all', 'subscribers')), + winner_count INTEGER NOT NULL CHECK (winner_count > 0), + ends_at TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'closed')), + winners JSONB, + drawn_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; + + await sql` + CREATE TABLE IF NOT EXISTS stream_drop_entries ( + drop_id UUID NOT NULL REFERENCES stream_drops(id) ON DELETE CASCADE, + viewer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (drop_id, viewer_id) + ) + `; +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { id } = await params; + + try { + await ensureDropTables(); + + await sql`BEGIN`; + try { + await sql` + UPDATE stream_drops + SET status = 'closed', updated_at = NOW() + WHERE id = ${id} + AND status = 'active' + AND ends_at <= NOW() + `; + + const { rows: dropRows } = await sql` + SELECT id, creator_id, winner_count, winners, drawn_at, status + FROM stream_drops + WHERE id = ${id} + LIMIT 1 + `; + + if (dropRows.length === 0) { + await sql`ROLLBACK`; + return NextResponse.json({ error: "Drop not found" }, { status: 404 }); + } + + const drop = dropRows[0]; + + if (String(drop.creator_id) !== session.userId) { + await sql`ROLLBACK`; + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + if (drop.drawn_at) { + await sql`COMMIT`; + return NextResponse.json({ + message: "Winners already drawn", + winners: drop.winners ?? [], + closed: true, + }); + } + + const { rows: entryRows } = await sql` + SELECT e.viewer_id, u.username, u.avatar + FROM stream_drop_entries e + JOIN users u ON u.id = e.viewer_id + WHERE e.drop_id = ${id} + ORDER BY e.created_at ASC + `; + + const winners = pickWinners(entryRows, Number(drop.winner_count ?? 0)); + + await sql` + UPDATE stream_drops + SET + winners = ${JSON.stringify(winners)}::jsonb, + status = 'closed', + drawn_at = NOW(), + updated_at = NOW() + WHERE id = ${id} + `; + + await sql`COMMIT`; + + return NextResponse.json({ + drop_id: id, + winner_count: winners.length, + winners, + closed: true, + }); + } catch (txError) { + await sql`ROLLBACK`; + throw txError; + } + } catch (error) { + console.error("[routes-f drops/:id/draw POST]", error); + return NextResponse.json( + { error: "Failed to draw winners" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/drops/[id]/enter/route.ts b/app/api/routes-f/drops/[id]/enter/route.ts new file mode 100644 index 00000000..1f242cb2 --- /dev/null +++ b/app/api/routes-f/drops/[id]/enter/route.ts @@ -0,0 +1,113 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +async function ensureDropTables() { + await sql` + CREATE TABLE IF NOT EXISTS stream_drops ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stream_id UUID NOT NULL REFERENCES stream_sessions(id) ON DELETE CASCADE, + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + reward TEXT NOT NULL, + eligible_viewers TEXT NOT NULL CHECK (eligible_viewers IN ('all', 'subscribers')), + winner_count INTEGER NOT NULL CHECK (winner_count > 0), + ends_at TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'closed')), + winners JSONB, + drawn_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; + + await sql` + CREATE TABLE IF NOT EXISTS stream_drop_entries ( + drop_id UUID NOT NULL REFERENCES stream_drops(id) ON DELETE CASCADE, + viewer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (drop_id, viewer_id) + ) + `; +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { id } = await params; + + try { + await ensureDropTables(); + + await sql` + UPDATE stream_drops + SET status = 'closed', updated_at = NOW() + WHERE id = ${id} + AND status = 'active' + AND ends_at <= NOW() + `; + + const { rows: dropRows } = await sql` + SELECT id, creator_id, eligible_viewers, status, ends_at + FROM stream_drops + WHERE id = ${id} + LIMIT 1 + `; + + if (dropRows.length === 0) { + return NextResponse.json({ error: "Drop not found" }, { status: 404 }); + } + + const drop = dropRows[0]; + if ( + drop.status !== "active" || + new Date(String(drop.ends_at)).getTime() <= Date.now() + ) { + return NextResponse.json({ error: "Drop is closed" }, { status: 409 }); + } + + if (String(drop.eligible_viewers) === "subscribers") { + const { rows: subRows } = await sql` + SELECT 1 + FROM subscriptions + WHERE creator_id = ${drop.creator_id} + AND supporter_id = ${session.userId} + AND status = 'active' + LIMIT 1 + `; + + if (subRows.length === 0) { + return NextResponse.json( + { error: "Only active subscribers can enter this drop" }, + { status: 403 } + ); + } + } + + const { rowCount } = await sql` + INSERT INTO stream_drop_entries (drop_id, viewer_id) + VALUES (${id}, ${session.userId}) + ON CONFLICT DO NOTHING + `; + + if ((rowCount ?? 0) === 0) { + return NextResponse.json( + { error: "Viewer has already entered this drop" }, + { status: 409 } + ); + } + + return NextResponse.json({ message: "Entry recorded" }, { status: 201 }); + } catch (error) { + console.error("[routes-f drops/:id/enter POST]", error); + return NextResponse.json( + { error: "Failed to enter drop" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/drops/route.ts b/app/api/routes-f/drops/route.ts new file mode 100644 index 00000000..7cdc597f --- /dev/null +++ b/app/api/routes-f/drops/route.ts @@ -0,0 +1,213 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; + +const eligibleViewersSchema = z.enum(["all", "subscribers"]); + +const listDropsSchema = z.object({ + creator: z.string().min(1), +}); + +const createDropSchema = z.object({ + stream_id: z.string().uuid(), + reward: z.string().trim().min(1).max(500), + eligible_viewers: eligibleViewersSchema, + winner_count: z.number().int().min(1).max(100), + ends_at: z.coerce.date(), +}); + +async function ensureDropTables() { + await sql` + CREATE TABLE IF NOT EXISTS stream_drops ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stream_id UUID NOT NULL REFERENCES stream_sessions(id) ON DELETE CASCADE, + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + reward TEXT NOT NULL, + eligible_viewers TEXT NOT NULL CHECK (eligible_viewers IN ('all', 'subscribers')), + winner_count INTEGER NOT NULL CHECK (winner_count > 0), + ends_at TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'closed')), + winners JSONB, + drawn_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; + + await sql` + CREATE TABLE IF NOT EXISTS stream_drop_entries ( + drop_id UUID NOT NULL REFERENCES stream_drops(id) ON DELETE CASCADE, + viewer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (drop_id, viewer_id) + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_stream_drops_creator_status + ON stream_drops (creator_id, status, ends_at ASC) + `; +} + +async function closeExpiredDrops(creatorId?: string) { + if (creatorId) { + await sql` + UPDATE stream_drops + SET status = 'closed', updated_at = NOW() + WHERE creator_id = ${creatorId} + AND status = 'active' + AND ends_at <= NOW() + `; + return; + } + + await sql` + UPDATE stream_drops + SET status = 'closed', updated_at = NOW() + WHERE status = 'active' + AND ends_at <= NOW() + `; +} + +async function resolveCreatorId(input: string): Promise { + const maybeUuid = z.string().uuid().safeParse(input); + + const { rows } = maybeUuid.success + ? await sql`SELECT id FROM users WHERE id = ${input} LIMIT 1` + : await sql`SELECT id FROM users WHERE LOWER(username) = LOWER(${input}) LIMIT 1`; + + if (rows.length === 0) { + return null; + } + + return String(rows[0].id); +} + +export async function GET(req: NextRequest): Promise { + const queryResult = validateQuery( + new URL(req.url).searchParams, + listDropsSchema + ); + if (queryResult instanceof Response) { + return queryResult; + } + + try { + await ensureDropTables(); + + const creatorId = await resolveCreatorId(queryResult.data.creator); + if (!creatorId) { + return NextResponse.json({ error: "Creator not found" }, { status: 404 }); + } + + await closeExpiredDrops(creatorId); + + const { rows } = await sql` + SELECT + d.id, + d.stream_id, + d.creator_id, + d.reward, + d.eligible_viewers, + d.winner_count, + d.ends_at, + d.status, + d.winners, + d.drawn_at, + d.created_at, + d.updated_at, + COALESCE(COUNT(e.viewer_id), 0)::int AS entry_count + FROM stream_drops d + LEFT JOIN stream_drop_entries e ON e.drop_id = d.id + WHERE d.creator_id = ${creatorId} + AND d.status = 'active' + GROUP BY d.id + ORDER BY d.ends_at ASC + `; + + return NextResponse.json({ drops: rows }); + } catch (error) { + console.error("[routes-f drops GET]", error); + return NextResponse.json( + { error: "Failed to list active drops" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, createDropSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { stream_id, reward, eligible_viewers, winner_count, ends_at } = + bodyResult.data; + + if (ends_at.getTime() <= Date.now()) { + return NextResponse.json( + { error: "ends_at must be in the future" }, + { status: 400 } + ); + } + + try { + await ensureDropTables(); + + const { rows: streamRows } = await sql` + SELECT id + FROM stream_sessions + WHERE id = ${stream_id} + AND user_id = ${session.userId} + LIMIT 1 + `; + + if (streamRows.length === 0) { + return NextResponse.json( + { error: "Stream not found or not owned by user" }, + { status: 404 } + ); + } + + const { rows } = await sql` + INSERT INTO stream_drops ( + stream_id, + creator_id, + reward, + eligible_viewers, + winner_count, + ends_at, + status, + created_at, + updated_at + ) + VALUES ( + ${stream_id}, + ${session.userId}, + ${reward}, + ${eligible_viewers}, + ${winner_count}, + ${ends_at.toISOString()}, + 'active', + NOW(), + NOW() + ) + RETURNING id, stream_id, creator_id, reward, eligible_viewers, winner_count, ends_at, status, created_at, updated_at + `; + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + console.error("[routes-f drops POST]", error); + return NextResponse.json( + { error: "Failed to create drop" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/export/route.ts b/app/api/routes-f/export/route.ts new file mode 100644 index 00000000..45832f16 --- /dev/null +++ b/app/api/routes-f/export/route.ts @@ -0,0 +1,226 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { z } from "zod"; + +const EXPORT_TYPES = ["earnings", "streams", "followers", "tips", "full"] as const; +const EXPORT_FORMATS = ["csv", "json"] as const; +const MAX_EXPORTS_PER_DAY = 3; + +async function ensureExportTable() { + await sql` + CREATE TABLE IF NOT EXISTS export_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + type TEXT NOT NULL, + format TEXT NOT NULL, + status TEXT DEFAULT 'queued', + file_url TEXT, + error TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + completed_at TIMESTAMPTZ + ) + `; +} + +const createExportSchema = z.object({ + type: z.enum(EXPORT_TYPES), + format: z.enum(EXPORT_FORMATS), + from: z.string().optional(), + to: z.string().optional(), +}); + +const pollSchema = z.object({ + job_id: z.string().uuid(), +}); + +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const result = await validateBody(req, createExportSchema); + if (result instanceof NextResponse) return result; + const { type, format, from, to } = result.data; + + await ensureExportTable(); + + // Rate limit: max 3 exports per user per day + const { rows: countRows } = await sql` + SELECT COUNT(*) AS count + FROM export_jobs + WHERE user_id = ${session.userId} + AND created_at >= NOW() - INTERVAL '24 hours' + `; + if (Number(countRows[0]?.count ?? 0) >= MAX_EXPORTS_PER_DAY) { + return NextResponse.json( + { error: "Export rate limit reached. Maximum 3 exports per 24 hours." }, + { status: 429 } + ); + } + + const { rows } = await sql` + INSERT INTO export_jobs (user_id, type, format, status) + VALUES (${session.userId}, ${type}, ${format}, 'queued') + RETURNING id, status, created_at + `; + + const job = rows[0]; + + // Fire background processing (non-blocking stub — real worker handles this) + processExportJob(job.id, session.userId, type, format, from, to).catch( + () => {} + ); + + return NextResponse.json( + { job_id: job.id, status: job.status }, + { status: 202 } + ); +} + +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const result = validateQuery(new URL(req.url).searchParams, pollSchema); + if (result instanceof NextResponse) return result; + const { job_id } = result.data; + + await ensureExportTable(); + + const { rows } = await sql` + SELECT id, type, format, status, file_url, error, created_at, completed_at + FROM export_jobs + WHERE id = ${job_id} AND user_id = ${session.userId} + LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ error: "Job not found" }, { status: 404 }); + } + + const job = rows[0]; + return NextResponse.json({ + job_id: job.id, + type: job.type, + format: job.format, + status: job.status, + download_url: job.file_url ?? null, + error: job.error ?? null, + created_at: job.created_at, + completed_at: job.completed_at ?? null, + }); +} + +/** + * Background export processor stub. In production this runs as a queue worker + * (e.g. Vercel Cron / BullMQ). Builds the file, uploads to R2/S3, then + * updates the job record with a signed URL that expires in 1 hour. + */ +async function processExportJob( + jobId: string, + userId: string, + type: (typeof EXPORT_TYPES)[number], + format: (typeof EXPORT_FORMATS)[number], + from?: string, + to?: string +) { + try { + await sql` + UPDATE export_jobs SET status = 'processing' WHERE id = ${jobId} + `; + + const fromDate = from ? new Date(from) : new Date("2000-01-01"); + const toDate = to ? new Date(to) : new Date(); + + let data: unknown[]; + + if (type === "earnings" || type === "full") { + const { rows } = await sql` + SELECT t.created_at AS date, u.username AS sender, t.amount, t.asset, t.tx_hash + FROM tips t + LEFT JOIN users u ON u.id = t.sender_id + WHERE t.recipient_id = ${userId} + AND t.created_at BETWEEN ${fromDate.toISOString()} AND ${toDate.toISOString()} + ORDER BY t.created_at DESC + `; + data = rows; + } else if (type === "streams") { + const { rows } = await sql` + SELECT title, started_at AS date, ended_at, peak_viewers, total_views + FROM stream_sessions + WHERE creator_id = ${userId} + AND started_at BETWEEN ${fromDate.toISOString()} AND ${toDate.toISOString()} + ORDER BY started_at DESC + `; + data = rows; + } else if (type === "followers") { + const { rows } = await sql` + SELECT u.username, f.created_at AS followed_at + FROM follows f + JOIN users u ON u.id = f.follower_id + WHERE f.creator_id = ${userId} + AND f.created_at BETWEEN ${fromDate.toISOString()} AND ${toDate.toISOString()} + ORDER BY f.created_at DESC + `; + data = rows; + } else if (type === "tips") { + const { rows } = await sql` + SELECT t.created_at AS date, u.username AS recipient, t.amount, t.asset, t.tx_hash + FROM tips t + LEFT JOIN users u ON u.id = t.recipient_id + WHERE t.sender_id = ${userId} + AND t.created_at BETWEEN ${fromDate.toISOString()} AND ${toDate.toISOString()} + ORDER BY t.created_at DESC + `; + data = rows; + } else { + data = []; + } + + // Produce serialized output (real impl uploads to R2/S3 and returns signed URL) + const serialized = + format === "json" + ? JSON.stringify(data, null, 2) + : toCsv(data as Record[]); + + // Placeholder: in production, upload `serialized` to object storage and store + // the signed URL. Here we store a data URI so the job reaches "ready" status. + const fileUrl = + `data:${format === "json" ? "application/json" : "text/csv"};base64,` + + Buffer.from(serialized).toString("base64"); + + await sql` + UPDATE export_jobs + SET status = 'ready', file_url = ${fileUrl}, completed_at = NOW() + WHERE id = ${jobId} + `; + } catch (err) { + await sql` + UPDATE export_jobs + SET status = 'failed', error = ${String(err)}, completed_at = NOW() + WHERE id = ${jobId} + `.catch(() => {}); + } +} + +function toCsv(rows: Record[]): string { + if (rows.length === 0) return ""; + const headers = Object.keys(rows[0]); + const lines = [ + headers.join(","), + ...rows.map((row) => + headers + .map((h) => { + const val = row[h]; + if (val === null || val === undefined) return ""; + const str = String(val); + return str.includes(",") || str.includes('"') || str.includes("\n") + ? `"${str.replace(/"/g, '""')}"` + : str; + }) + .join(",") + ), + ]; + return lines.join("\n"); +} diff --git a/app/api/routes-f/feedback/[id]/route.ts b/app/api/routes-f/feedback/[id]/route.ts new file mode 100644 index 00000000..29b80e38 --- /dev/null +++ b/app/api/routes-f/feedback/[id]/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { z } from "zod"; + +const updateStatusSchema = z.object({ + status: z.enum(["new", "read", "actioned", "dismissed"]), +}); + +async function requireAdmin(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return { ok: false as const, response: session.response }; + } + const { rows } = await sql` + SELECT role FROM users WHERE id = ${session.userId} LIMIT 1 + `; + if (rows[0]?.role !== "admin") { + return { + ok: false as const, + response: NextResponse.json({ error: "Forbidden" }, { status: 403 }), + }; + } + return { ok: true as const }; +} + +/** Admin only — update feedback status. */ +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const auth = await requireAdmin(req); + if (!auth.ok) return auth.response; + + const result = await validateBody(req, updateStatusSchema); + if (result instanceof NextResponse) return result; + const { status } = result.data; + + const { id } = await params; + + const { rows } = await sql` + UPDATE feedback + SET status = ${status} + WHERE id = ${id} + RETURNING id, type, subject, status, created_at + `; + + if (rows.length === 0) { + return NextResponse.json({ error: "Feedback not found" }, { status: 404 }); + } + + return NextResponse.json({ feedback: rows[0] }); +} diff --git a/app/api/routes-f/feedback/route.ts b/app/api/routes-f/feedback/route.ts new file mode 100644 index 00000000..7b684803 --- /dev/null +++ b/app/api/routes-f/feedback/route.ts @@ -0,0 +1,157 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { z } from "zod"; + +const FEEDBACK_TYPES = ["bug", "feature_request", "general", "nps"] as const; +const MAX_SUBMISSIONS_PER_DAY = 5; + +async function ensureFeedbackTable() { + await sql` + CREATE TABLE IF NOT EXISTS feedback ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + type TEXT NOT NULL CHECK (type IN ('bug', 'feature_request', 'general', 'nps')), + subject TEXT, + body TEXT NOT NULL, + rating INT CHECK (rating IS NULL OR (rating >= 0 AND rating <= 10)), + page_url TEXT, + user_agent TEXT, + metadata JSONB, + status TEXT DEFAULT 'new' CHECK (status IN ('new', 'read', 'actioned', 'dismissed')), + created_at TIMESTAMPTZ DEFAULT now() + ) + `; +} + +const submitFeedbackSchema = z + .object({ + type: z.enum(FEEDBACK_TYPES), + subject: z.string().trim().max(255).optional(), + body: z.string().trim().min(1), + rating: z.number().int().min(0).max(10).nullable().optional(), + page_url: z.string().max(1000).optional(), + metadata: z.record(z.unknown()).optional(), + }) + .refine( + d => d.type !== "nps" || (d.rating !== null && d.rating !== undefined), + { message: "rating is required for nps type", path: ["rating"] } + ); + +const listFeedbackSchema = z.object({ + type: z.enum(FEEDBACK_TYPES).optional(), + status: z.enum(["new", "read", "actioned", "dismissed"]).optional(), + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + +async function requireAdmin(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return { ok: false as const, response: session.response }; + } + const { rows } = await sql` + SELECT role FROM users WHERE id = ${session.userId} LIMIT 1 + `; + if (rows[0]?.role !== "admin") { + return { + ok: false as const, + response: NextResponse.json({ error: "Forbidden" }, { status: 403 }), + }; + } + return { ok: true as const, session }; +} + +/** Public — submit feedback (anonymous submissions supported). */ +export async function POST(req: NextRequest): Promise { + // Attempt auth but don't require it (anonymous allowed) + const session = await verifySession(req); + const userId = session.ok ? session.userId : null; + + const result = await validateBody(req, submitFeedbackSchema); + if (result instanceof NextResponse) return result; + const { type, subject, body, rating, page_url, metadata } = result.data; + + await ensureFeedbackTable(); + + // Rate limit: 5 submissions per user per 24h (skip for anonymous) + if (userId) { + const { rows: rateRows } = await sql` + SELECT COUNT(*) AS count + FROM feedback + WHERE user_id = ${userId} + AND created_at >= NOW() - INTERVAL '24 hours' + `; + if (Number(rateRows[0]?.count ?? 0) >= MAX_SUBMISSIONS_PER_DAY) { + return NextResponse.json( + { error: "Rate limit reached. Maximum 5 submissions per 24 hours." }, + { status: 429 } + ); + } + } + + const userAgent = req.headers.get("user-agent") ?? null; + + const { rows } = await sql` + INSERT INTO feedback (user_id, type, subject, body, rating, page_url, user_agent, metadata) + VALUES ( + ${userId}, + ${type}, + ${subject ?? null}, + ${body}, + ${rating ?? null}, + ${page_url ?? null}, + ${userAgent}, + ${metadata ? JSON.stringify(metadata) : null} + ) + RETURNING id, type, status, created_at + `; + + return NextResponse.json({ feedback: rows[0] }, { status: 201 }); +} + +/** Admin only — list feedback submissions with pagination. */ +export async function GET(req: NextRequest): Promise { + const auth = await requireAdmin(req); + if (!auth.ok) return auth.response; + + const result = validateQuery( + new URL(req.url).searchParams, + listFeedbackSchema + ); + if (result instanceof NextResponse) return result; + const { type, status, page, limit } = result.data; + const pageNumber = page ?? 1; + const pageLimit = limit ?? 20; + + await ensureFeedbackTable(); + + const offset = (pageNumber - 1) * pageLimit; + + const { rows } = await sql` + SELECT + f.id, f.type, f.subject, f.body, f.rating, f.page_url, + f.status, f.created_at, u.username + FROM feedback f + LEFT JOIN users u ON u.id = f.user_id + WHERE (${type ?? null}::text IS NULL OR f.type = ${type ?? null}) + AND (${status ?? null}::text IS NULL OR f.status = ${status ?? null}) + ORDER BY f.created_at DESC + LIMIT ${pageLimit} OFFSET ${offset} + `; + + const { rows: countRows } = await sql` + SELECT COUNT(*) AS total + FROM feedback + WHERE (${type ?? null}::text IS NULL OR type = ${type ?? null}) + AND (${status ?? null}::text IS NULL OR status = ${status ?? null}) + `; + + return NextResponse.json({ + items: rows, + total: Number(countRows[0]?.total ?? 0), + page: pageNumber, + limit: pageLimit, + }); +} diff --git a/app/api/routes-f/gifts/__tests__/route.test.ts b/app/api/routes-f/gifts/__tests__/route.test.ts new file mode 100644 index 00000000..5f2bd376 --- /dev/null +++ b/app/api/routes-f/gifts/__tests__/route.test.ts @@ -0,0 +1,100 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); +jest.mock("../_lib/db", () => ({ + ensureGiftSchema: jest.fn().mockResolvedValue(undefined), + getGiftCatalogItem: jest.fn((id: string) => + id === "crown" + ? { + id: "crown", + name: "Crown", + price_usd: 25, + icon_url: "/images/gifts/crown.png", + animation_url: "/animations/gifts/crown.json", + } + : undefined + ), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { POST } from "../route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +function makeRequest(body: object) { + return new Request("http://localhost/api/routes-f/gifts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) as unknown as import("next/server").NextRequest; +} + +describe("routes-f gifts", () => { + beforeEach(() => { + jest.clearAllMocks(); + verifySessionMock.mockResolvedValue({ + ok: true, + userId: "sender-1", + wallet: null, + privyId: "did:privy:1", + username: "alice", + email: "alice@example.com", + }); + }); + + it("creates a gift transaction", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [{ id: "recipient-1", username: "bob" }] }) + .mockResolvedValueOnce({ + rows: [ + { + id: "gift-tx-1", + gift_id: "crown", + gift_name: "Crown", + quantity: 2, + amount_usdc: "50", + tx_hash: "0xtx", + }, + ], + }); + + const res = await POST( + makeRequest({ + recipient_id: "550e8400-e29b-41d4-a716-446655440000", + gift_id: "crown", + quantity: 2, + tx_hash: "0xtx", + }) + ); + const json = await res.json(); + + expect(res.status).toBe(201); + expect(json.gift_id).toBe("crown"); + expect(json.amount_usdc).toBe("50"); + }); + + it("enforces quantity max 100", async () => { + const res = await POST( + makeRequest({ + recipient_id: "550e8400-e29b-41d4-a716-446655440000", + gift_id: "crown", + quantity: 101, + }) + ); + + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/gifts/_lib/db.ts b/app/api/routes-f/gifts/_lib/db.ts new file mode 100644 index 00000000..19305141 --- /dev/null +++ b/app/api/routes-f/gifts/_lib/db.ts @@ -0,0 +1,98 @@ +import { sql } from "@vercel/postgres"; + +export const GIFT_CATALOG = [ + { + id: "flower", + name: "Flower", + price_usd: 1, + icon_url: "/images/gifts/flower.png", + animation_url: "/animations/gifts/flower.json", + }, + { + id: "candy", + name: "Candy", + price_usd: 5, + icon_url: "/images/gifts/candy.png", + animation_url: "/animations/gifts/candy.json", + }, + { + id: "crown", + name: "Crown", + price_usd: 25, + icon_url: "/images/gifts/crown.png", + animation_url: "/animations/gifts/crown.json", + }, + { + id: "lion", + name: "Lion", + price_usd: 100, + icon_url: "/images/gifts/lion.png", + animation_url: "/animations/gifts/lion.json", + }, + { + id: "dragon", + name: "Dragon", + price_usd: 500, + icon_url: "/images/gifts/dragon.png", + animation_url: "/animations/gifts/dragon.json", + }, +] as const; + +export type GiftCatalogItem = (typeof GIFT_CATALOG)[number]; + +export async function ensureGiftSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS gift_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + supporter_id UUID REFERENCES users(id) ON DELETE SET NULL, + creator_id UUID REFERENCES users(id) ON DELETE CASCADE, + recipient_id UUID REFERENCES users(id) ON DELETE CASCADE, + stream_session_id UUID REFERENCES stream_sessions(id) ON DELETE SET NULL, + gift_id TEXT NOT NULL, + gift_name TEXT NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + amount_usdc NUMERIC(20,2) NOT NULL DEFAULT 0, + tx_hash TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; + + await sql` + ALTER TABLE gift_transactions + ADD COLUMN IF NOT EXISTS recipient_id UUID REFERENCES users(id) ON DELETE CASCADE + `; + + await sql` + ALTER TABLE gift_transactions + ADD COLUMN IF NOT EXISTS gift_id TEXT + `; + + await sql` + ALTER TABLE gift_transactions + ADD COLUMN IF NOT EXISTS gift_name TEXT + `; + + await sql` + ALTER TABLE gift_transactions + ADD COLUMN IF NOT EXISTS quantity INTEGER NOT NULL DEFAULT 1 + `; + + await sql` + ALTER TABLE gift_transactions + ADD COLUMN IF NOT EXISTS tx_hash TEXT + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_gift_transactions_supporter_created + ON gift_transactions (supporter_id, created_at DESC) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_gift_transactions_creator_created + ON gift_transactions (creator_id, created_at DESC) + `; +} + +export function getGiftCatalogItem(id: string): GiftCatalogItem | undefined { + return GIFT_CATALOG.find(item => item.id === id); +} diff --git a/app/api/routes-f/gifts/catalog/route.ts b/app/api/routes-f/gifts/catalog/route.ts new file mode 100644 index 00000000..8f9a1ebb --- /dev/null +++ b/app/api/routes-f/gifts/catalog/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +import { GIFT_CATALOG } from "../_lib/db"; + +export async function GET(): Promise { + return NextResponse.json({ + catalog: GIFT_CATALOG, + }); +} diff --git a/app/api/routes-f/gifts/history/route.ts b/app/api/routes-f/gifts/history/route.ts new file mode 100644 index 00000000..59684159 --- /dev/null +++ b/app/api/routes-f/gifts/history/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { ensureGiftSchema } from "../_lib/db"; + +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + await ensureGiftSchema(); + + const { rows } = await sql` + SELECT + gt.id, + gt.gift_id, + gt.gift_name, + gt.quantity, + gt.amount_usdc::text AS amount_usdc, + gt.tx_hash, + gt.stream_session_id AS stream_id, + gt.created_at, + gt.supporter_id, + gt.creator_id, + sender.username AS sender_username, + sender.avatar AS sender_avatar, + recipient.username AS recipient_username, + recipient.avatar AS recipient_avatar, + CASE + WHEN gt.supporter_id = ${session.userId} THEN 'sent' + ELSE 'received' + END AS direction + FROM gift_transactions gt + LEFT JOIN users sender ON sender.id = gt.supporter_id + LEFT JOIN users recipient ON recipient.id = gt.creator_id + WHERE gt.supporter_id = ${session.userId} + OR gt.creator_id = ${session.userId} + ORDER BY gt.created_at DESC + LIMIT 100 + `; + + return NextResponse.json({ + history: rows, + }); + } catch (error) { + console.error("[routes-f gifts/history GET]", error); + return NextResponse.json( + { error: "Failed to fetch gift history" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/gifts/route.ts b/app/api/routes-f/gifts/route.ts new file mode 100644 index 00000000..712b7527 --- /dev/null +++ b/app/api/routes-f/gifts/route.ts @@ -0,0 +1,138 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { uuidSchema } from "@/app/api/routes-f/_lib/schemas"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { ensureGiftSchema, getGiftCatalogItem } from "./_lib/db"; + +const sendGiftSchema = z.object({ + recipient_id: uuidSchema, + gift_id: z.enum(["flower", "candy", "crown", "lion", "dragon"]), + quantity: z.number().int().min(1).max(100), + stream_id: uuidSchema.optional(), + tx_hash: z.string().trim().min(1).max(255).optional(), +}); + +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, sendGiftSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + try { + await ensureGiftSchema(); + + const { recipient_id, gift_id, quantity, stream_id, tx_hash } = + bodyResult.data; + const gift = getGiftCatalogItem(gift_id); + + if (!gift) { + return NextResponse.json({ error: "Gift not found" }, { status: 404 }); + } + + const { rows: recipientRows } = await sql` + SELECT id, username + FROM users + WHERE id = ${recipient_id} + LIMIT 1 + `; + + if (recipientRows.length === 0) { + return NextResponse.json( + { error: "Recipient not found" }, + { status: 404 } + ); + } + + const amountUsd = gift.price_usd * quantity; + + const { rows } = await sql` + INSERT INTO gift_transactions ( + supporter_id, + creator_id, + recipient_id, + stream_session_id, + gift_id, + gift_name, + quantity, + amount_usdc, + tx_hash + ) + VALUES ( + ${session.userId}, + ${recipient_id}, + ${recipient_id}, + ${stream_id ?? null}, + ${gift.id}, + ${gift.name}, + ${quantity}, + ${amountUsd}, + ${tx_hash ?? null} + ) + RETURNING + id, + supporter_id, + creator_id, + recipient_id, + stream_session_id AS stream_id, + gift_id, + gift_name, + quantity, + amount_usdc::text AS amount_usdc, + tx_hash, + created_at + `; + + // Activity events — non-blocking + try { + const recipientUsername = recipientRows[0].username; + const { rows: senderRows } = await sql` + SELECT username FROM users WHERE id = ${session.userId} LIMIT 1 + `; + const senderUsername = senderRows[0]?.username ?? "Someone"; + + await Promise.all([ + sql` + INSERT INTO route_f_activity_events (user_id, type, actor_id, metadata) + VALUES ( + ${recipient_id}, + 'gift_received', + ${session.userId}, + ${JSON.stringify({ + gift_name: gift.name, + quantity, + amount_usdc: amountUsd, + sender_username: senderUsername, + })}::jsonb + ) + `, + sql` + INSERT INTO route_f_activity_events (user_id, type, metadata) + VALUES ( + ${session.userId}, + 'gift_sent', + ${JSON.stringify({ + gift_name: gift.name, + quantity, + amount_usdc: amountUsd, + recipient_username: recipientUsername, + })}::jsonb + ) + `, + ]); + } catch (activityErr) { + console.error("[routes-f gifts] activity insert error:", activityErr); + } + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + console.error("[routes-f gifts POST]", error); + return NextResponse.json({ error: "Failed to send gift" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/highlights/[id]/route.ts b/app/api/routes-f/highlights/[id]/route.ts new file mode 100644 index 00000000..002282f9 --- /dev/null +++ b/app/api/routes-f/highlights/[id]/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { ensureHighlightsSchema } from "../_lib/db"; + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +export async function DELETE( + req: NextRequest, + { params }: RouteContext +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { id } = await params; + + try { + await ensureHighlightsSchema(); + + const { rows } = await sql` + DELETE FROM route_f_highlights + WHERE id = ${id} AND user_id = ${session.userId} + RETURNING id + `; + + if (rows.length === 0) { + return NextResponse.json( + { error: "Highlight not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ deleted: true, id }); + } catch (error) { + console.error("[highlights] DELETE error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/highlights/__tests__/route.test.ts b/app/api/routes-f/highlights/__tests__/route.test.ts new file mode 100644 index 00000000..60606523 --- /dev/null +++ b/app/api/routes-f/highlights/__tests__/route.test.ts @@ -0,0 +1,150 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +jest.mock("../_lib/db", () => ({ + ensureHighlightsSchema: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("../../highlights/_lib/db", () => ({ + ensureHighlightsSchema: jest.fn().mockResolvedValue(undefined), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { GET, POST } from "../route"; +import { DELETE } from "../[id]/route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; +const RECORDING_ID = "550e8400-e29b-41d4-a716-446655440000"; +const HIGHLIGHT_ID = "550e8400-e29b-41d4-a716-446655440001"; + +function makeRequest(method: string, path: string, body?: object) { + return new Request(`http://localhost${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; +} + +describe("routes-f highlights", () => { + beforeEach(() => { + jest.clearAllMocks(); + verifySessionMock.mockResolvedValue({ + ok: true, + userId: "user-id", + wallet: null, + privyId: "did:privy:abc", + username: "alice", + email: "alice@example.com", + }); + jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("lists the authenticated creator's highlights", async () => { + sqlMock.mockResolvedValueOnce({ + rows: [ + { + id: HIGHLIGHT_ID, + recording_id: RECORDING_ID, + title: "Big moment", + }, + ], + }); + + const res = await GET(makeRequest("GET", "/api/routes-f/highlights")); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.highlights).toHaveLength(1); + }); + + it("rejects clips longer than 90 seconds", async () => { + const res = await POST( + makeRequest("POST", "/api/routes-f/highlights", { + recording_id: RECORDING_ID, + start_offset: 0, + end_offset: 91, + title: "Too long", + }) + ); + + expect(res.status).toBe(400); + }); + + it("creates a highlight with a playback URL", async () => { + sqlMock + .mockResolvedValueOnce({ + rows: [ + { + id: RECORDING_ID, + user_id: "user-id", + playback_id: "mux-playback", + duration: 180, + title: "Recording", + status: "ready", + }, + ], + }) + .mockResolvedValueOnce({ + rows: [ + { + id: HIGHLIGHT_ID, + recording_id: RECORDING_ID, + user_id: "user-id", + title: "Big moment", + start_offset: 5, + end_offset: 45, + playback_url: + "https://stream.mux.com/mux-playback.m3u8?asset_type=clip&start=5&end=45", + created_at: "2026-03-28T00:00:00Z", + }, + ], + }); + + const res = await POST( + makeRequest("POST", "/api/routes-f/highlights", { + recording_id: RECORDING_ID, + start_offset: 5, + end_offset: 45, + title: "Big moment", + }) + ); + const json = await res.json(); + + expect(res.status).toBe(201); + expect(json.playback_url).toContain("asset_type=clip"); + }); + + it("deletes a highlight owned by the current creator", async () => { + sqlMock.mockResolvedValueOnce({ + rows: [{ id: HIGHLIGHT_ID }], + }); + + const res = await DELETE( + makeRequest("DELETE", `/api/routes-f/highlights/${HIGHLIGHT_ID}`), + { params: Promise.resolve({ id: HIGHLIGHT_ID }) } + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.deleted).toBe(true); + }); +}); diff --git a/app/api/routes-f/highlights/_lib/db.ts b/app/api/routes-f/highlights/_lib/db.ts new file mode 100644 index 00000000..108f9ece --- /dev/null +++ b/app/api/routes-f/highlights/_lib/db.ts @@ -0,0 +1,26 @@ +import { sql } from "@vercel/postgres"; + +export async function ensureHighlightsSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS route_f_highlights ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + recording_id UUID NOT NULL REFERENCES stream_recordings(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(120) NOT NULL, + start_offset INTEGER NOT NULL, + end_offset INTEGER NOT NULL, + playback_url TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_route_f_highlights_user_created + ON route_f_highlights (user_id, created_at DESC) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_route_f_highlights_recording + ON route_f_highlights (recording_id, created_at DESC) + `; +} diff --git a/app/api/routes-f/highlights/route.ts b/app/api/routes-f/highlights/route.ts new file mode 100644 index 00000000..362ecc8a --- /dev/null +++ b/app/api/routes-f/highlights/route.ts @@ -0,0 +1,172 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { uuidSchema } from "@/app/api/routes-f/_lib/schemas"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { ensureHighlightsSchema } from "./_lib/db"; + +const MAX_CLIP_LENGTH_SECONDS = 90; + +const createHighlightSchema = z.object({ + recording_id: uuidSchema, + start_offset: z.number().int().min(0), + end_offset: z.number().int().min(1), + title: z.string().trim().min(1).max(120), +}); + +function buildPlaybackUrl( + playbackId: string, + startOffset: number, + endOffset: number +): string { + const params = new URLSearchParams({ + asset_type: "clip", + start: String(startOffset), + end: String(endOffset), + }); + + return `https://stream.mux.com/${playbackId}.m3u8?${params.toString()}`; +} + +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + await ensureHighlightsSchema(); + + const { rows } = await sql` + SELECT + h.id, + h.recording_id, + h.title, + h.start_offset, + h.end_offset, + h.playback_url, + h.created_at, + r.playback_id, + r.duration, + r.title AS recording_title + FROM route_f_highlights h + JOIN stream_recordings r ON r.id = h.recording_id + WHERE h.user_id = ${session.userId} + ORDER BY h.created_at DESC + `; + + return NextResponse.json({ highlights: rows }); + } catch (error) { + console.error("[highlights] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, createHighlightSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { recording_id, start_offset, end_offset, title } = bodyResult.data; + + if (start_offset >= end_offset) { + return NextResponse.json( + { error: "start_offset must be less than end_offset" }, + { status: 400 } + ); + } + + if (end_offset - start_offset > MAX_CLIP_LENGTH_SECONDS) { + return NextResponse.json( + { + error: `Highlight clips may not exceed ${MAX_CLIP_LENGTH_SECONDS} seconds`, + }, + { status: 400 } + ); + } + + try { + await ensureHighlightsSchema(); + + const { rows: recordingRows } = await sql` + SELECT id, user_id, playback_id, duration, title, status + FROM stream_recordings + WHERE id = ${recording_id} + LIMIT 1 + `; + + if (recordingRows.length === 0) { + return NextResponse.json( + { error: "Recording not found" }, + { status: 404 } + ); + } + + const recording = recordingRows[0]; + + if (recording.user_id !== session.userId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + if (!recording.playback_id) { + return NextResponse.json( + { error: "Recording playback is not ready" }, + { status: 409 } + ); + } + + if ( + typeof recording.duration === "number" && + end_offset > recording.duration + ) { + return NextResponse.json( + { error: "end_offset exceeds recording duration" }, + { status: 400 } + ); + } + + const playbackUrl = buildPlaybackUrl( + recording.playback_id, + start_offset, + end_offset + ); + + const { rows } = await sql` + INSERT INTO route_f_highlights ( + recording_id, + user_id, + title, + start_offset, + end_offset, + playback_url + ) + VALUES ( + ${recording_id}, + ${session.userId}, + ${title}, + ${start_offset}, + ${end_offset}, + ${playbackUrl} + ) + RETURNING id, recording_id, user_id, title, start_offset, end_offset, playback_url, created_at + `; + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + console.error("[highlights] POST error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/import/__tests__/route.test.ts b/app/api/routes-f/import/__tests__/route.test.ts new file mode 100644 index 00000000..3e353934 --- /dev/null +++ b/app/api/routes-f/import/__tests__/route.test.ts @@ -0,0 +1,612 @@ +/** + * Tests for POST /api/routes-f/import and GET /api/routes-f/import + * + * Mocks: + * - @vercel/postgres — no real DB + * - next/server — jsdom polyfill + * - @/lib/rate-limit — always allows + * - @/lib/auth/verify-session — controllable session + * - global fetch — intercepts external HTTP calls + */ + +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/rate-limit", () => ({ + createRateLimiter: () => async () => false, // never rate-limited in tests +})); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { POST, GET } from "../route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const makeRequest = ( + method: string, + body?: object, + search?: string +): import("next/server").NextRequest => + new Request(`http://localhost/api/routes-f/import${search ?? ""}`, { + method, + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; + +const authedSession = { + ok: true as const, + userId: "user-123", + wallet: null, + privyId: "did:privy:abc", + username: "testuser", + email: "test@example.com", +}; + +// Mock CREATE TABLE IF NOT EXISTS + any subsequent query +function mockEnsureTable() { + sqlMock.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ensureJobsTable +} + +let consoleErrorSpy: jest.SpyInstance; + +// ── POST tests ───────────────────────────────────────────────────────────────── + +describe("POST /api/routes-f/import", () => { + beforeEach(() => { + jest.clearAllMocks(); + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + verifySessionMock.mockResolvedValue(authedSession); + }); + afterEach(() => consoleErrorSpy?.mockRestore()); + + it("returns 401 when session is invalid", async () => { + verifySessionMock.mockResolvedValue({ + ok: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }), + }); + const req = makeRequest("POST", { source: "json", data: {} }); + const res = await POST(req); + expect(res.status).toBe(401); + }); + + it("returns 400 for invalid source", async () => { + const req = makeRequest("POST", { + source: "instagram", + data: { foo: "bar" }, + }); + const res = await POST(req); + expect(res.status).toBe(400); + const body = await res.json(); + // Zod validation returns { error: "Validation failed", issues: [...] } + expect(body.error).toBe("Validation failed"); + expect(JSON.stringify(body.issues)).toMatch(/source/i); + }); + + it("returns 400 when data is missing", async () => { + const req = makeRequest("POST", { source: "json" }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("returns 400 when data is an array", async () => { + const req = makeRequest("POST", { source: "json", data: [1, 2] }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("returns 429 when user already imported in past 24h", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-old" }] }); // rate-limit check + const req = makeRequest("POST", { + source: "json", + data: { bio: "Hello" }, + }); + const res = await POST(req); + expect(res.status).toBe(429); + const body = await res.json(); + expect(body.error).toMatch(/24 hours/i); + }); + + it("returns 500 when DB fails on rate-limit check", async () => { + mockEnsureTable(); + sqlMock.mockRejectedValueOnce(new Error("DB down")); + const req = makeRequest("POST", { + source: "json", + data: { bio: "Hello" }, + }); + const res = await POST(req); + expect(res.status).toBe(500); + }); + + it("returns 500 when job record creation fails", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); // rate-limit check — no recent jobs + sqlMock.mockRejectedValueOnce(new Error("INSERT failed")); + const req = makeRequest("POST", { + source: "json", + data: { bio: "Hello" }, + }); + const res = await POST(req); + expect(res.status).toBe(500); + }); + + // ── JSON source ────────────────────────────────────────────────────────────── + + describe("json source", () => { + it("returns 422 when JSON export is empty", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); // rate-limit + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-1" }] }); // INSERT job + + const req = makeRequest("POST", { source: "json", data: {} }); + const res = await POST(req); + expect(res.status).toBe(422); + // Call 3 is UPDATE import_jobs SET status = 'failed', error = ${msg}, ... WHERE id = ${jobId} + // 'failed' is a SQL literal in the template string, not an interpolated value. + // Interpolated values are: [errorMessage, jobId] + const failCall = sqlMock.mock.calls[3]; + expect(failCall[1]).toMatch(/must contain/i); // error message + expect(failCall[2]).toBe("job-1"); // jobId + }); + + it("returns 422 when social_links entries are malformed", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-2" }] }); + + const req = makeRequest("POST", { + source: "json", + data: { social_links: [{ href: "bad" }] }, + }); + const res = await POST(req); + expect(res.status).toBe(422); + }); + + it("returns 200 and applies bio import", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); // rate-limit + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-3" }] }); // INSERT job + sqlMock.mockResolvedValueOnce({ + // applyImport SELECT + rows: [{ bio: null, avatar: null, sociallinks: null, creator: null }], + }); + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE users + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE job → done + + const req = makeRequest("POST", { + source: "json", + data: { bio: "Imported bio" }, + }); + const res = await POST(req); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.job_id).toBe("job-3"); + expect(body.status).toBe("done"); + }); + + it("does not overwrite non-empty bio when overwrite_existing is false", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-4" }] }); + sqlMock.mockResolvedValueOnce({ + rows: [ + { + bio: "Existing bio", + avatar: null, + sociallinks: null, + creator: null, + }, + ], + }); + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE users + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE job + + const req = makeRequest("POST", { + source: "json", + data: { bio: "New bio" }, + options: { overwrite_existing: false }, + }); + await POST(req); + + // SQL calls: 0=ensureTable, 1=rate-limit, 2=INSERT job, 3=SELECT user, 4=UPDATE users + const updateUsersCall = sqlMock.mock.calls[4]; + const interpolatedValues = updateUsersCall.slice(1); + expect(interpolatedValues).toContain("Existing bio"); + expect(interpolatedValues).not.toContain("New bio"); + }); + + it("overwrites existing bio when overwrite_existing is true", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-5" }] }); + sqlMock.mockResolvedValueOnce({ + rows: [ + { + bio: "Old bio", + avatar: null, + sociallinks: null, + creator: null, + }, + ], + }); + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE users + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE job + + const req = makeRequest("POST", { + source: "json", + data: { bio: "New bio" }, + options: { overwrite_existing: true }, + }); + await POST(req); + + // SQL calls: 0=ensureTable, 1=rate-limit, 2=INSERT job, 3=SELECT user, 4=UPDATE users + const updateUsersCall = sqlMock.mock.calls[4]; + const interpolatedValues = updateUsersCall.slice(1); + expect(interpolatedValues).toContain("New bio"); + }); + + it("maps social_links correctly to socialTitle/socialLink shape", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-6" }] }); + sqlMock.mockResolvedValueOnce({ + rows: [{ bio: null, avatar: null, sociallinks: null, creator: null }], + }); + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE users + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE job + + const req = makeRequest("POST", { + source: "json", + data: { + social_links: [{ title: "Twitter", url: "https://twitter.com/x" }], + }, + }); + await POST(req); + + // SQL calls: 0=ensureTable, 1=rate-limit, 2=INSERT job, 3=SELECT user, 4=UPDATE users + const updateUsersCall = sqlMock.mock.calls[4]; + const interpolatedValues = updateUsersCall.slice(1); + const socialLinksArg = interpolatedValues.find( + (v: unknown) => typeof v === "string" && v.includes("socialTitle") + ); + expect(socialLinksArg).toBeDefined(); + const parsed = JSON.parse(socialLinksArg); + expect(parsed[0]).toEqual({ + socialTitle: "Twitter", + socialLink: "https://twitter.com/x", + }); + }); + + it("maps first category to creator.category", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-7" }] }); + sqlMock.mockResolvedValueOnce({ + rows: [ + { + bio: null, + avatar: null, + sociallinks: null, + creator: JSON.stringify({ streamTitle: "My Stream" }), + }, + ], + }); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [] }); + + const req = makeRequest("POST", { + source: "json", + data: { categories: ["Gaming", "IRL"] }, + }); + await POST(req); + + // SQL calls: 0=ensureTable, 1=rate-limit, 2=INSERT job, 3=SELECT user, 4=UPDATE users + const updateUsersCall = sqlMock.mock.calls[4]; + const interpolatedValues = updateUsersCall.slice(1); + const creatorArg = interpolatedValues.find( + (v: unknown) => typeof v === "string" && v.includes("category") + ); + expect(creatorArg).toBeDefined(); + const parsed = JSON.parse(creatorArg); + expect(parsed.category).toBe("Gaming"); + expect(parsed.streamTitle).toBe("My Stream"); // existing field preserved + }); + }); + + // ── Twitch source ──────────────────────────────────────────────────────────── + + describe("twitch source", () => { + beforeEach(() => { + process.env.TWITCH_CLIENT_ID = "test-client-id"; + }); + afterEach(() => { + delete process.env.TWITCH_CLIENT_ID; + }); + + it("returns 400 when access_token is missing", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-t1" }] }); + + global.fetch = jest.fn(); // should not be called + + const req = makeRequest("POST", { source: "twitch", data: {} }); + const res = await POST(req); + expect(res.status).toBe(422); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("returns 422 when Twitch returns 401", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-t2" }] }); + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE job failed + + global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 401 }); + + const req = makeRequest("POST", { + source: "twitch", + data: { access_token: "bad-token" }, + }); + const res = await POST(req); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.error).toMatch(/invalid or expired/i); + }); + + it("returns 422 when TWITCH_CLIENT_ID is not configured", async () => { + delete process.env.TWITCH_CLIENT_ID; + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-t3" }] }); + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE job failed + + const req = makeRequest("POST", { + source: "twitch", + data: { access_token: "token" }, + }); + const res = await POST(req); + expect(res.status).toBe(422); + }); + + it("imports Twitch profile successfully", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-t4" }] }); + sqlMock.mockResolvedValueOnce({ + rows: [{ bio: null, avatar: null, sociallinks: null, creator: null }], + }); + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE users + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE job + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [ + { + description: "Twitch bio", + profile_image_url: "https://twitch.tv/avatar.png", + }, + ], + }), + }); + + const req = makeRequest("POST", { + source: "twitch", + data: { access_token: "valid-token" }, + }); + const res = await POST(req); + expect(res.status).toBe(200); + + // Verify access token is NOT passed to any SQL call (never persisted) + const allSqlArgs = sqlMock.mock.calls.flatMap((call: unknown[]) => + call.slice(1) + ); + expect(allSqlArgs).not.toContain("valid-token"); + }); + }); + + // ── YouTube source ─────────────────────────────────────────────────────────── + + describe("youtube source", () => { + it("returns 400 when channel_url is missing", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-y1" }] }); + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE job failed + + global.fetch = jest.fn(); + + const req = makeRequest("POST", { source: "youtube", data: {} }); + const res = await POST(req); + expect(res.status).toBe(422); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("returns 422 for a non-YouTube URL (SSRF guard)", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-y2" }] }); + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE job failed + + global.fetch = jest.fn(); + + const req = makeRequest("POST", { + source: "youtube", + data: { channel_url: "https://evil.internal/steal-data" }, + }); + const res = await POST(req); + expect(res.status).toBe(422); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("imports YouTube channel bio and avatar from og: meta tags", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-y3" }] }); + sqlMock.mockResolvedValueOnce({ + rows: [{ bio: null, avatar: null, sociallinks: null, creator: null }], + }); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [] }); + + const mockHtml = ` + + + + + `; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: async () => mockHtml, + }); + + const req = makeRequest("POST", { + source: "youtube", + data: { channel_url: "https://www.youtube.com/@testchannel" }, + }); + const res = await POST(req); + expect(res.status).toBe(200); + + // SQL calls: 0=ensureTable, 1=rate-limit, 2=INSERT job, 3=SELECT user, 4=UPDATE users + const updateUsersCall = sqlMock.mock.calls[4]; + const interpolatedValues = updateUsersCall.slice(1); + expect(interpolatedValues).toContain("My YouTube channel bio"); + expect(interpolatedValues).toContain("https://yt3.ggpht.com/avatar.jpg"); + }); + }); +}); + +// ── GET tests ────────────────────────────────────────────────────────────────── + +describe("GET /api/routes-f/import", () => { + beforeEach(() => { + jest.clearAllMocks(); + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + verifySessionMock.mockResolvedValue(authedSession); + }); + afterEach(() => consoleErrorSpy?.mockRestore()); + + const validJobId = "550e8400-e29b-41d4-a716-446655440000"; + + it("returns 401 when not authenticated", async () => { + verifySessionMock.mockResolvedValue({ + ok: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }), + }); + const req = makeRequest("GET", undefined, `?job_id=${validJobId}`); + const res = await GET(req); + expect(res.status).toBe(401); + }); + + it("returns 400 when job_id is missing", async () => { + const req = makeRequest("GET", undefined, ""); + const res = await GET(req); + expect(res.status).toBe(400); + const body = await res.json(); + // Zod validation returns { error: "Validation failed", issues: [{field: "job_id", ...}] } + expect(body.error).toBe("Validation failed"); + expect(JSON.stringify(body.issues)).toMatch(/job_id/i); + }); + + it("returns 400 for non-UUID job_id", async () => { + const req = makeRequest("GET", undefined, "?job_id=not-a-uuid"); + const res = await GET(req); + expect(res.status).toBe(400); + const body = await res.json(); + // Zod validation returns { error: "Validation failed", issues: [{message: "Invalid UUID", ...}] } + expect(body.error).toBe("Validation failed"); + expect(JSON.stringify(body.issues)).toMatch(/invalid/i); + }); + + it("returns 404 when job belongs to a different user", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); // no rows — different user + + const req = makeRequest("GET", undefined, `?job_id=${validJobId}`); + const res = await GET(req); + expect(res.status).toBe(404); + }); + + it("returns job details for a completed job", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ + rows: [ + { + id: validJobId, + status: "done", + source: "json", + result: { imported: { bio: "Hello" } }, + error: null, + created_at: "2026-03-27T00:00:00Z", + updated_at: "2026-03-27T00:00:01Z", + }, + ], + }); + + const req = makeRequest("GET", undefined, `?job_id=${validJobId}`); + const res = await GET(req); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.job_id).toBe(validJobId); + expect(body.status).toBe("done"); + expect(body.source).toBe("json"); + expect(body.result).toEqual({ imported: { bio: "Hello" } }); + expect(body.error).toBeNull(); + }); + + it("returns job details for a failed job", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ + rows: [ + { + id: validJobId, + status: "failed", + source: "twitch", + result: null, + error: "Twitch access token is invalid or expired", + created_at: "2026-03-27T00:00:00Z", + updated_at: "2026-03-27T00:00:01Z", + }, + ], + }); + + const req = makeRequest("GET", undefined, `?job_id=${validJobId}`); + const res = await GET(req); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.status).toBe("failed"); + expect(body.error).toMatch(/invalid or expired/i); + }); + + it("returns 500 on unexpected DB error", async () => { + mockEnsureTable(); + sqlMock.mockRejectedValueOnce(new Error("DB crash")); + + const req = makeRequest("GET", undefined, `?job_id=${validJobId}`); + const res = await GET(req); + expect(res.status).toBe(500); + }); +}); diff --git a/app/api/routes-f/import/route.ts b/app/api/routes-f/import/route.ts new file mode 100644 index 00000000..02c350cc --- /dev/null +++ b/app/api/routes-f/import/route.ts @@ -0,0 +1,565 @@ +/** + * POST /api/routes-f/import — start a platform import job + * GET /api/routes-f/import?job_id={id} — poll import status + * + * Supported sources: twitch | youtube | json + * + * Security: + * - Session cookie required (privy or wallet) + * - Rate limited: 5 requests/min per IP (general) + 1 import/user/24h (user-level) + * - Third-party OAuth tokens are NEVER persisted — used once and discarded + */ + +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { createRateLimiter } from "@/lib/rate-limit"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { uuidSchema } from "@/app/api/routes-f/_lib/schemas"; + +// 5 requests per minute per IP +const isIpRateLimited = createRateLimiter(60_000, 5); + +// ── Types ────────────────────────────────────────────────────────────────────── + +interface ImportOptions { + import_avatar?: boolean; + import_bio?: boolean; + import_social_links?: boolean; + import_categories?: boolean; + /** When false (default), only fills currently-empty fields */ + overwrite_existing?: boolean; +} + +/** Normalised profile data returned by each importer */ +interface ImportedProfile { + bio?: string; + avatar_url?: string; + social_links?: Array<{ socialTitle: string; socialLink: string }>; + /** First entry maps to creator.category */ + categories?: string[]; +} + +/** Expected shape of a StreamFi JSON export */ +interface StreamFiExport { + bio?: string; + avatar_url?: string; + social_links?: Array<{ title: string; url: string }>; + categories?: string[]; +} + +// ── Source importers ─────────────────────────────────────────────────────────── + +function getTwitchClientId(): string { + const id = process.env.TWITCH_CLIENT_ID; + if (!id) { + throw new Error("TWITCH_CLIENT_ID not configured"); + } + return id; +} + +/** + * Fetches profile from Twitch Helix API using the caller-supplied OAuth token. + * The token is used once and never stored. + */ +async function importFromTwitch( + data: Record +): Promise { + const accessToken = data.access_token; + if (typeof accessToken !== "string" || !accessToken) { + throw new Error("Twitch import requires data.access_token"); + } + + const clientId = getTwitchClientId(); + let response: Response; + try { + response = await fetch("https://api.twitch.tv/helix/users", { + headers: { + Authorization: `Bearer ${accessToken}`, + "Client-Id": clientId, + }, + }); + } catch (err) { + throw new Error( + `Failed to reach Twitch API: ${err instanceof Error ? err.message : "network error"}` + ); + } + + if (!response.ok) { + // 401 means the token was invalid/expired — surface a clear message + if (response.status === 401) { + throw new Error("Twitch access token is invalid or expired"); + } + throw new Error(`Twitch API returned ${response.status}`); + } + + const json = await response.json(); + const user = json?.data?.[0]; + if (!user) { + throw new Error("No Twitch user data returned for this token"); + } + + return { + bio: user.description || undefined, + avatar_url: user.profile_image_url || undefined, + // Twitch Helix /users does not expose social links + social_links: undefined, + categories: undefined, + }; +} + +/** Decodes HTML entities that appear in meta-tag content. + * Single-pass replacement prevents double-unescaping (e.g. &lt; → < → <). */ +const HTML_ENTITIES: Record = { + "&": "&", + "<": "<", + ">": ">", + """: '"', + "'": "'", +}; + +function decodeHTMLEntities(text: string): string { + return text.replace( + /&|<|>|"|'/g, + m => HTML_ENTITIES[m] ?? m + ); +} + +/** + * Fetches public channel metadata from a YouTube channel URL via og: meta tags. + * No API key required — works with any public channel page. + */ +async function importFromYouTube( + data: Record +): Promise { + const channelUrl = data.channel_url; + if (typeof channelUrl !== "string" || !channelUrl) { + throw new Error("YouTube import requires data.channel_url"); + } + + // SSRF guard: only accept YouTube channel URLs + const ytPattern = + /^https:\/\/(www\.)?youtube\.com\/(channel\/UC[\w-]{22}|@[\w.-]+|c\/[\w.-]+)\/?$/; + if (!ytPattern.test(channelUrl)) { + throw new Error( + "data.channel_url must be a valid YouTube channel URL (e.g. https://www.youtube.com/@channelname)" + ); + } + + let html: string; + try { + const response = await fetch(channelUrl, { + headers: { "User-Agent": "Mozilla/5.0 (compatible; StreamFi/1.0)" }, + }); + if (!response.ok) { + throw new Error(`YouTube returned ${response.status}`); + } + html = await response.text(); + } catch (err) { + throw new Error( + `Failed to fetch YouTube channel: ${err instanceof Error ? err.message : "network error"}` + ); + } + + // Extract og: meta content — two attribute orderings are possible + const ogContent = (property: string): string | undefined => { + const pattern = new RegExp( + `]+(?:property|name)="${property}"[^>]+content="([^"]*)"` + + `|]+content="([^"]*)"[^>]+(?:property|name)="${property}"`, + "i" + ); + const match = html.match(pattern); + const raw = match?.[1] ?? match?.[2]; + return raw ? decodeHTMLEntities(raw) : undefined; + }; + + return { + bio: ogContent("og:description") ?? ogContent("description"), + avatar_url: ogContent("og:image"), + social_links: undefined, + categories: undefined, + }; +} + +/** Validates and maps a StreamFi-format JSON export to a normalised profile. */ +function importFromJson(data: Record): ImportedProfile { + const exportData = data as StreamFiExport; + + const hasContent = + exportData.bio !== undefined || + exportData.avatar_url !== undefined || + exportData.social_links !== undefined || + exportData.categories !== undefined; + + if (!hasContent) { + throw new Error( + "JSON export must contain at least one of: bio, avatar_url, social_links, categories" + ); + } + + if (exportData.bio !== undefined && typeof exportData.bio !== "string") { + throw new Error("JSON export: bio must be a string"); + } + if ( + exportData.avatar_url !== undefined && + typeof exportData.avatar_url !== "string" + ) { + throw new Error("JSON export: avatar_url must be a string"); + } + if (exportData.social_links !== undefined) { + if (!Array.isArray(exportData.social_links)) { + throw new Error( + "JSON export: social_links must be an array of {title, url} objects" + ); + } + for (const link of exportData.social_links) { + if ( + typeof link !== "object" || + link === null || + typeof (link as Record).title !== "string" || + typeof (link as Record).url !== "string" + ) { + throw new Error( + "JSON export: each social_links entry must have string fields title and url" + ); + } + } + } + if (exportData.categories !== undefined) { + if ( + !Array.isArray(exportData.categories) || + !exportData.categories.every(c => typeof c === "string") + ) { + throw new Error("JSON export: categories must be an array of strings"); + } + } + + return { + bio: exportData.bio, + avatar_url: exportData.avatar_url, + social_links: exportData.social_links?.map(l => ({ + socialTitle: l.title, + socialLink: l.url, + })), + categories: exportData.categories, + }; +} + +// ── DB helpers ──────────────────────────────────────────────────────────────── + +async function ensureJobsTable(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS import_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id VARCHAR NOT NULL, + status VARCHAR NOT NULL DEFAULT 'queued', + source VARCHAR NOT NULL, + result JSONB, + error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; +} + +/** + * Applies the normalised profile to the user row, respecting overwrite_existing. + * When overwrite_existing is false, only empty/null fields are updated. + */ +async function applyImport( + userId: string, + profile: ImportedProfile, + options: Required +): Promise { + const { rows } = await sql` + SELECT bio, avatar, sociallinks, creator + FROM users + WHERE id = ${userId} + LIMIT 1 + `; + + if (rows.length === 0) { + throw new Error("User not found"); + } + + const user = rows[0]; + const overwrite = options.overwrite_existing; + + const newBio = + options.import_bio && profile.bio !== undefined + ? overwrite || !user.bio + ? profile.bio + : user.bio + : user.bio; + + const newAvatar = + options.import_avatar && profile.avatar_url !== undefined + ? overwrite || !user.avatar + ? profile.avatar_url + : user.avatar + : user.avatar; + + let newSocialLinks = user.sociallinks; + if (options.import_social_links && profile.social_links?.length) { + const existing: unknown[] = user.sociallinks + ? typeof user.sociallinks === "string" + ? JSON.parse(user.sociallinks) + : user.sociallinks + : []; + if (overwrite || existing.length === 0) { + newSocialLinks = JSON.stringify(profile.social_links); + } + } + + let newCreator = user.creator; + if (options.import_categories && profile.categories?.length) { + const existingCreator: Record = user.creator + ? typeof user.creator === "string" + ? JSON.parse(user.creator) + : user.creator + : {}; + if (overwrite || !existingCreator.category) { + newCreator = JSON.stringify({ + ...existingCreator, + category: profile.categories[0], + }); + } + } + + await sql` + UPDATE users + SET + bio = ${newBio}, + avatar = ${newAvatar}, + sociallinks = ${newSocialLinks}, + creator = ${ + newCreator + ? typeof newCreator === "string" + ? newCreator + : JSON.stringify(newCreator) + : null + }, + updated_at = CURRENT_TIMESTAMP + WHERE id = ${userId} + `; +} + +// ── Zod schema for import request body ──────────────────────────────────────── + +const importOptionsSchema = z + .object({ + import_avatar: z.boolean().optional(), + import_bio: z.boolean().optional(), + import_social_links: z.boolean().optional(), + import_categories: z.boolean().optional(), + overwrite_existing: z.boolean().optional(), + }) + .optional(); + +const importBodySchema = z.object({ + source: z.enum(["twitch", "youtube", "json"], { + errorMap: () => ({ + message: "source must be one of: twitch, youtube, json", + }), + }), + data: z + .record(z.unknown()) + .refine(v => !Array.isArray(v), "data must be a non-null object"), + options: importOptionsSchema, +}); + +const jobIdQuerySchema = z.object({ + job_id: uuidSchema, +}); + +// ── POST /api/routes-f/import ───────────────────────────────────────────────── + +export async function POST(req: NextRequest) { + // 1. IP-level rate limit + const ip = + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + "unknown"; + + if (await isIpRateLimited(ip)) { + return NextResponse.json( + { error: "Too many requests" }, + { status: 429, headers: { "Retry-After": "60" } } + ); + } + + // 2. Session auth + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + // 3. Validate body + const bodyResult = await validateBody(req, importBodySchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { source, data, options = {} } = bodyResult.data; + + const resolvedOptions: Required = { + import_avatar: options?.import_avatar ?? true, + import_bio: options?.import_bio ?? true, + import_social_links: options?.import_social_links ?? true, + import_categories: options?.import_categories ?? true, + overwrite_existing: options?.overwrite_existing ?? false, + }; + + // 4. User-level rate limit: 1 successful or in-flight import per 24 hours + try { + await ensureJobsTable(); + + const { rows: recent } = await sql` + SELECT id FROM import_jobs + WHERE user_id = ${session.userId} + AND status != 'failed' + AND created_at > NOW() - INTERVAL '24 hours' + LIMIT 1 + `; + + if (recent.length > 0) { + return NextResponse.json( + { error: "Import limit reached. You may import once every 24 hours." }, + { status: 429, headers: { "Retry-After": "86400" } } + ); + } + } catch (err) { + console.error("[import] DB error checking rate limit:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + + // 5. Create job record + let jobId: string; + try { + const { rows } = await sql` + INSERT INTO import_jobs (user_id, status, source) + VALUES (${session.userId}, 'processing', ${source}) + RETURNING id + `; + jobId = rows[0].id; + } catch (err) { + console.error("[import] Failed to create job record:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + + // 6. Process import (synchronous — serverless has no true background jobs) + try { + let profile: ImportedProfile; + + switch (source) { + case "twitch": + profile = await importFromTwitch(data); + break; + case "youtube": + profile = await importFromYouTube(data); + break; + case "json": + profile = importFromJson(data); + break; + } + + await applyImport(session.userId, profile, resolvedOptions); + + await sql` + UPDATE import_jobs + SET status = 'done', result = ${JSON.stringify({ imported: profile })}, updated_at = NOW() + WHERE id = ${jobId} + `; + + return NextResponse.json({ job_id: jobId, status: "done" }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + console.error("[import] Processing failed:", err); + + try { + await sql` + UPDATE import_jobs + SET status = 'failed', error = ${message}, updated_at = NOW() + WHERE id = ${jobId} + `; + } catch (updateErr) { + console.error("[import] Failed to mark job as failed:", updateErr); + } + + return NextResponse.json( + { error: `Import failed: ${message}` }, + { status: 422 } + ); + } +} + +// ── GET /api/routes-f/import?job_id= ───────────────────────────────────────── + +export async function GET(req: NextRequest) { + // 1. IP-level rate limit + const ip = + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + "unknown"; + + if (await isIpRateLimited(ip)) { + return NextResponse.json( + { error: "Too many requests" }, + { status: 429, headers: { "Retry-After": "60" } } + ); + } + + // 2. Session auth + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + // 3. Validate job_id query param + const { searchParams } = new URL(req.url); + const queryResult = validateQuery(searchParams, jobIdQuerySchema); + if (queryResult instanceof Response) { + return queryResult; + } + const { job_id: jobId } = queryResult.data; + + // 4. Fetch job (scoped to authenticated user — no cross-user leakage) + try { + await ensureJobsTable(); + + const { rows } = await sql` + SELECT id, status, source, result, error, created_at, updated_at + FROM import_jobs + WHERE id = ${jobId} AND user_id = ${session.userId} + LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ error: "Job not found" }, { status: 404 }); + } + + const job = rows[0]; + return NextResponse.json({ + job_id: job.id, + status: job.status, + source: job.source, + result: job.result ?? null, + error: job.error ?? null, + created_at: job.created_at, + updated_at: job.updated_at, + }); + } catch (err) { + console.error("[import] DB error fetching job:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/integrations/[platform]/route.ts b/app/api/routes-f/integrations/[platform]/route.ts new file mode 100644 index 00000000..480826e3 --- /dev/null +++ b/app/api/routes-f/integrations/[platform]/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +const SUPPORTED_PLATFORMS = ["discord", "youtube", "twitter"] as const; + +/** + * DELETE /api/routes-f/integrations/[platform] + * Disconnect a third-party integration. + */ +export async function DELETE( + req: NextRequest, + { params }: { params: { platform: string } } +): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const platform = params.platform.toLowerCase(); + + if (!(SUPPORTED_PLATFORMS as readonly string[]).includes(platform)) { + return NextResponse.json( + { error: `Unsupported platform. Must be one of: ${SUPPORTED_PLATFORMS.join(", ")}` }, + { status: 400 } + ); + } + + try { + const { rowCount } = await sql` + DELETE FROM route_f_integrations + WHERE creator_id = ${session.userId} AND platform = ${platform} + `; + + if (rowCount === 0) { + return NextResponse.json({ error: "Integration not found" }, { status: 404 }); + } + + return NextResponse.json({ success: true, platform }); + } catch (error) { + console.error("[routes-f integrations DELETE]", error); + return NextResponse.json({ error: "Failed to disconnect integration" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/integrations/route.ts b/app/api/routes-f/integrations/route.ts new file mode 100644 index 00000000..bafab047 --- /dev/null +++ b/app/api/routes-f/integrations/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +const SUPPORTED_PLATFORMS = ["discord", "youtube", "twitter"] as const; +type Platform = (typeof SUPPORTED_PLATFORMS)[number]; + +const connectIntegrationSchema = z.object({ + platform: z.enum(SUPPORTED_PLATFORMS), + access_token: z.string().min(1), + refresh_token: z.string().optional(), +}); + +async function ensureIntegrationsSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS route_f_integrations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + platform TEXT NOT NULL CHECK (platform IN ('discord', 'youtube', 'twitter')), + access_token TEXT NOT NULL, + refresh_token TEXT, + connected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (creator_id, platform) + ) + `; +} + +/** + * GET /api/routes-f/integrations + * List connected integrations for the authenticated creator. + */ +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + try { + await ensureIntegrationsSchema(); + + const { rows } = await sql` + SELECT id, platform, connected_at, updated_at + FROM route_f_integrations + WHERE creator_id = ${session.userId} + ORDER BY connected_at DESC + `; + + return NextResponse.json({ integrations: rows }); + } catch (error) { + console.error("[routes-f integrations GET]", error); + return NextResponse.json({ error: "Failed to fetch integrations" }, { status: 500 }); + } +} + +/** + * POST /api/routes-f/integrations + * Connect a third-party integration. + */ +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const bodyResult = await validateBody(req, connectIntegrationSchema); + if (bodyResult instanceof Response) return bodyResult; + + const { platform, access_token, refresh_token } = bodyResult.data; + + try { + await ensureIntegrationsSchema(); + + const { rows } = await sql` + INSERT INTO route_f_integrations (creator_id, platform, access_token, refresh_token, updated_at) + VALUES (${session.userId}, ${platform}, ${access_token}, ${refresh_token ?? null}, NOW()) + ON CONFLICT (creator_id, platform) DO UPDATE SET + access_token = EXCLUDED.access_token, + refresh_token = EXCLUDED.refresh_token, + updated_at = NOW() + RETURNING id, platform, connected_at, updated_at + `; + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + console.error("[routes-f integrations POST]", error); + return NextResponse.json({ error: "Failed to connect integration" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/invites/[code]/route.ts b/app/api/routes-f/invites/[code]/route.ts new file mode 100644 index 00000000..8b807e98 --- /dev/null +++ b/app/api/routes-f/invites/[code]/route.ts @@ -0,0 +1,161 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { ensureInvitesSchema } from "../_lib/db"; + +type RouteContext = { + params: Promise<{ code: string }>; +}; + +type InviteLookupRow = { + code: string; + stream_id: string; + creator_id: string; + max_uses: number; + use_count: number; + expires_at: string | null; + revoked_at: string | null; + created_at: string; + username: string; + avatar: string | null; + is_live: boolean | null; + creator: { + streamTitle?: string; + category?: string; + tags?: string[]; + description?: string; + } | null; +}; + +function normalizeCode(value: string): string { + return value.trim().toUpperCase(); +} + +function isCodeUnavailable(invite: { + revoked_at: string | null; + expires_at: string | null; + use_count: number; + max_uses: number; +}): boolean { + if (invite.revoked_at) { + return true; + } + + if ( + invite.expires_at && + new Date(invite.expires_at).getTime() <= Date.now() + ) { + return true; + } + + return invite.use_count >= invite.max_uses; +} + +export async function GET( + _req: NextRequest, + { params }: RouteContext +): Promise { + const { code } = await params; + const normalizedCode = normalizeCode(code); + + if (!/^[A-Z0-9]{8}$/.test(normalizedCode)) { + return NextResponse.json({ error: "Invalid invite code" }, { status: 400 }); + } + + try { + await ensureInvitesSchema(); + + const { rows } = await sql` + SELECT + i.code, + i.stream_id, + i.creator_id, + i.max_uses, + i.use_count, + i.expires_at, + i.revoked_at, + i.created_at, + u.username, + u.avatar, + u.is_live, + u.creator + FROM route_f_stream_invites i + JOIN users u ON u.id = i.stream_id + WHERE i.code = ${normalizedCode} + LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ error: "Invite not found" }, { status: 404 }); + } + + const invite = rows[0] as InviteLookupRow; + + if (isCodeUnavailable(invite)) { + return NextResponse.json( + { error: "Invite code has expired or been exhausted" }, + { status: 410 } + ); + } + + const creator = invite.creator ?? {}; + + return NextResponse.json({ + code: invite.code, + remaining_uses: Math.max(invite.max_uses - invite.use_count, 0), + expires_at: invite.expires_at, + stream: { + id: invite.stream_id, + username: invite.username, + avatar: invite.avatar, + is_live: invite.is_live ?? false, + title: creator.streamTitle ?? "Untitled Stream", + category: creator.category ?? "", + tags: creator.tags ?? [], + description: creator.description ?? "", + }, + }); + } catch (error) { + console.error("[invites] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function DELETE( + req: NextRequest, + { params }: RouteContext +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { code } = await params; + const normalizedCode = normalizeCode(code); + + try { + await ensureInvitesSchema(); + + const { rows } = await sql` + UPDATE route_f_stream_invites + SET revoked_at = now(), updated_at = now() + WHERE code = ${normalizedCode} AND creator_id = ${session.userId} + RETURNING code + `; + + if (rows.length === 0) { + return NextResponse.json({ error: "Invite not found" }, { status: 404 }); + } + + return NextResponse.json({ revoked: true, code: normalizedCode }); + } catch (error) { + console.error("[invites] DELETE error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/invites/__tests__/route.test.ts b/app/api/routes-f/invites/__tests__/route.test.ts new file mode 100644 index 00000000..5583020b --- /dev/null +++ b/app/api/routes-f/invites/__tests__/route.test.ts @@ -0,0 +1,141 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +jest.mock("../_lib/db", () => ({ + ensureInvitesSchema: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("../../invites/_lib/db", () => ({ + ensureInvitesSchema: jest.fn().mockResolvedValue(undefined), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { POST } from "../route"; +import { GET, DELETE } from "../[code]/route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; +const STREAM_ID = "550e8400-e29b-41d4-a716-446655440000"; + +function makeRequest(method: string, path: string, body?: object) { + return new Request(`http://localhost${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; +} + +describe("routes-f invites", () => { + beforeEach(() => { + jest.clearAllMocks(); + verifySessionMock.mockResolvedValue({ + ok: true, + userId: STREAM_ID, + wallet: null, + privyId: "did:privy:abc", + username: "alice", + email: "alice@example.com", + }); + jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("creates an invite code for the creator's stream", async () => { + sqlMock + .mockResolvedValueOnce({ + rows: [ + { + id: STREAM_ID, + username: "alice", + avatar: null, + creator: { streamTitle: "Live coding" }, + is_live: true, + }, + ], + }) + .mockResolvedValueOnce({ + rows: [ + { + code: "ABCD1234", + stream_id: STREAM_ID, + creator_id: STREAM_ID, + max_uses: 5, + use_count: 0, + expires_at: null, + created_at: "2026-03-28T00:00:00Z", + }, + ], + }); + + const res = await POST( + makeRequest("POST", "/api/routes-f/invites", { + stream_id: STREAM_ID, + max_uses: 5, + }) + ); + + expect(res.status).toBe(201); + }); + + it("returns 410 for an expired invite code", async () => { + sqlMock.mockResolvedValueOnce({ + rows: [ + { + code: "ABCD1234", + stream_id: STREAM_ID, + creator_id: STREAM_ID, + max_uses: 5, + use_count: 0, + expires_at: "2020-01-01T00:00:00.000Z", + revoked_at: null, + created_at: "2026-03-28T00:00:00Z", + username: "alice", + avatar: null, + is_live: true, + creator: { streamTitle: "Live coding" }, + }, + ], + }); + + const res = await GET( + makeRequest("GET", "/api/routes-f/invites/ABCD1234"), + { + params: Promise.resolve({ code: "ABCD1234" }), + } + ); + + expect(res.status).toBe(410); + }); + + it("revokes an invite code for the creator", async () => { + sqlMock.mockResolvedValueOnce({ + rows: [{ code: "ABCD1234" }], + }); + + const res = await DELETE( + makeRequest("DELETE", "/api/routes-f/invites/ABCD1234"), + { params: Promise.resolve({ code: "ABCD1234" }) } + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.revoked).toBe(true); + }); +}); diff --git a/app/api/routes-f/invites/_lib/db.ts b/app/api/routes-f/invites/_lib/db.ts new file mode 100644 index 00000000..e397373b --- /dev/null +++ b/app/api/routes-f/invites/_lib/db.ts @@ -0,0 +1,27 @@ +import { sql } from "@vercel/postgres"; + +export async function ensureInvitesSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS route_f_stream_invites ( + code VARCHAR(8) PRIMARY KEY, + stream_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + max_uses INTEGER NOT NULL, + use_count INTEGER NOT NULL DEFAULT 0, + expires_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_route_f_stream_invites_creator_created + ON route_f_stream_invites (creator_id, created_at DESC) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_route_f_stream_invites_stream + ON route_f_stream_invites (stream_id, created_at DESC) + `; +} diff --git a/app/api/routes-f/invites/route.ts b/app/api/routes-f/invites/route.ts new file mode 100644 index 00000000..e76259b6 --- /dev/null +++ b/app/api/routes-f/invites/route.ts @@ -0,0 +1,115 @@ +import { randomBytes } from "node:crypto"; +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { uuidSchema } from "@/app/api/routes-f/_lib/schemas"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { ensureInvitesSchema } from "./_lib/db"; + +const INVITE_CODE_LENGTH = 8; +const INVITE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + +const createInviteSchema = z.object({ + stream_id: uuidSchema, + max_uses: z.number().int().min(1).max(10000), + expires_at: z.string().datetime({ offset: true }).optional(), +}); + +function generateInviteCode(): string { + const bytes = randomBytes(INVITE_CODE_LENGTH); + let code = ""; + + for (let index = 0; index < INVITE_CODE_LENGTH; index += 1) { + code += INVITE_ALPHABET[bytes[index] % INVITE_ALPHABET.length]; + } + + return code; +} + +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, createInviteSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { stream_id, max_uses, expires_at } = bodyResult.data; + + if (expires_at && new Date(expires_at).getTime() <= Date.now()) { + return NextResponse.json( + { error: "expires_at must be in the future" }, + { status: 400 } + ); + } + + try { + await ensureInvitesSchema(); + + const { rows: streamRows } = await sql` + SELECT id, username, avatar, creator, is_live + FROM users + WHERE id = ${stream_id} + LIMIT 1 + `; + + if (streamRows.length === 0) { + return NextResponse.json({ error: "Stream not found" }, { status: 404 }); + } + + if (streamRows[0].id !== session.userId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + for (let attempt = 0; attempt < 5; attempt += 1) { + const code = generateInviteCode(); + + try { + const { rows } = await sql` + INSERT INTO route_f_stream_invites ( + code, + stream_id, + creator_id, + max_uses, + expires_at + ) + VALUES ( + ${code}, + ${stream_id}, + ${session.userId}, + ${max_uses}, + ${expires_at ?? null} + ) + RETURNING code, stream_id, creator_id, max_uses, use_count, expires_at, created_at + `; + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + if ( + error instanceof Error && + "code" in error && + String((error as { code?: string }).code) === "23505" + ) { + continue; + } + + throw error; + } + } + + return NextResponse.json( + { error: "Failed to generate a unique invite code" }, + { status: 500 } + ); + } catch (error) { + console.error("[invites] POST error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/jobs/[id]/route.ts b/app/api/routes-f/jobs/[id]/route.ts new file mode 100644 index 00000000..2f8fbca4 --- /dev/null +++ b/app/api/routes-f/jobs/[id]/route.ts @@ -0,0 +1,126 @@ +/** + * GET /api/routes-f/jobs/[id] — get job status + result + * DELETE /api/routes-f/jobs/[id] — cancel a pending job + */ + +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { uuidSchema } from "@/app/api/routes-f/_lib/schemas"; +import { ensureJobsSchema } from "../_lib/db"; + +interface RouteParams { + params: Promise<{ id: string }> | { id: string }; +} + +function validateId(id: string): NextResponse | null { + const result = uuidSchema.safeParse(id); + if (!result.success) { + return NextResponse.json({ error: "Invalid job id" }, { status: 400 }); + } + return null; +} + +// ── GET ─────────────────────────────────────────────────────────────────────── + +export async function GET( + req: NextRequest, + context: RouteParams +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { id } = await context.params; + + const idError = validateId(id); + if (idError) { + return idError; + } + + try { + await ensureJobsSchema(); + + const { rows } = await sql` + SELECT id, type, status, payload, result, error, attempts, + max_attempts, created_at, started_at, completed_at + FROM jobs + WHERE id = ${id} AND user_id = ${session.userId} + LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ error: "Job not found" }, { status: 404 }); + } + + return NextResponse.json(rows[0]); + } catch (err) { + console.error("[jobs/[id]] GET error:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// ── DELETE ──────────────────────────────────────────────────────────────────── + +export async function DELETE( + req: NextRequest, + context: RouteParams +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { id } = await context.params; + + const idError = validateId(id); + if (idError) { + return idError; + } + + try { + await ensureJobsSchema(); + + // Only pending jobs can be cancelled + const { rows } = await sql` + UPDATE jobs + SET status = 'cancelled' + WHERE id = ${id} + AND user_id = ${session.userId} + AND status = 'pending' + RETURNING id, status + `; + + if (rows.length === 0) { + // Either job doesn't exist, doesn't belong to user, or isn't pending + const { rows: existing } = await sql` + SELECT id, status FROM jobs + WHERE id = ${id} AND user_id = ${session.userId} + LIMIT 1 + `; + + if (existing.length === 0) { + return NextResponse.json({ error: "Job not found" }, { status: 404 }); + } + + return NextResponse.json( + { + error: `Cannot cancel a job with status "${existing[0].status}". Only pending jobs can be cancelled.`, + }, + { status: 409 } + ); + } + + return NextResponse.json({ id: rows[0].id, status: "cancelled" }); + } catch (err) { + console.error("[jobs/[id]] DELETE error:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/jobs/__tests__/id.test.ts b/app/api/routes-f/jobs/__tests__/id.test.ts new file mode 100644 index 00000000..d46e9f7e --- /dev/null +++ b/app/api/routes-f/jobs/__tests__/id.test.ts @@ -0,0 +1,164 @@ +/** + * Tests for GET /api/routes-f/jobs/[id] and DELETE /api/routes-f/jobs/[id] + */ + +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +jest.mock("../_lib/db", () => ({ + ensureJobsSchema: jest.fn().mockResolvedValue(undefined), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { GET, DELETE } from "../[id]/route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +const AUTHED_SESSION = { + ok: true as const, + userId: "user-id", + wallet: null, + privyId: "did:privy:abc", + username: "alice", + email: "alice@example.com", +}; + +const VALID_JOB_ID = "550e8400-e29b-41d4-a716-446655440000"; + +function makeGetRequest() { + return new Request(`http://localhost/api/routes-f/jobs/${VALID_JOB_ID}`, { + method: "GET", + }) as unknown as import("next/server").NextRequest; +} + +function makeDeleteRequest() { + return new Request(`http://localhost/api/routes-f/jobs/${VALID_JOB_ID}`, { + method: "DELETE", + }) as unknown as import("next/server").NextRequest; +} + +const VALID_PARAMS = { params: { id: VALID_JOB_ID } }; +const INVALID_PARAMS = { params: { id: "not-a-uuid" } }; + +describe("GET /api/routes-f/jobs/[id]", () => { + let consoleSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + verifySessionMock.mockResolvedValue(AUTHED_SESSION); + }); + + afterEach(() => consoleSpy.mockRestore()); + + it("returns 401 when not authenticated", async () => { + verifySessionMock.mockResolvedValue({ + ok: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }), + }); + const res = await GET(makeGetRequest(), VALID_PARAMS); + expect(res.status).toBe(401); + }); + + it("returns 400 for an invalid job id format", async () => { + const res = await GET(makeGetRequest(), INVALID_PARAMS); + expect(res.status).toBe(400); + }); + + it("returns 404 when job does not exist", async () => { + sqlMock.mockResolvedValue({ rows: [] }); + const res = await GET(makeGetRequest(), VALID_PARAMS); + expect(res.status).toBe(404); + }); + + it("returns job details when found", async () => { + const mockJob = { + id: VALID_JOB_ID, + type: "export", + status: "completed", + result: { download_url: "https://r2.example.com/file.csv" }, + }; + sqlMock.mockResolvedValue({ rows: [mockJob] }); + + const res = await GET(makeGetRequest(), VALID_PARAMS); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.id).toBe(VALID_JOB_ID); + expect(json.status).toBe("completed"); + }); +}); + +describe("DELETE /api/routes-f/jobs/[id]", () => { + let consoleSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + verifySessionMock.mockResolvedValue(AUTHED_SESSION); + }); + + afterEach(() => consoleSpy.mockRestore()); + + it("returns 400 for an invalid job id format", async () => { + const res = await DELETE(makeDeleteRequest(), INVALID_PARAMS); + expect(res.status).toBe(400); + }); + + it("cancels a pending job and returns cancelled status", async () => { + sqlMock.mockResolvedValue({ + rows: [{ id: VALID_JOB_ID, status: "cancelled" }], + }); + + const res = await DELETE(makeDeleteRequest(), VALID_PARAMS); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.status).toBe("cancelled"); + }); + + it("returns 409 when trying to cancel a non-pending job", async () => { + // UPDATE returns 0 rows (job is not pending) + sqlMock.mockResolvedValueOnce({ rows: [] }); + // SELECT returns the job with a non-pending status + sqlMock.mockResolvedValueOnce({ + rows: [{ id: VALID_JOB_ID, status: "running" }], + }); + + const res = await DELETE(makeDeleteRequest(), VALID_PARAMS); + expect(res.status).toBe(409); + const json = await res.json(); + expect(json.error).toMatch(/running/); + }); + + it("returns 404 when job does not exist", async () => { + // UPDATE returns 0 rows + sqlMock.mockResolvedValueOnce({ rows: [] }); + // SELECT also returns 0 rows (job not found) + sqlMock.mockResolvedValueOnce({ rows: [] }); + + const res = await DELETE(makeDeleteRequest(), VALID_PARAMS); + expect(res.status).toBe(404); + }); + + it("returns 500 on database error", async () => { + sqlMock.mockRejectedValue(new Error("DB down")); + const res = await DELETE(makeDeleteRequest(), VALID_PARAMS); + expect(res.status).toBe(500); + }); +}); diff --git a/app/api/routes-f/jobs/__tests__/route.test.ts b/app/api/routes-f/jobs/__tests__/route.test.ts new file mode 100644 index 00000000..4bf7c7cf --- /dev/null +++ b/app/api/routes-f/jobs/__tests__/route.test.ts @@ -0,0 +1,210 @@ +/** + * Tests for GET /api/routes-f/jobs and POST /api/routes-f/jobs + */ + +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +jest.mock("../_lib/db", () => ({ + ensureJobsSchema: jest.fn().mockResolvedValue(undefined), + JOB_TYPES: new Set([ + "export", + "clip_process", + "batch_notify", + "leaderboard_refresh", + "sitemap_refresh", + ]), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { GET, POST } from "../route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +const AUTHED_SESSION = { + ok: true as const, + userId: "user-id", + wallet: null, + privyId: "did:privy:abc", + username: "alice", + email: "alice@example.com", +}; + +function makeGetRequest(search = "") { + return new Request(`http://localhost/api/routes-f/jobs${search}`, { + method: "GET", + }) as unknown as import("next/server").NextRequest; +} + +function makePostRequest(body: object) { + return new Request("http://localhost/api/routes-f/jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) as unknown as import("next/server").NextRequest; +} + +describe("GET /api/routes-f/jobs", () => { + let consoleSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + verifySessionMock.mockResolvedValue(AUTHED_SESSION); + }); + + afterEach(() => consoleSpy.mockRestore()); + + it("returns 401 when not authenticated", async () => { + verifySessionMock.mockResolvedValue({ + ok: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }), + }); + const res = await GET(makeGetRequest()); + expect(res.status).toBe(401); + }); + + it("returns a list of jobs for the current user", async () => { + const mockJobs = [ + { + id: "job-1", + type: "export", + status: "completed", + created_at: "2026-03-27T00:00:00Z", + }, + ]; + sqlMock.mockResolvedValue({ rows: mockJobs }); + + const res = await GET(makeGetRequest()); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.jobs).toHaveLength(1); + expect(json.jobs[0].id).toBe("job-1"); + }); + + it("returns 400 for invalid limit query param", async () => { + const res = await GET(makeGetRequest("?limit=999")); + expect(res.status).toBe(400); + }); + + it("returns next_cursor when more jobs exist", async () => { + const mockJobs = Array.from({ length: 20 }, (_, i) => ({ + id: `job-${i}`, + type: "export", + status: "pending", + created_at: new Date().toISOString(), + })); + sqlMock.mockResolvedValue({ rows: mockJobs }); + + const res = await GET(makeGetRequest("?limit=20")); + const json = await res.json(); + expect(json.next_cursor).toBe("job-19"); + }); + + it("returns next_cursor: null when fewer jobs than limit", async () => { + sqlMock.mockResolvedValue({ + rows: [{ id: "job-1", type: "export", status: "done" }], + }); + + const res = await GET(makeGetRequest("?limit=20")); + const json = await res.json(); + expect(json.next_cursor).toBeNull(); + }); +}); + +describe("POST /api/routes-f/jobs", () => { + let consoleSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + verifySessionMock.mockResolvedValue(AUTHED_SESSION); + }); + + afterEach(() => consoleSpy.mockRestore()); + + it("returns 401 when not authenticated", async () => { + verifySessionMock.mockResolvedValue({ + ok: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }), + }); + const res = await POST(makePostRequest({ type: "export" })); + expect(res.status).toBe(401); + }); + + it("enqueues a job and returns 201 with job id", async () => { + sqlMock.mockResolvedValue({ + rows: [ + { + id: "job-new", + type: "export", + status: "pending", + created_at: "2026-03-27T00:00:00Z", + }, + ], + }); + + const res = await POST(makePostRequest({ type: "export" })); + expect(res.status).toBe(201); + const json = await res.json(); + expect(json.id).toBe("job-new"); + expect(json.status).toBe("pending"); + }); + + it("returns 400 for an unknown job type", async () => { + const res = await POST(makePostRequest({ type: "unknown_type" })); + expect(res.status).toBe(400); + }); + + it("returns 400 for missing type", async () => { + const res = await POST(makePostRequest({})); + expect(res.status).toBe(400); + }); + + it("accepts optional payload and max_attempts", async () => { + sqlMock.mockResolvedValue({ + rows: [ + { + id: "job-2", + type: "batch_notify", + status: "pending", + created_at: "2026-03-27T00:00:00Z", + }, + ], + }); + + const res = await POST( + makePostRequest({ + type: "batch_notify", + payload: { event_type: "live" }, + max_attempts: 5, + }) + ); + expect(res.status).toBe(201); + }); + + it("returns 500 on database error", async () => { + sqlMock.mockRejectedValue(new Error("DB down")); + const res = await POST(makePostRequest({ type: "export" })); + expect(res.status).toBe(500); + }); +}); diff --git a/app/api/routes-f/jobs/_lib/db.ts b/app/api/routes-f/jobs/_lib/db.ts new file mode 100644 index 00000000..a656f265 --- /dev/null +++ b/app/api/routes-f/jobs/_lib/db.ts @@ -0,0 +1,86 @@ +/** + * Shared DB helpers for the jobs API. + */ + +import { sql } from "@vercel/postgres"; + +export type JobStatus = + | "pending" + | "running" + | "completed" + | "failed" + | "cancelled"; + +export type JobType = + | "export" + | "clip_process" + | "batch_notify" + | "leaderboard_refresh" + | "sitemap_refresh"; + +export interface Job { + id: string; + user_id: string | null; + type: JobType; + status: JobStatus; + payload: Record | null; + result: Record | null; + error: string | null; + attempts: number; + max_attempts: number; + created_at: string; + started_at: string | null; + completed_at: string | null; +} + +export const JOB_TYPES = new Set([ + "export", + "clip_process", + "batch_notify", + "leaderboard_refresh", + "sitemap_refresh", +]); + +/** Creates the jobs table and enum types if they don't already exist. */ +export async function ensureJobsSchema(): Promise { + // Use DO $$ block to create types only if missing (DDL is not transactional in PG) + await sql` + DO $$ BEGIN + CREATE TYPE job_status AS ENUM ( + 'pending', 'running', 'completed', 'failed', 'cancelled' + ); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$ + `; + + await sql` + DO $$ BEGIN + CREATE TYPE job_type AS ENUM ( + 'export', 'clip_process', 'batch_notify', 'leaderboard_refresh', 'sitemap_refresh' + ); + EXCEPTION WHEN duplicate_object THEN NULL; + END $$ + `; + + await sql` + CREATE TABLE IF NOT EXISTS jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + type job_type NOT NULL, + status job_status NOT NULL DEFAULT 'pending', + payload JSONB, + result JSONB, + error TEXT, + attempts INT NOT NULL DEFAULT 0, + max_attempts INT NOT NULL DEFAULT 3, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS jobs_user_status + ON jobs (user_id, status, created_at DESC) + `; +} diff --git a/app/api/routes-f/jobs/_lib/process.ts b/app/api/routes-f/jobs/_lib/process.ts new file mode 100644 index 00000000..01d2ceb1 --- /dev/null +++ b/app/api/routes-f/jobs/_lib/process.ts @@ -0,0 +1,158 @@ +/** + * Background job processor — called by Vercel Cron every minute via + * POST /api/routes-f/jobs/process (see app/api/routes-f/jobs/process/route.ts). + * + * Processing loop: + * 1. Select the oldest pending job. + * 2. Mark it as 'running'. + * 3. Execute the appropriate handler. + * 4. On success: mark 'completed', store result. + * 5. On failure: increment attempts; if attempts < max_attempts, mark back + * to 'pending' with exponential backoff delay; otherwise mark 'failed'. + * + * Auto-cleanup: completed/failed/cancelled jobs older than 30 days are deleted. + */ + +import { sql } from "@vercel/postgres"; +import { ensureJobsSchema, type JobType, type Job } from "./db"; + +// ── Job handlers ────────────────────────────────────────────────────────────── + +type JobResult = Record; + +async function handleExport(job: Job): Promise { + // Placeholder: generate CSV/JSON and upload to R2, return download URL. + // Real implementation would use an R2 client and stream data from the DB. + const format = (job.payload?.format as string) ?? "json"; + return { + message: "Export completed", + format, + download_url: null, // populated by real implementation + }; +} + +async function handleClipProcess(job: Job): Promise { + // Placeholder: poll Mux asset status and update stream_recordings. + const assetId = job.payload?.asset_id as string | undefined; + if (!assetId) { + throw new Error("clip_process job missing payload.asset_id"); + } + return { asset_id: assetId, status: "ready" }; +} + +async function handleBatchNotify(job: Job): Promise { + // Placeholder: send bulk notifications to followers. + const eventType = job.payload?.event_type as string | undefined; + return { event_type: eventType, sent: 0 }; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function handleLeaderboardRefresh(_job: Job): Promise { + // Placeholder: recompute leaderboard cache. + return { recomputed_at: new Date().toISOString() }; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function handleSitemapRefresh(_job: Job): Promise { + // Placeholder: regenerate sitemap and upload to CDN. + return { refreshed_at: new Date().toISOString() }; +} + +const HANDLERS: Record Promise> = { + export: handleExport, + clip_process: handleClipProcess, + batch_notify: handleBatchNotify, + leaderboard_refresh: handleLeaderboardRefresh, + sitemap_refresh: handleSitemapRefresh, +}; + +// ── Exponential backoff ─────────────────────────────────────────────────────── + +/** Returns the number of seconds to wait before retrying based on attempt count. */ +function backoffSeconds(attempts: number): number { + // 30s, 2m, 8m, 32m, … + return Math.min(30 * Math.pow(4, attempts - 1), 3600); +} + +// ── Core processor ──────────────────────────────────────────────────────────── + +/** + * Process one pending job. + * Returns the processed job id, or null if no pending job was found. + */ +export async function processNextJob(): Promise { + await ensureJobsSchema(); + + // Select and claim the oldest pending job atomically + const { rows: claimed } = await sql` + UPDATE jobs + SET status = 'running', started_at = now(), attempts = attempts + 1 + WHERE id = ( + SELECT id FROM jobs + WHERE status = 'pending' + ORDER BY created_at ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + RETURNING * + `; + + if (claimed.length === 0) { + return null; + } + + const job = claimed[0] as Job; + const handler = HANDLERS[job.type]; + + if (!handler) { + await sql` + UPDATE jobs + SET status = 'failed', error = ${"Unknown job type: " + job.type}, completed_at = now() + WHERE id = ${job.id} + `; + return job.id; + } + + try { + const result = await handler(job); + await sql` + UPDATE jobs + SET status = 'completed', result = ${JSON.stringify(result)}, completed_at = now() + WHERE id = ${job.id} + `; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Unknown error"; + console.error(`[jobs/process] Job ${job.id} (${job.type}) failed:`, err); + + if (job.attempts < job.max_attempts) { + const delaySec = backoffSeconds(job.attempts); + await sql` + UPDATE jobs + SET status = 'pending', + error = ${errorMessage}, + -- Schedule retry by shifting created_at into the future so it sorts + -- after currently-pending jobs. + created_at = now() + (${delaySec} || ' seconds')::INTERVAL + WHERE id = ${job.id} + `; + } else { + await sql` + UPDATE jobs + SET status = 'failed', error = ${errorMessage}, completed_at = now() + WHERE id = ${job.id} + `; + } + } + + return job.id; +} + +/** Delete completed/failed/cancelled jobs older than 30 days. */ +export async function cleanupOldJobs(): Promise { + const { rowCount } = await sql` + DELETE FROM jobs + WHERE status IN ('completed', 'failed', 'cancelled') + AND created_at < now() - INTERVAL '30 days' + `; + return rowCount ?? 0; +} diff --git a/app/api/routes-f/jobs/process/route.ts b/app/api/routes-f/jobs/process/route.ts new file mode 100644 index 00000000..e3d91564 --- /dev/null +++ b/app/api/routes-f/jobs/process/route.ts @@ -0,0 +1,38 @@ +/** + * POST /api/routes-f/jobs/process + * + * Called by Vercel Cron every minute. + * Secured by the CRON_SECRET environment variable (set in Vercel project settings). + * + * vercel.json entry: + * { "path": "/api/routes-f/jobs/process", "schedule": "* * * * *" } + */ + +import { NextRequest, NextResponse } from "next/server"; +import { processNextJob, cleanupOldJobs } from "../_lib/process"; + +export async function POST(req: NextRequest): Promise { + // Verify cron secret — Vercel sets the Authorization header automatically + const authHeader = req.headers.get("authorization"); + const cronSecret = process.env.CRON_SECRET; + + if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const jobId = await processNextJob(); + const cleaned = await cleanupOldJobs(); + + return NextResponse.json({ + processed: jobId ?? null, + cleaned, + }); + } catch (err) { + console.error("[jobs/process] Cron error:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/jobs/route.ts b/app/api/routes-f/jobs/route.ts new file mode 100644 index 00000000..c6ded7d2 --- /dev/null +++ b/app/api/routes-f/jobs/route.ts @@ -0,0 +1,141 @@ +/** + * GET /api/routes-f/jobs — list jobs for the current user + * POST /api/routes-f/jobs — enqueue a new background job + */ + +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { paginationSchema } from "@/app/api/routes-f/_lib/schemas"; +import { ensureJobsSchema, JOB_TYPES, type JobType } from "./_lib/db"; + +const enqueueBodySchema = z.object({ + type: z + .string() + .refine( + (v): v is JobType => JOB_TYPES.has(v as JobType), + `type must be one of: ${[...JOB_TYPES].join(", ")}` + ), + payload: z.record(z.unknown()).optional(), + max_attempts: z.number().int().min(1).max(10).optional(), +}); + +const listQuerySchema = paginationSchema.extend({ + status: z + .enum(["pending", "running", "completed", "failed", "cancelled"]) + .optional(), +}); + +// ── GET ─────────────────────────────────────────────────────────────────────── + +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { searchParams } = new URL(req.url); + const queryResult = validateQuery(searchParams, listQuerySchema); + if (queryResult instanceof Response) { + return queryResult; + } + + const { limit, cursor, status } = queryResult.data; + + try { + await ensureJobsSchema(); + + let rows: unknown[]; + + if (cursor && status) { + ({ rows } = await sql` + SELECT id, type, status, payload, result, error, attempts, + max_attempts, created_at, started_at, completed_at + FROM jobs + WHERE user_id = ${session.userId} + AND status = ${status} + AND created_at < (SELECT created_at FROM jobs WHERE id = ${cursor} LIMIT 1) + ORDER BY created_at DESC + LIMIT ${limit} + `); + } else if (cursor) { + ({ rows } = await sql` + SELECT id, type, status, payload, result, error, attempts, + max_attempts, created_at, started_at, completed_at + FROM jobs + WHERE user_id = ${session.userId} + AND created_at < (SELECT created_at FROM jobs WHERE id = ${cursor} LIMIT 1) + ORDER BY created_at DESC + LIMIT ${limit} + `); + } else if (status) { + ({ rows } = await sql` + SELECT id, type, status, payload, result, error, attempts, + max_attempts, created_at, started_at, completed_at + FROM jobs + WHERE user_id = ${session.userId} + AND status = ${status} + ORDER BY created_at DESC + LIMIT ${limit} + `); + } else { + ({ rows } = await sql` + SELECT id, type, status, payload, result, error, attempts, + max_attempts, created_at, started_at, completed_at + FROM jobs + WHERE user_id = ${session.userId} + ORDER BY created_at DESC + LIMIT ${limit} + `); + } + + const nextCursor = + rows.length === limit + ? (rows[rows.length - 1] as { id: string }).id + : null; + + return NextResponse.json({ jobs: rows, next_cursor: nextCursor }); + } catch (err) { + console.error("[jobs] GET error:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// ── POST ────────────────────────────────────────────────────────────────────── + +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, enqueueBodySchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { type, payload = {}, max_attempts = 3 } = bodyResult.data; + + try { + await ensureJobsSchema(); + + const { rows } = await sql` + INSERT INTO jobs (user_id, type, payload, max_attempts) + VALUES (${session.userId}, ${type}, ${JSON.stringify(payload)}, ${max_attempts}) + RETURNING id, type, status, created_at + `; + + return NextResponse.json(rows[0], { status: 201 }); + } catch (err) { + console.error("[jobs] POST error:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/leaderboard/route.ts b/app/api/routes-f/leaderboard/route.ts new file mode 100644 index 00000000..d2a8e909 --- /dev/null +++ b/app/api/routes-f/leaderboard/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { validateQuery } from "@/app/api/routes-f/_lib/validate"; + +/** + * GET /api/routes-f/leaderboard + * Query params: ?category=earnings|viewers|followers|streams&period=7d|30d|all&limit=20 + */ + +const leaderboardQuerySchema = z.object({ + category: z + .enum(["earnings", "viewers", "followers", "streams"]) + .default("earnings"), + period: z.enum(["7d", "30d", "all"]).default("7d"), + limit: z.coerce.number().min(1).max(100).default(20), +}); + +function periodToDate(period: "7d" | "30d" | "all"): Date { + if (period === "7d") return new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + if (period === "30d") return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + return new Date(0); // epoch — effectively "no filter" +} + +export async function GET(req: NextRequest): Promise { + const { searchParams } = new URL(req.url); + const queryResult = validateQuery(searchParams, leaderboardQuerySchema); + if (queryResult instanceof Response) return queryResult; + + const { category, period, limit } = queryResult.data; + const since = periodToDate(period ?? "7d").toISOString(); + + try { + let entries: unknown[] = []; + + if (category === "earnings") { + const { rows } = await sql` + SELECT + ROW_NUMBER() OVER (ORDER BY COALESCE(SUM(t.amount_usdc), 0) DESC)::int AS rank, + u.username, + u.avatar, + u.is_live, + COALESCE(SUM(t.amount_usdc), 0)::numeric(20,2)::text AS value, + 'USDC earned' AS value_label + FROM users u + LEFT JOIN transactions t + ON t.recipient_id = u.id AND t.created_at >= ${since} + GROUP BY u.id, u.username, u.avatar, u.is_live + ORDER BY COALESCE(SUM(t.amount_usdc), 0) DESC + LIMIT ${limit} + `; + entries = rows; + } else if (category === "viewers") { + const { rows } = await sql` + SELECT + ROW_NUMBER() OVER (ORDER BY COALESCE(SUM(ss.peak_viewers), 0) DESC)::int AS rank, + u.username, + u.avatar, + u.is_live, + COALESCE(SUM(ss.peak_viewers), 0)::text AS value, + 'peak viewers' AS value_label + FROM users u + LEFT JOIN stream_sessions ss + ON ss.user_id = u.id AND ss.created_at >= ${since} + GROUP BY u.id, u.username, u.avatar, u.is_live + ORDER BY COALESCE(SUM(ss.peak_viewers), 0) DESC + LIMIT ${limit} + `; + entries = rows; + } else if (category === "followers") { + const { rows } = await sql` + SELECT + ROW_NUMBER() OVER (ORDER BY COUNT(f.follower_id) DESC)::int AS rank, + u.username, + u.avatar, + u.is_live, + COUNT(f.follower_id)::text AS value, + 'followers' AS value_label + FROM users u + LEFT JOIN follows f + ON f.following_id = u.id AND f.created_at >= ${since} + GROUP BY u.id, u.username, u.avatar, u.is_live + ORDER BY COUNT(f.follower_id) DESC + LIMIT ${limit} + `; + entries = rows; + } else { + // streams + const { rows } = await sql` + SELECT + ROW_NUMBER() OVER (ORDER BY COUNT(ss.id) DESC)::int AS rank, + u.username, + u.avatar, + u.is_live, + COUNT(ss.id)::text AS value, + 'streams' AS value_label + FROM users u + LEFT JOIN stream_sessions ss + ON ss.user_id = u.id AND ss.created_at >= ${since} + GROUP BY u.id, u.username, u.avatar, u.is_live + ORDER BY COUNT(ss.id) DESC + LIMIT ${limit} + `; + entries = rows; + } + + return NextResponse.json({ + category, + period, + entries, + generated_at: new Date().toISOString(), + }); + } catch (error) { + console.error("[leaderboard] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/live/chat/[messageId]/route.ts b/app/api/routes-f/live/chat/[messageId]/route.ts new file mode 100644 index 00000000..6e80e2e5 --- /dev/null +++ b/app/api/routes-f/live/chat/[messageId]/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +// ── DELETE /api/routes-f/live/chat/[messageId] ──────────────────────────────── +// Soft-deletes a chat message. Caller must be the stream owner or a moderator +// (currently: stream owner only — moderator table can extend this later). +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ messageId: string }> } +) { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const { messageId: rawMessageId } = await params; + const messageId = parseInt(rawMessageId, 10); + if (isNaN(messageId)) { + return NextResponse.json({ error: "Invalid message ID" }, { status: 400 }); + } + + try { + // Fetch message + stream owner in one query + const result = await sql` + SELECT + cm.id, + cm.is_deleted, + ss.user_id AS stream_owner_id + FROM chat_messages cm + JOIN stream_sessions ss ON cm.stream_session_id = ss.id + WHERE cm.id = ${messageId} + LIMIT 1 + `; + + if (result.rows.length === 0) { + return NextResponse.json({ error: "Message not found" }, { status: 404 }); + } + + const msg = result.rows[0]; + + if (msg.is_deleted) { + return NextResponse.json( + { error: "Message already deleted" }, + { status: 409 } + ); + } + + // Only stream owner (or the message author themselves) may delete + if (session.userId !== msg.stream_owner_id) { + // Allow self-delete + const authorCheck = await sql` + SELECT user_id FROM chat_messages WHERE id = ${messageId} LIMIT 1 + `; + if (authorCheck.rows[0]?.user_id !== session.userId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + } + + await sql` + UPDATE chat_messages SET + is_deleted = true, + is_moderated = true, + moderated_by = ${session.userId} + WHERE id = ${messageId} + `; + + return NextResponse.json({ ok: true }, { status: 200 }); + } catch (error) { + console.error("[chat:DELETE] error:", error); + return NextResponse.json( + { error: "Failed to delete message" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/live/chat/route.ts b/app/api/routes-f/live/chat/route.ts new file mode 100644 index 00000000..bf0c9d40 --- /dev/null +++ b/app/api/routes-f/live/chat/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { createRateLimiter } from "@/lib/rate-limit"; + +const isRateLimited = createRateLimiter(60_000, 60); // 60 reads/min per IP + +// ── GET /api/routes-f/live/chat?stream_id=&limit=50&cursor= ────────────────── +// Returns paginated chat history in reverse-chronological order (newest first). +// cursor = last message id from previous page (exclusive upper bound). +export async function GET(req: NextRequest) { + const ip = + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + "unknown"; + + if (await isRateLimited(ip)) { + return NextResponse.json( + { error: "Too many requests" }, + { status: 429, headers: { "Retry-After": "60" } } + ); + } + + const { searchParams } = req.nextUrl; + const streamId = searchParams.get("stream_id"); + const rawLimit = parseInt(searchParams.get("limit") ?? "50", 10); + const cursor = searchParams.get("cursor"); // message id cursor + + if (!streamId) { + return NextResponse.json( + { error: "stream_id is required" }, + { status: 400 } + ); + } + + const limit = Math.min(Math.max(1, isNaN(rawLimit) ? 50 : rawLimit), 100); + + try { + // Resolve active stream session for this streamer + const sessionResult = await sql` + SELECT ss.id AS session_id + FROM stream_sessions ss + WHERE ss.user_id = ${streamId} + AND ss.ended_at IS NULL + ORDER BY ss.started_at DESC + LIMIT 1 + `; + + if (sessionResult.rows.length === 0) { + return NextResponse.json( + { messages: [], nextCursor: null }, + { status: 200 } + ); + } + + const sessionId = sessionResult.rows[0].session_id; + const cursorId = cursor ? parseInt(cursor, 10) : null; + + const rows = await sql` + SELECT + cm.id, + cm.username AS sender_username, + cm.content AS body, + cm.created_at AS sent_at, + cm.is_deleted + FROM chat_messages cm + WHERE cm.stream_session_id = ${sessionId} + AND (${cursorId}::int IS NULL OR cm.id < ${cursorId}) + ORDER BY cm.id DESC + LIMIT ${limit} + `; + + const messages = rows.rows.map(r => ({ + id: r.id, + sender_username: r.sender_username, + body: r.body, + sent_at: r.sent_at, + is_deleted: r.is_deleted, + })); + + // Provide next cursor if there may be more pages + const nextCursor = + messages.length === limit + ? String(messages[messages.length - 1].id) + : null; + + return NextResponse.json({ messages, nextCursor }, { status: 200 }); + } catch (error) { + console.error("[chat:GET] error:", error); + return NextResponse.json( + { error: "Failed to fetch messages" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/live/events/route.ts b/app/api/routes-f/live/events/route.ts new file mode 100644 index 00000000..b3ca5a11 --- /dev/null +++ b/app/api/routes-f/live/events/route.ts @@ -0,0 +1,175 @@ +import { timingSafeEqual } from "crypto"; +import { NextRequest, NextResponse } from "next/server"; +import { createRateLimiter } from "@/lib/rate-limit"; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export type StreamEventType = + | "raid_in" + | "raid_out" + | "gift_sent" + | "subscription" + | "stream_start" + | "stream_end"; + +const EVENT_TYPE_GROUPS: Record = { + all: [ + "raid_in", + "raid_out", + "gift_sent", + "subscription", + "stream_start", + "stream_end", + ], + raid: ["raid_in", "raid_out"], + gift: ["gift_sent"], + sub: ["subscription"], +}; + +interface StreamEvent { + id: string; + stream_id: string; + type: StreamEventType; + payload: Record; + timestamp: string; // ISO-8601 +} + +// ── In-memory store ─────────────────────────────────────────────────────────── +// Maps stream_id → StreamEvent[] (chronological order, capped at 500 per stream) +const MAX_EVENTS_PER_STREAM = 500; +const eventStore = new Map(); + +let _counter = 0; +function nextId(): string { + return `evt_${Date.now()}_${(++_counter).toString(36)}`; +} + +// ── Auth helpers ────────────────────────────────────────────────────────────── + +function verifyInternalSecret(req: NextRequest): boolean { + const secret = process.env.INTERNAL_SECRET; + if (!secret) { + // Not configured — block all POST calls in production, warn in dev + if (process.env.NODE_ENV === "production") return false; + console.warn( + "[events] INTERNAL_SECRET not set — allowing POST in dev only" + ); + return true; + } + + const header = req.headers.get("x-internal-secret"); + if (!header) return false; + + try { + const a = Buffer.from(header); + const b = Buffer.from(secret); + // Lengths must match for timingSafeEqual + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); + } catch { + return false; + } +} + +// ── Rate limiter (GET only — POST is internal) ──────────────────────────────── +const isRateLimited = createRateLimiter(60_000, 120); + +// ── GET /api/routes-f/live/events?stream_id=&type=all|raid|gift|sub ────────── +// Returns stream lifecycle events in chronological order. +export async function GET(req: NextRequest) { + const ip = + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + "unknown"; + + if (await isRateLimited(ip)) { + return NextResponse.json( + { error: "Too many requests" }, + { status: 429, headers: { "Retry-After": "60" } } + ); + } + + const { searchParams } = req.nextUrl; + const streamId = searchParams.get("stream_id"); + const typeFilter = (searchParams.get("type") ?? "all").toLowerCase(); + + if (!streamId) { + return NextResponse.json( + { error: "stream_id is required" }, + { status: 400 } + ); + } + + const allowedTypes = EVENT_TYPE_GROUPS[typeFilter]; + if (!allowedTypes) { + return NextResponse.json( + { + error: `Invalid type. Must be one of: ${Object.keys(EVENT_TYPE_GROUPS).join(", ")}`, + }, + { status: 400 } + ); + } + + const all = eventStore.get(streamId) ?? []; + const events = + typeFilter === "all" ? all : all.filter(e => allowedTypes.includes(e.type)); + + return NextResponse.json({ events }, { status: 200 }); +} + +// ── POST /api/routes-f/live/events ─────────────────────────────────────────── +// Internal: record a stream lifecycle event. +// Requires X-Internal-Secret header matching INTERNAL_SECRET env var. +export async function POST(req: NextRequest) { + if (!verifyInternalSecret(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: { + stream_id?: string; + type?: string; + payload?: Record; + }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { stream_id, type, payload = {} } = body; + + if (!stream_id || !type) { + return NextResponse.json( + { error: "stream_id and type are required" }, + { status: 400 } + ); + } + + const validTypes = EVENT_TYPE_GROUPS["all"]; + if (!validTypes.includes(type as StreamEventType)) { + return NextResponse.json( + { error: `Invalid event type. Must be one of: ${validTypes.join(", ")}` }, + { status: 400 } + ); + } + + const event: StreamEvent = { + id: nextId(), + stream_id, + type: type as StreamEventType, + payload, + timestamp: new Date().toISOString(), + }; + + const existing = eventStore.get(stream_id) ?? []; + existing.push(event); + + // Cap per-stream history to avoid unbounded memory growth + if (existing.length > MAX_EVENTS_PER_STREAM) { + existing.splice(0, existing.length - MAX_EVENTS_PER_STREAM); + } + + eventStore.set(stream_id, existing); + + return NextResponse.json({ ok: true, event }, { status: 201 }); +} diff --git a/app/api/routes-f/live/viewers/route.ts b/app/api/routes-f/live/viewers/route.ts new file mode 100644 index 00000000..e5f68497 --- /dev/null +++ b/app/api/routes-f/live/viewers/route.ts @@ -0,0 +1,177 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { createRateLimiter } from "@/lib/rate-limit"; + +// ── Constants ───────────────────────────────────────────────────────────────── +const VIEWER_TTL_MS = 60_000; // expire after 60s of no heartbeat +const HEARTBEAT_RATE_LIMIT = createRateLimiter(30_000, 5); // 5 pings per 30s per IP + +// ── In-memory presence store ────────────────────────────────────────────────── +// Maps stream_id → Map +// Ephemeral by design — resets on cold start, which is acceptable for live counts. + +interface ViewerEntry { + sessionId: string; + userId: string | null; // null = anonymous + username: string | null; + lastSeen: number; // Date.now() + hideFromList: boolean; // user opted out of viewer list +} + +const presenceStore = new Map>(); + +/** Remove stale entries for a given stream and return the live map. */ +function evict(streamId: string): Map { + const viewers = presenceStore.get(streamId) ?? new Map(); + const now = Date.now(); + for (const [sid, entry] of viewers) { + if (now - entry.lastSeen > VIEWER_TTL_MS) viewers.delete(sid); + } + presenceStore.set(streamId, viewers); + return viewers; +} + +// ── GET /api/routes-f/live/viewers?stream_id= ───────────────────────────────── +// Returns viewer count + authenticated viewer list (privacy-respecting). +export async function GET(req: NextRequest) { + const streamId = req.nextUrl.searchParams.get("stream_id"); + if (!streamId) { + return NextResponse.json( + { error: "stream_id is required" }, + { status: 400 } + ); + } + + // Verify the stream exists and is live + try { + const { rows } = await sql` + SELECT id, is_live FROM users WHERE id = ${streamId} LIMIT 1 + `; + if (rows.length === 0) { + return NextResponse.json({ error: "Stream not found" }, { status: 404 }); + } + if (!rows[0].is_live) { + return NextResponse.json( + { error: "Stream is not live" }, + { status: 409 } + ); + } + } catch (err) { + console.error("[viewers:GET] DB error:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + + const viewers = evict(streamId); + const viewerCount = viewers.size; + + // Authenticated viewers who haven't opted out + const authenticatedViewers = Array.from(viewers.values()) + .filter(v => v.userId !== null && !v.hideFromList) + .map(v => ({ userId: v.userId, username: v.username })); + + return NextResponse.json( + { viewerCount, authenticatedViewers }, + { headers: { "Cache-Control": "no-store" } } + ); +} + +// ── POST /api/routes-f/live/viewers/heartbeat ───────────────────────────────── +// Viewer pings to maintain presence. Auth is optional — anonymous viewers are +// tracked by session_id only. +export async function POST(req: NextRequest) { + const ip = + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + "unknown"; + + if (await HEARTBEAT_RATE_LIMIT(ip)) { + return NextResponse.json( + { error: "Too many requests" }, + { status: 429, headers: { "Retry-After": "30" } } + ); + } + + let body: { stream_id?: string; session_id?: string }; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { stream_id, session_id } = body; + if (!stream_id || !session_id) { + return NextResponse.json( + { error: "stream_id and session_id are required" }, + { status: 400 } + ); + } + + // Verify stream is live + try { + const { rows } = await sql` + SELECT id, is_live FROM users WHERE id = ${stream_id} LIMIT 1 + `; + if (rows.length === 0) { + return NextResponse.json({ error: "Stream not found" }, { status: 404 }); + } + if (!rows[0].is_live) { + return NextResponse.json( + { error: "Stream is not live" }, + { status: 409 } + ); + } + } catch (err) { + console.error("[viewers:POST] DB error:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + + // Resolve authenticated viewer (optional — anonymous viewers are fine) + let userId: string | null = null; + let username: string | null = null; + let hideFromList = false; + + const session = await verifySession(req); + if (session.ok) { + userId = session.userId; + username = session.username; + + // Check opt-out preference — best-effort, don't block on failure + try { + const { rows } = await sql` + SELECT p.show_in_viewer_list, u.hide_from_viewer_list + FROM users u + LEFT JOIN user_privacy p ON u.id = p.user_id + WHERE u.id = ${userId} + LIMIT 1 + `; + // User is hidden if show_in_viewer_list is false OR legacy hide_from_viewer_list is true + const showInList = rows[0]?.show_in_viewer_list ?? true; + const legacyHide = rows[0]?.hide_from_viewer_list ?? false; + hideFromList = !showInList || legacyHide; + } catch { + // Column may not exist yet — default to visible + } + } + + const viewers = evict(stream_id); + viewers.set(session_id, { + sessionId: session_id, + userId, + username, + lastSeen: Date.now(), + hideFromList, + }); + presenceStore.set(stream_id, viewers); + + return NextResponse.json( + { ok: true, viewerCount: viewers.size }, + { status: 200 } + ); +} diff --git a/app/api/routes-f/loyalty/tiers/__tests__/route.test.ts b/app/api/routes-f/loyalty/tiers/__tests__/route.test.ts new file mode 100644 index 00000000..00e5daba --- /dev/null +++ b/app/api/routes-f/loyalty/tiers/__tests__/route.test.ts @@ -0,0 +1,118 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { GET, PATCH } from "../route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +const AUTHED_SESSION = { + ok: true as const, + userId: "creator-id", + wallet: null, + privyId: "did:privy:abc", + username: "creator", + email: "creator@example.com", +}; + +function makeRequest(method: string, path: string, body?: object) { + return new Request(`http://localhost${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; +} + +describe("routes-f loyalty/tiers", () => { + beforeEach(() => { + jest.clearAllMocks(); + verifySessionMock.mockResolvedValue(AUTHED_SESSION); + }); + + it("returns default tiers when creator has no saved config", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ + rows: [{ id: "creator-id", username: "creator", tiers: null }], + }); + + const res = await GET( + makeRequest("GET", "/api/routes-f/loyalty/tiers?creator=creator") + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.tiers).toHaveLength(4); + expect(json.tiers[0].name).toBe("Viewer"); + }); + + it("enforces strictly ascending min_points on PATCH", async () => { + const res = await PATCH( + makeRequest("PATCH", "/api/routes-f/loyalty/tiers", { + tiers: [ + { name: "Viewer", min_points: 0, perks: [] }, + { name: "Regular", min_points: 500, perks: ["custom_badge"] }, + { name: "VIP", min_points: 400, perks: ["priority_chat"] }, + ], + }) + ); + const json = await res.json(); + + expect(res.status).toBe(400); + expect(json.error).toBe("Validation failed"); + }); + + it("updates tiers for authenticated creator", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ + rows: [ + { + creator_id: "creator-id", + tiers: [ + { name: "Viewer", min_points: 0, perks: [] }, + { + name: "VIP", + min_points: 1000, + perks: ["custom_badge", "priority_chat"], + }, + ], + updated_at: "2026-03-28T00:00:00Z", + }, + ], + }); + + const res = await PATCH( + makeRequest("PATCH", "/api/routes-f/loyalty/tiers", { + tiers: [ + { name: "Viewer", min_points: 0, perks: [] }, + { + name: "VIP", + min_points: 1000, + perks: ["custom_badge", "priority_chat"], + }, + ], + }) + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.creator_id).toBe("creator-id"); + expect(json.tiers).toHaveLength(2); + }); +}); diff --git a/app/api/routes-f/loyalty/tiers/route.ts b/app/api/routes-f/loyalty/tiers/route.ts new file mode 100644 index 00000000..e8b2972c --- /dev/null +++ b/app/api/routes-f/loyalty/tiers/route.ts @@ -0,0 +1,162 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { usernameSchema } from "@/app/api/routes-f/_lib/schemas"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; + +const LOYALTY_PERKS = [ + "custom_badge", + "priority_chat", + "emote_slots", + "ad_free", +] as const; + +type LoyaltyPerk = (typeof LOYALTY_PERKS)[number]; + +type LoyaltyTier = { + name: string; + min_points: number; + perks: LoyaltyPerk[]; +}; + +const DEFAULT_TIERS: LoyaltyTier[] = [ + { name: "Viewer", min_points: 0, perks: [] }, + { name: "Regular", min_points: 500, perks: ["custom_badge"] }, + { + name: "VIP", + min_points: 2000, + perks: ["custom_badge", "priority_chat"], + }, + { + name: "Legend", + min_points: 10000, + perks: ["custom_badge", "priority_chat", "emote_slots"], + }, +]; + +const tierSchema = z.object({ + name: z.string().trim().min(1).max(60), + min_points: z.number().int().min(0), + perks: z.array(z.enum(LOYALTY_PERKS)).max(4), +}); + +const getTierQuerySchema = z.object({ + creator: usernameSchema, +}); + +const updateTierSchema = z + .object({ + tiers: z.array(tierSchema).min(1).max(4), + }) + .superRefine(({ tiers }, ctx) => { + for (let index = 1; index < tiers.length; index += 1) { + if (tiers[index].min_points <= tiers[index - 1].min_points) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["tiers", index, "min_points"], + message: "min_points must be strictly ascending", + }); + } + } + }); + +async function ensureLoyaltyTierSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS route_f_loyalty_tiers ( + creator_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + tiers JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; +} + +function normalizeTiers(tiers: LoyaltyTier[]): LoyaltyTier[] { + return tiers.map(tier => ({ + name: tier.name.trim(), + min_points: tier.min_points, + perks: [...new Set(tier.perks)], + })); +} + +export async function GET(req: NextRequest): Promise { + const queryResult = validateQuery( + new URL(req.url).searchParams, + getTierQuerySchema + ); + if (queryResult instanceof Response) { + return queryResult; + } + + try { + await ensureLoyaltyTierSchema(); + + const { creator } = queryResult.data; + const { rows } = await sql` + SELECT + u.id, + u.username, + t.tiers + FROM users u + LEFT JOIN route_f_loyalty_tiers t + ON t.creator_id = u.id + WHERE LOWER(u.username) = LOWER(${creator}) + LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ error: "Creator not found" }, { status: 404 }); + } + + const row = rows[0]; + const tiers = Array.isArray(row.tiers) ? row.tiers : DEFAULT_TIERS; + + return NextResponse.json({ + creator: row.username, + tiers, + }); + } catch (error) { + console.error("[routes-f loyalty/tiers GET]", error); + return NextResponse.json( + { error: "Failed to fetch loyalty tiers" }, + { status: 500 } + ); + } +} + +export async function PATCH(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, updateTierSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const tiers = normalizeTiers(bodyResult.data.tiers); + + try { + await ensureLoyaltyTierSchema(); + + const { rows } = await sql` + INSERT INTO route_f_loyalty_tiers (creator_id, tiers, updated_at) + VALUES (${session.userId}, ${JSON.stringify(tiers)}::jsonb, NOW()) + ON CONFLICT (creator_id) + DO UPDATE SET + tiers = EXCLUDED.tiers, + updated_at = NOW() + RETURNING creator_id, tiers, updated_at + `; + + return NextResponse.json(rows[0]); + } catch (error) { + console.error("[routes-f loyalty/tiers PATCH]", error); + return NextResponse.json( + { error: "Failed to update loyalty tiers" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/maintenance/route.ts b/app/api/routes-f/maintenance/route.ts new file mode 100644 index 00000000..70b203e0 --- /dev/null +++ b/app/api/routes-f/maintenance/route.ts @@ -0,0 +1,146 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { z } from "zod"; + +async function ensureMaintenanceTable() { + await sql` + CREATE TABLE IF NOT EXISTS maintenance_windows ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + active BOOLEAN DEFAULT false, + message TEXT NOT NULL, + started_at TIMESTAMPTZ, + estimated_end TIMESTAMPTZ, + affects TEXT[], + created_by UUID REFERENCES users(id), + ended_at TIMESTAMPTZ + ) + `; +} + +async function requireAdmin(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return { ok: false as const, response: session.response }; + } + + const { rows } = await sql` + SELECT role FROM users WHERE id = ${session.userId} LIMIT 1 + `; + if (rows[0]?.role !== "admin") { + return { + ok: false as const, + response: NextResponse.json({ error: "Forbidden" }, { status: 403 }), + }; + } + + return { ok: true as const, session }; +} + +const activateSchema = z.object({ + message: z.string().trim().min(1), + estimated_end: z.coerce.date().optional(), + affects: z + .array(z.enum(["streaming", "payments", "all"])) + .min(1) + .default(["all"]), + block_new_streams: z.boolean().default(false), +}); + +/** Public — returns current maintenance status. */ +export async function GET(): Promise { + await ensureMaintenanceTable(); + + const { rows } = await sql` + SELECT active, message, started_at, estimated_end, affects + FROM maintenance_windows + WHERE active = true + ORDER BY started_at DESC + LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ active: false }); + } + + const row = rows[0]; + return NextResponse.json({ + active: row.active, + message: row.message, + started_at: row.started_at, + estimated_end: row.estimated_end, + affects: row.affects, + }); +} + +/** Admin only — activate maintenance mode. */ +export async function POST(req: NextRequest): Promise { + const auth = await requireAdmin(req); + if (!auth.ok) return auth.response; + + const result = await validateBody(req, activateSchema); + if (result instanceof NextResponse) return result; + const { message, estimated_end, affects, block_new_streams } = result.data; + const impactAreas = [...(affects ?? ["all"])]; + const serializedImpactAreas = JSON.stringify(impactAreas); + + await ensureMaintenanceTable(); + + // Deactivate any existing active window first + await sql` + UPDATE maintenance_windows SET active = false, ended_at = NOW() + WHERE active = true + `; + + const { rows } = await sql` + INSERT INTO maintenance_windows (active, message, started_at, estimated_end, affects, created_by) + VALUES ( + true, + ${message}, + NOW(), + ${estimated_end?.toISOString() ?? null}, + ARRAY( + SELECT jsonb_array_elements_text(${serializedImpactAreas}::jsonb) + ), + ${auth.session.userId} + ) + RETURNING * + `; + + // Store block_new_streams flag in metadata-style column if needed — + // persisted in the affects array as a convention marker + if (block_new_streams && !impactAreas.includes("streaming")) { + await sql` + UPDATE maintenance_windows + SET affects = array_append(affects, 'streaming') + WHERE id = ${rows[0].id} + `; + } + + return NextResponse.json({ window: rows[0] }, { status: 201 }); +} + +/** Admin only — deactivate maintenance mode and purge cache. */ +export async function DELETE(req: NextRequest): Promise { + const auth = await requireAdmin(req); + if (!auth.ok) return auth.response; + + await ensureMaintenanceTable(); + + const { rows } = await sql` + UPDATE maintenance_windows + SET active = false, ended_at = NOW() + WHERE active = true + RETURNING * + `; + + if (rows.length === 0) { + return NextResponse.json( + { error: "No active maintenance window" }, + { status: 404 } + ); + } + + return NextResponse.json({ deactivated: rows[0] }); +} diff --git a/app/api/routes-f/meta/route.ts b/app/api/routes-f/meta/route.ts new file mode 100644 index 00000000..e14a482b --- /dev/null +++ b/app/api/routes-f/meta/route.ts @@ -0,0 +1,141 @@ +import { NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; + +const STATS_CACHE_TTL_MS = 5 * 60 * 1000; +const LIVE_COUNT_TTL_MS = 30 * 1000; + +interface StatsCache { + data: PlatformStats; + fetchedAt: number; +} + +interface PlatformStats { + total_creators: number; + total_streams_all_time: number; + streams_live_now: number; + total_tips_xlm: string; + total_gifts_usdc: string; +} + +let statsCache: StatsCache | null = null; +let liveCountCache: { count: number; fetchedAt: number } | null = null; + +async function fetchStats(): Promise { + const now = Date.now(); + + if (statsCache && now - statsCache.fetchedAt < STATS_CACHE_TTL_MS) { + const liveCount = await fetchLiveCount(); + return { ...statsCache.data, streams_live_now: liveCount }; + } + + try { + const { rows } = await sql` + SELECT + COUNT(DISTINCT id) AS total_creators, + COUNT(DISTINCT id) FILTER (WHERE is_live = true) AS streams_live_now + FROM users + `; + + const creatorRow = rows[0] ?? { + total_creators: 0, + streams_live_now: 0, + }; + + const { rows: tipRows } = await sql` + SELECT + COALESCE(SUM(CASE WHEN currency = 'XLM' THEN amount ELSE 0 END), 0) AS total_tips_xlm, + COALESCE(SUM(CASE WHEN currency = 'USDC' THEN amount ELSE 0 END), 0) AS total_gifts_usdc, + COUNT(*) AS total_streams_all_time + FROM tip_transactions + `; + + const tipRow = tipRows[0] ?? { + total_tips_xlm: "0", + total_gifts_usdc: "0", + total_streams_all_time: 0, + }; + + const stats: PlatformStats = { + total_creators: Number(creatorRow.total_creators), + total_streams_all_time: Number(tipRow.total_streams_all_time), + streams_live_now: Number(creatorRow.streams_live_now), + total_tips_xlm: Number(tipRow.total_tips_xlm).toFixed(2), + total_gifts_usdc: Number(tipRow.total_gifts_usdc).toFixed(2), + }; + + statsCache = { data: stats, fetchedAt: now }; + liveCountCache = { + count: stats.streams_live_now, + fetchedAt: now, + }; + + return stats; + } catch { + if (statsCache) { + return { ...statsCache.data, streams_live_now: 0 }; + } + return { + total_creators: 0, + total_streams_all_time: 0, + streams_live_now: 0, + total_tips_xlm: "0.00", + total_gifts_usdc: "0.00", + }; + } +} + +async function fetchLiveCount(): Promise { + const now = Date.now(); + if (liveCountCache && now - liveCountCache.fetchedAt < LIVE_COUNT_TTL_MS) { + return liveCountCache.count; + } + + try { + const { rows } = await sql` + SELECT COUNT(*) AS live_count FROM users WHERE is_live = true + `; + const count = Number(rows[0]?.live_count ?? 0); + liveCountCache = { count, fetchedAt: now }; + return count; + } catch { + return liveCountCache?.count ?? 0; + } +} + +const APP_VERSION = process.env.npm_package_version ?? "0.1.0"; +const ENVIRONMENT = + process.env.NODE_ENV === "production" ? "production" : "staging"; +const STELLAR_NETWORK = process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? "testnet"; + +/** + * GET /api/routes-f/meta + * Public endpoint returning platform metadata, stats, features, and network info. + */ +export async function GET(): Promise { + const stats = await fetchStats(); + + const body = { + platform: { + name: "StreamFi", + version: APP_VERSION, + environment: ENVIRONMENT, + }, + stats, + features: { + live_streaming: true, + gifts: true, + subscriptions: false, + token_gating: false, + }, + network: { + stellar: STELLAR_NETWORK, + mux: ENVIRONMENT === "production" ? "production" : "development", + }, + }; + + return NextResponse.json(body, { + headers: { + "Cache-Control": "public, max-age=30", + }, + }); +} diff --git a/app/api/routes-f/milestones/[id]/route.ts b/app/api/routes-f/milestones/[id]/route.ts new file mode 100644 index 00000000..4d1d5420 --- /dev/null +++ b/app/api/routes-f/milestones/[id]/route.ts @@ -0,0 +1,136 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { uuidSchema } from "@/app/api/routes-f/_lib/schemas"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { ensureMilestonesSchema } from "../_lib/db"; + +interface RouteParams { + params: Promise<{ id: string }> | { id: string }; +} + +const updateMilestoneSchema = z + .object({ + target: z.number().positive().optional(), + title: z.string().trim().min(1).max(255).optional(), + reward_description: z.string().trim().max(500).nullable().optional(), + }) + .refine(body => Object.keys(body).length > 0, { + message: "At least one field is required", + path: ["body"], + }); + +function validateId(id: string): NextResponse | null { + const result = uuidSchema.safeParse(id); + if (!result.success) { + return NextResponse.json( + { error: "Invalid milestone id" }, + { status: 400 } + ); + } + return null; +} + +export async function PATCH( + req: NextRequest, + context: RouteParams +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { id } = await context.params; + + const idError = validateId(id); + if (idError) { + return idError; + } + + const bodyResult = await validateBody(req, updateMilestoneSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { target, title, reward_description } = bodyResult.data; + + try { + await ensureMilestonesSchema(); + + const { rows } = await sql` + UPDATE route_f_milestones + SET target = COALESCE(${target ?? null}, target), + title = COALESCE(${title ?? null}, title), + reward_description = CASE + WHEN ${reward_description !== undefined} THEN ${reward_description ?? null} + ELSE reward_description + END, + updated_at = now() + WHERE id = ${id} + AND creator_id = ${session.userId} + RETURNING id, creator_id, type, target, title, reward_description, completed_at, created_at, updated_at + `; + + if (rows.length === 0) { + return NextResponse.json( + { error: "Milestone not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ + ...rows[0], + target: Number(rows[0].target), + }); + } catch (error) { + console.error("[milestones/[id]] PATCH error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function DELETE( + req: NextRequest, + context: RouteParams +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { id } = await context.params; + + const idError = validateId(id); + if (idError) { + return idError; + } + + try { + await ensureMilestonesSchema(); + + const { rows } = await sql` + DELETE FROM route_f_milestones + WHERE id = ${id} + AND creator_id = ${session.userId} + RETURNING id + `; + + if (rows.length === 0) { + return NextResponse.json( + { error: "Milestone not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ id: rows[0].id, deleted: true }); + } catch (error) { + console.error("[milestones/[id]] DELETE error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/milestones/__tests__/route.test.ts b/app/api/routes-f/milestones/__tests__/route.test.ts new file mode 100644 index 00000000..cef51e2b --- /dev/null +++ b/app/api/routes-f/milestones/__tests__/route.test.ts @@ -0,0 +1,190 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +jest.mock("../_lib/db", () => ({ + ensureMilestonesSchema: jest.fn().mockResolvedValue(undefined), + MILESTONE_TYPES: ["sub_count", "tip_amount", "viewer_count"], +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { GET, POST } from "../route"; +import { PATCH, DELETE } from "../[id]/route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +const AUTHED_SESSION = { + ok: true as const, + userId: "creator-id", + wallet: null, + privyId: "did:privy:abc", + username: "alice", + email: "alice@example.com", +}; + +const VALID_ID = "550e8400-e29b-41d4-a716-446655440000"; + +function makeRequest(method: string, path: string, body?: object) { + return new Request(`http://localhost${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; +} + +describe("routes-f milestones", () => { + let consoleSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + verifySessionMock.mockResolvedValue(AUTHED_SESSION); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it("lists milestones with live progress and stamps completion", async () => { + sqlMock + .mockResolvedValueOnce({ + rows: [ + { + id: "creator-id", + username: "alice", + total_tips_received: "120", + current_viewers: 42, + follower_count: 15, + }, + ], + }) + .mockResolvedValueOnce({ + rows: [ + { + id: VALID_ID, + creator_id: "creator-id", + type: "tip_amount", + target: "100", + title: "Reach first 100 USDC", + reward_description: null, + completed_at: null, + created_at: "2026-03-27T00:00:00Z", + updated_at: "2026-03-27T00:00:00Z", + }, + ], + }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await GET( + makeRequest("GET", "/api/routes-f/milestones?creator=alice") + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.metrics.tip_amount).toBe(120); + expect(json.completed).toHaveLength(1); + expect(sqlMock).toHaveBeenCalledTimes(3); + }); + + it("rejects creating more than 5 active milestones", async () => { + sqlMock.mockResolvedValueOnce({ rows: [{ active_count: 5 }] }); + + const res = await POST( + makeRequest("POST", "/api/routes-f/milestones", { + type: "sub_count", + target: 20, + title: "20 followers", + }) + ); + + expect(res.status).toBe(409); + }); + + it("creates a milestone", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [{ active_count: 1 }] }) + .mockResolvedValueOnce({ + rows: [ + { + id: VALID_ID, + creator_id: "creator-id", + type: "viewer_count", + target: "100", + title: "100 viewers", + reward_description: "Giveaway", + completed_at: null, + created_at: "2026-03-27T00:00:00Z", + updated_at: "2026-03-27T00:00:00Z", + }, + ], + }); + + const res = await POST( + makeRequest("POST", "/api/routes-f/milestones", { + type: "viewer_count", + target: 100, + title: "100 viewers", + reward_description: "Giveaway", + }) + ); + const json = await res.json(); + + expect(res.status).toBe(201); + expect(json.target).toBe(100); + }); + + it("updates a milestone", async () => { + sqlMock.mockResolvedValueOnce({ + rows: [ + { + id: VALID_ID, + creator_id: "creator-id", + type: "sub_count", + target: "25", + title: "25 supporters", + reward_description: null, + completed_at: null, + created_at: "2026-03-27T00:00:00Z", + updated_at: "2026-03-27T00:00:00Z", + }, + ], + }); + + const res = await PATCH( + makeRequest("PATCH", `/api/routes-f/milestones/${VALID_ID}`, { + target: 25, + title: "25 supporters", + }), + { params: { id: VALID_ID } } + ); + + expect(res.status).toBe(200); + }); + + it("deletes a milestone", async () => { + sqlMock.mockResolvedValueOnce({ rows: [{ id: VALID_ID }] }); + + const res = await DELETE( + makeRequest("DELETE", `/api/routes-f/milestones/${VALID_ID}`), + { params: { id: VALID_ID } } + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.deleted).toBe(true); + }); +}); diff --git a/app/api/routes-f/milestones/_lib/db.ts b/app/api/routes-f/milestones/_lib/db.ts new file mode 100644 index 00000000..20e523fa --- /dev/null +++ b/app/api/routes-f/milestones/_lib/db.ts @@ -0,0 +1,30 @@ +import { sql } from "@vercel/postgres"; + +export const MILESTONE_TYPES = [ + "sub_count", + "tip_amount", + "viewer_count", +] as const; + +export type MilestoneType = (typeof MILESTONE_TYPES)[number]; + +export async function ensureMilestonesSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS route_f_milestones ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL, + target NUMERIC(20, 7) NOT NULL, + title VARCHAR(255) NOT NULL, + reward_description TEXT, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_route_f_milestones_creator_created + ON route_f_milestones (creator_id, created_at DESC) + `; +} diff --git a/app/api/routes-f/milestones/route.ts b/app/api/routes-f/milestones/route.ts new file mode 100644 index 00000000..c005134f --- /dev/null +++ b/app/api/routes-f/milestones/route.ts @@ -0,0 +1,212 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { usernameSchema } from "@/app/api/routes-f/_lib/schemas"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { + MILESTONE_TYPES, + type MilestoneType, + ensureMilestonesSchema, +} from "./_lib/db"; + +const createMilestoneSchema = z.object({ + type: z.enum(MILESTONE_TYPES), + target: z.number().positive(), + title: z.string().trim().min(1).max(255), + reward_description: z.string().trim().max(500).optional(), +}); + +const listMilestonesQuerySchema = z.object({ + creator: usernameSchema, +}); + +type CreatorMetrics = Record; + +type MilestoneRow = { + id: string; + creator_id: string; + type: MilestoneType; + target: string | number; + title: string; + reward_description: string | null; + completed_at: string | null; + created_at: string; + updated_at: string; +}; + +function mapTarget(value: string | number) { + return typeof value === "number" ? value : Number(value); +} + +async function getCreatorMetrics(username: string): Promise<{ + creatorId: string; + creatorUsername: string; + metrics: CreatorMetrics; +} | null> { + const { rows } = await sql` + SELECT + u.id, + u.username, + COALESCE(u.total_tips_received, 0) AS total_tips_received, + COALESCE(u.current_viewers, 0) AS current_viewers, + ( + SELECT COUNT(*)::int + FROM user_follows uf + WHERE uf.followee_id = u.id + ) AS follower_count + FROM users u + WHERE LOWER(u.username) = LOWER(${username}) + LIMIT 1 + `; + + if (rows.length === 0) { + return null; + } + + const row = rows[0]; + + return { + creatorId: row.id, + creatorUsername: row.username, + metrics: { + sub_count: Number(row.follower_count ?? 0), + tip_amount: Number(row.total_tips_received ?? 0), + viewer_count: Number(row.current_viewers ?? 0), + }, + }; +} + +function withProgress(row: MilestoneRow, metrics: CreatorMetrics) { + const progress = metrics[row.type] ?? 0; + const target = mapTarget(row.target); + + return { + ...row, + target, + progress, + is_completed: Boolean(row.completed_at) || progress >= target, + }; +} + +export async function GET(req: NextRequest): Promise { + const { searchParams } = new URL(req.url); + const queryResult = validateQuery(searchParams, listMilestonesQuerySchema); + if (queryResult instanceof Response) { + return queryResult; + } + + try { + await ensureMilestonesSchema(); + + const creator = await getCreatorMetrics(queryResult.data.creator); + if (!creator) { + return NextResponse.json({ error: "Creator not found" }, { status: 404 }); + } + + const { rows } = await sql` + SELECT id, creator_id, type, target, title, reward_description, completed_at, created_at, updated_at + FROM route_f_milestones + WHERE creator_id = ${creator.creatorId} + ORDER BY created_at DESC + `; + + const milestones = rows.map(row => + withProgress(row as MilestoneRow, creator.metrics) + ); + + await Promise.all( + milestones + .filter( + milestone => + !milestone.completed_at && milestone.progress >= milestone.target + ) + .map( + milestone => + sql` + UPDATE route_f_milestones + SET completed_at = now(), updated_at = now() + WHERE id = ${milestone.id} AND completed_at IS NULL + ` + ) + ); + + const normalized = milestones.map(milestone => ({ + ...milestone, + completed_at: + milestone.completed_at ?? + (milestone.progress >= milestone.target + ? new Date().toISOString() + : null), + })); + + return NextResponse.json({ + creator: creator.creatorUsername, + metrics: creator.metrics, + active: normalized.filter(milestone => !milestone.completed_at), + completed: normalized.filter(milestone => + Boolean(milestone.completed_at) + ), + }); + } catch (error) { + console.error("[milestones] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, createMilestoneSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { type, target, title, reward_description } = bodyResult.data; + + try { + await ensureMilestonesSchema(); + + const { rows: countRows } = await sql` + SELECT COUNT(*)::int AS active_count + FROM route_f_milestones + WHERE creator_id = ${session.userId} + AND completed_at IS NULL + `; + + if (Number(countRows[0]?.active_count ?? 0) >= 5) { + return NextResponse.json( + { error: "Creators may only have 5 active milestones" }, + { status: 409 } + ); + } + + const { rows } = await sql` + INSERT INTO route_f_milestones (creator_id, type, target, title, reward_description) + VALUES (${session.userId}, ${type}, ${target}, ${title}, ${reward_description ?? null}) + RETURNING id, creator_id, type, target, title, reward_description, completed_at, created_at, updated_at + `; + + return NextResponse.json( + { + ...rows[0], + target: Number(rows[0].target), + progress: 0, + is_completed: false, + }, + { status: 201 } + ); + } catch (error) { + console.error("[milestones] POST error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/monetization/route.ts b/app/api/routes-f/monetization/route.ts new file mode 100644 index 00000000..b2127f75 --- /dev/null +++ b/app/api/routes-f/monetization/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +const patchMonetizationSchema = z.object({ + tipping_enabled: z.boolean().optional(), + min_tip_amount: z.number().min(0).optional(), + subscription_price: z.number().min(0).optional(), + gift_cooldown_seconds: z.number().int().min(0).max(86400).optional(), +}); + +async function ensureMonetizationSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS route_f_monetization ( + creator_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + tipping_enabled BOOLEAN NOT NULL DEFAULT TRUE, + min_tip_amount NUMERIC(10,2) NOT NULL DEFAULT 1.00, + subscription_price NUMERIC(10,2) NOT NULL DEFAULT 5.00, + gift_cooldown_seconds INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; +} + +const DEFAULT_SETTINGS = { + tipping_enabled: true, + min_tip_amount: 1.0, + subscription_price: 5.0, + gift_cooldown_seconds: 0, +}; + +/** + * GET /api/routes-f/monetization + * Returns current monetization settings for the authenticated creator. + */ +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + try { + await ensureMonetizationSchema(); + + const { rows } = await sql` + SELECT tipping_enabled, min_tip_amount, subscription_price, gift_cooldown_seconds, updated_at + FROM route_f_monetization + WHERE creator_id = ${session.userId} + LIMIT 1 + `; + + const settings = rows.length > 0 ? rows[0] : DEFAULT_SETTINGS; + return NextResponse.json(settings); + } catch (error) { + console.error("[routes-f monetization GET]", error); + return NextResponse.json({ error: "Failed to fetch monetization settings" }, { status: 500 }); + } +} + +/** + * PATCH /api/routes-f/monetization + * Update monetization settings for the authenticated creator. + */ +export async function PATCH(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const bodyResult = await validateBody(req, patchMonetizationSchema); + if (bodyResult instanceof Response) return bodyResult; + + const { tipping_enabled, min_tip_amount, subscription_price, gift_cooldown_seconds } = bodyResult.data; + + if (Object.keys(bodyResult.data).length === 0) { + return NextResponse.json({ error: "No fields provided to update" }, { status: 400 }); + } + + try { + await ensureMonetizationSchema(); + + const { rows } = await sql` + INSERT INTO route_f_monetization ( + creator_id, tipping_enabled, min_tip_amount, subscription_price, gift_cooldown_seconds, updated_at + ) + VALUES ( + ${session.userId}, + ${tipping_enabled ?? DEFAULT_SETTINGS.tipping_enabled}, + ${min_tip_amount ?? DEFAULT_SETTINGS.min_tip_amount}, + ${subscription_price ?? DEFAULT_SETTINGS.subscription_price}, + ${gift_cooldown_seconds ?? DEFAULT_SETTINGS.gift_cooldown_seconds}, + NOW() + ) + ON CONFLICT (creator_id) DO UPDATE SET + tipping_enabled = COALESCE(${tipping_enabled ?? null}, route_f_monetization.tipping_enabled), + min_tip_amount = COALESCE(${min_tip_amount ?? null}, route_f_monetization.min_tip_amount), + subscription_price = COALESCE(${subscription_price ?? null}, route_f_monetization.subscription_price), + gift_cooldown_seconds = COALESCE(${gift_cooldown_seconds ?? null}, route_f_monetization.gift_cooldown_seconds), + updated_at = NOW() + RETURNING tipping_enabled, min_tip_amount, subscription_price, gift_cooldown_seconds, updated_at + `; + + return NextResponse.json(rows[0]); + } catch (error) { + console.error("[routes-f monetization PATCH]", error); + return NextResponse.json({ error: "Failed to update monetization settings" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/notifications/settings/__tests__/route.test.ts b/app/api/routes-f/notifications/settings/__tests__/route.test.ts new file mode 100644 index 00000000..e2379c0e --- /dev/null +++ b/app/api/routes-f/notifications/settings/__tests__/route.test.ts @@ -0,0 +1,115 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { GET, PATCH } from "../route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +const authedSession = { + ok: true as const, + userId: "user-123", + wallet: null, + privyId: "did:privy:abc", + username: "creator", + email: "creator@example.com", +}; + +const makeRequest = (method: string, body?: object) => + new Request("http://localhost/api/routes-f/notifications/settings", { + method, + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; + +describe("routes-f notification settings", () => { + beforeEach(() => { + jest.clearAllMocks(); + verifySessionMock.mockResolvedValue(authedSession); + }); + + it("returns default preferences when no settings row exists", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await GET(makeRequest("GET")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.preferences.email.new_follower).toBe(true); + expect(body.preferences.push.stream_live).toBe(true); + expect(body.preferences.in_app.tip_received).toBe(true); + }); + + it("applies a partial patch and preserves unspecified values", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ preferences: { + email: { new_follower: true, stream_live: true, tip_received: true }, + push: { new_follower: true, stream_live: true, tip_received: true }, + in_app: { new_follower: true, stream_live: false, tip_received: true }, + } }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await PATCH( + makeRequest("PATCH", { + email: { tip_received: false }, + in_app: { new_follower: false }, + }) + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.preferences.email.tip_received).toBe(false); + expect(body.preferences.email.new_follower).toBe(true); + expect(body.preferences.in_app.new_follower).toBe(false); + expect(body.preferences.push.tip_received).toBe(true); + }); + + it("rejects unknown top-level keys", async () => { + sqlMock.mockResolvedValueOnce({ rows: [] }); + + const res = await PATCH( + makeRequest("PATCH", { + sms: { new_follower: true }, + }) + ); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toMatchObject({ + error: expect.stringMatching(/unknown notification channel/i), + }); + }); + + it("rejects unknown nested keys", async () => { + sqlMock.mockResolvedValueOnce({ rows: [] }); + + const res = await PATCH( + makeRequest("PATCH", { + email: { marketing: true }, + }) + ); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toMatchObject({ + error: expect.stringMatching(/unknown preference key/i), + }); + }); +}); diff --git a/app/api/routes-f/notifications/settings/route.ts b/app/api/routes-f/notifications/settings/route.ts new file mode 100644 index 00000000..d2beadec --- /dev/null +++ b/app/api/routes-f/notifications/settings/route.ts @@ -0,0 +1,222 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +type NotificationPreferenceType = + | "new_follower" + | "stream_live" + | "tip_received"; + +type NotificationChannel = "email" | "push" | "in_app"; + +type ChannelPreferences = Record; + +type NotificationSettings = Record; + +const CHANNELS: NotificationChannel[] = ["email", "push", "in_app"]; +const TYPES: NotificationPreferenceType[] = [ + "new_follower", + "stream_live", + "tip_received", +]; + +const defaultSettings: NotificationSettings = { + email: { + new_follower: true, + stream_live: true, + tip_received: true, + }, + push: { + new_follower: true, + stream_live: true, + tip_received: true, + }, + in_app: { + new_follower: true, + stream_live: true, + tip_received: true, + }, +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function cloneDefaults(): NotificationSettings { + return { + email: { ...defaultSettings.email }, + push: { ...defaultSettings.push }, + in_app: { ...defaultSettings.in_app }, + }; +} + +function normaliseStoredSettings(input: unknown): NotificationSettings { + const base = cloneDefaults(); + if (!isRecord(input)) { + return base; + } + + for (const channel of CHANNELS) { + const current = input[channel]; + if (!isRecord(current)) { + continue; + } + + for (const type of TYPES) { + if (typeof current[type] === "boolean") { + base[channel][type] = current[type] as boolean; + } + } + } + + return base; +} + +function validatePatch(payload: unknown): Record< + NotificationChannel, + Partial +> { + if (!isRecord(payload)) { + throw new Error("Invalid request body"); + } + + const patch: Record> = { + email: {}, + push: {}, + in_app: {}, + }; + + for (const key of Object.keys(payload)) { + if (!CHANNELS.includes(key as NotificationChannel)) { + throw new Error(`Unknown notification channel: ${key}`); + } + + const channel = key as NotificationChannel; + const value = payload[channel]; + if (!isRecord(value)) { + throw new Error(`Channel ${channel} must be an object`); + } + + for (const innerKey of Object.keys(value)) { + if (!TYPES.includes(innerKey as NotificationPreferenceType)) { + throw new Error(`Unknown preference key: ${innerKey}`); + } + + const prefKey = innerKey as NotificationPreferenceType; + if (typeof value[prefKey] !== "boolean") { + throw new Error(`Preference ${channel}.${prefKey} must be a boolean`); + } + + patch[channel][prefKey] = value[prefKey] as boolean; + } + } + + return patch; +} + +function applyPatch( + current: NotificationSettings, + patch: Record> +): NotificationSettings { + const next = cloneDefaults(); + + for (const channel of CHANNELS) { + next[channel] = { + ...current[channel], + ...patch[channel], + }; + } + + return next; +} + +async function ensureSettingsTable() { + await sql` + CREATE TABLE IF NOT EXISTS routes_f_notification_settings ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + preferences JSONB NOT NULL DEFAULT '{ + "email": {"new_follower": true, "stream_live": true, "tip_received": true}, + "push": {"new_follower": true, "stream_live": true, "tip_received": true}, + "in_app": {"new_follower": true, "stream_live": true, "tip_received": true} + }'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; +} + +async function getUserSettings(userId: string): Promise { + const result = await sql<{ preferences: unknown }>` + SELECT preferences + FROM routes_f_notification_settings + WHERE user_id = ${userId} + LIMIT 1 + `; + + if (result.rows.length === 0) { + await sql` + INSERT INTO routes_f_notification_settings (user_id, preferences) + VALUES (${userId}, ${JSON.stringify(defaultSettings)}::jsonb) + ON CONFLICT (user_id) DO NOTHING + `; + + return cloneDefaults(); + } + + return normaliseStoredSettings(result.rows[0].preferences); +} + +export async function GET(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + await ensureSettingsTable(); + const preferences = await getUserSettings(session.userId); + return NextResponse.json({ preferences }); + } catch (error) { + console.error("[routes-f/notifications/settings] GET error:", error); + return NextResponse.json( + { error: "Failed to fetch notification settings" }, + { status: 500 } + ); + } +} + +export async function PATCH(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + await ensureSettingsTable(); + + const payload = await req.json(); + const patch = validatePatch(payload); + const current = await getUserSettings(session.userId); + const preferences = applyPatch(current, patch); + + await sql` + INSERT INTO routes_f_notification_settings (user_id, preferences, updated_at) + VALUES (${session.userId}, ${JSON.stringify(preferences)}::jsonb, NOW()) + ON CONFLICT (user_id) DO UPDATE SET + preferences = EXCLUDED.preferences, + updated_at = NOW() + `; + + return NextResponse.json({ preferences }); + } catch (error) { + if (error instanceof Error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + console.error("[routes-f/notifications/settings] PATCH error:", error); + return NextResponse.json( + { error: "Failed to update notification settings" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/pinned/[id]/route.ts b/app/api/routes-f/pinned/[id]/route.ts new file mode 100644 index 00000000..b608eb59 --- /dev/null +++ b/app/api/routes-f/pinned/[id]/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +async function ensurePinnedTable() { + await sql` + CREATE TABLE IF NOT EXISTS creator_pins ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + item_id UUID NOT NULL, + item_type VARCHAR(20) NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT creator_pins_item_type_check + CHECK (item_type IN ('clip', 'recording')), + CONSTRAINT creator_pins_unique_item + UNIQUE (creator_id, item_type, item_id) + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_creator_pins_creator_position + ON creator_pins (creator_id, position, created_at) + `; +} + +async function getCreatorPinIds(creatorId: string) { + const result = await sql<{ id: string }>` + SELECT id + FROM creator_pins + WHERE creator_id = ${creatorId} + ORDER BY position ASC, created_at ASC + `; + + return result.rows.map(row => row.id); +} + +async function normalisePositions(creatorId: string, orderedIds: string[]) { + for (let index = 0; index < orderedIds.length; index += 1) { + await sql` + UPDATE creator_pins + SET position = ${index}, updated_at = NOW() + WHERE id = ${orderedIds[index]} AND creator_id = ${creatorId} + `; + } +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + await ensurePinnedTable(); + + const { id } = await params; + if (!UUID_RE.test(id)) { + return NextResponse.json({ error: "Invalid pin ID" }, { status: 400 }); + } + + const pinResult = await sql<{ id: string }>` + DELETE FROM creator_pins + WHERE id = ${id} AND creator_id = ${session.userId} + RETURNING id + `; + + if (pinResult.rows.length === 0) { + return NextResponse.json({ error: "Pin not found" }, { status: 404 }); + } + + const remainingIds = await getCreatorPinIds(session.userId); + await normalisePositions(session.userId, remainingIds); + + return NextResponse.json({ ok: true }); + } catch (error) { + console.error("[routes-f/pinned/[id]] DELETE error:", error); + return NextResponse.json( + { error: "Failed to unpin content" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/pinned/__tests__/route.test.ts b/app/api/routes-f/pinned/__tests__/route.test.ts new file mode 100644 index 00000000..7f132ccd --- /dev/null +++ b/app/api/routes-f/pinned/__tests__/route.test.ts @@ -0,0 +1,89 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { POST } from "../route"; +import { PATCH } from "../reorder/route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +const authedSession = { + ok: true as const, + userId: "user-123", + wallet: null, + privyId: "did:privy:abc", + username: "creator", + email: "creator@example.com", +}; + +const makeRequest = (method: string, path: string, body?: object) => + new Request(`http://localhost${path}`, { + method, + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; + +describe("routes-f pinned", () => { + beforeEach(() => { + jest.clearAllMocks(); + verifySessionMock.mockResolvedValue(authedSession); + }); + + it("enforces max 6 pinned items", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ id: "recording-1" }] }) + .mockResolvedValueOnce({ + rows: Array.from({ length: 6 }, (_, index) => ({ id: `pin-${index}` })), + }); + + const res = await POST( + makeRequest("POST", "/api/routes-f/pinned", { + item_id: "11111111-1111-1111-1111-111111111111", + item_type: "recording", + position: 0, + }) + ); + + expect(res.status).toBe(409); + await expect(res.json()).resolves.toMatchObject({ + error: expect.stringMatching(/at most 6/i), + }); + }); + + it("validates reorder IDs belong to creator", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ + rows: [{ id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }], + }); + + const res = await PATCH( + makeRequest("PATCH", "/api/routes-f/pinned/reorder", { + ordered_ids: ["bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"], + }) + ); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toMatchObject({ + error: expect.stringMatching(/authenticated creator/i), + }); + }); +}); diff --git a/app/api/routes-f/pinned/reorder/route.ts b/app/api/routes-f/pinned/reorder/route.ts new file mode 100644 index 00000000..5c15af8a --- /dev/null +++ b/app/api/routes-f/pinned/reorder/route.ts @@ -0,0 +1,106 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +async function ensurePinnedTable() { + await sql` + CREATE TABLE IF NOT EXISTS creator_pins ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + item_id UUID NOT NULL, + item_type VARCHAR(20) NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT creator_pins_item_type_check + CHECK (item_type IN ('clip', 'recording')), + CONSTRAINT creator_pins_unique_item + UNIQUE (creator_id, item_type, item_id) + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_creator_pins_creator_position + ON creator_pins (creator_id, position, created_at) + `; +} + +export async function PATCH(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + await ensurePinnedTable(); + + const payload = await req.json(); + const orderedIds = payload?.ordered_ids; + + if (!Array.isArray(orderedIds) || orderedIds.length === 0) { + return NextResponse.json( + { error: "ordered_ids must be a non-empty array" }, + { status: 400 } + ); + } + + if ( + !orderedIds.every(id => typeof id === "string" && UUID_RE.test(id)) + ) { + return NextResponse.json( + { error: "ordered_ids must contain valid UUIDs" }, + { status: 400 } + ); + } + + const currentResult = await sql<{ id: string }>` + SELECT id + FROM creator_pins + WHERE creator_id = ${session.userId} + ORDER BY position ASC, created_at ASC + `; + + const currentIds = currentResult.rows.map(row => row.id); + const incomingIds = orderedIds as string[]; + + if (currentIds.length !== incomingIds.length) { + return NextResponse.json( + { error: "ordered_ids must include every pin for the creator" }, + { status: 400 } + ); + } + + const currentSet = new Set(currentIds); + if ( + incomingIds.some(id => !currentSet.has(id)) || + new Set(incomingIds).size !== incomingIds.length + ) { + return NextResponse.json( + { error: "ordered_ids must belong to the authenticated creator" }, + { status: 400 } + ); + } + + for (let index = 0; index < incomingIds.length; index += 1) { + await sql` + UPDATE creator_pins + SET position = ${index}, updated_at = NOW() + WHERE id = ${incomingIds[index]} AND creator_id = ${session.userId} + `; + } + + return NextResponse.json({ + ok: true, + ordered_ids: incomingIds, + }); + } catch (error) { + console.error("[routes-f/pinned/reorder] PATCH error:", error); + return NextResponse.json( + { error: "Failed to reorder pinned content" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/pinned/route.ts b/app/api/routes-f/pinned/route.ts new file mode 100644 index 00000000..5ca0a5bc --- /dev/null +++ b/app/api/routes-f/pinned/route.ts @@ -0,0 +1,245 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +type PinItemType = "clip" | "recording"; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function clampPosition(position: number, maxIndex: number) { + if (!Number.isFinite(position)) { + return maxIndex; + } + return Math.max(0, Math.min(Math.trunc(position), maxIndex)); +} + +async function ensurePinnedTable() { + await sql` + CREATE TABLE IF NOT EXISTS creator_pins ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + item_id UUID NOT NULL, + item_type VARCHAR(20) NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT creator_pins_item_type_check + CHECK (item_type IN ('clip', 'recording')), + CONSTRAINT creator_pins_unique_item + UNIQUE (creator_id, item_type, item_id) + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_creator_pins_creator_position + ON creator_pins (creator_id, position, created_at) + `; +} + +async function normalisePositions(creatorId: string, orderedIds: string[]) { + for (let index = 0; index < orderedIds.length; index += 1) { + await sql` + UPDATE creator_pins + SET position = ${index}, updated_at = NOW() + WHERE id = ${orderedIds[index]} AND creator_id = ${creatorId} + `; + } +} + +async function ensureOwnedRecording(recordingId: string, creatorId: string) { + const result = await sql` + SELECT id + FROM stream_recordings + WHERE id = ${recordingId} AND user_id = ${creatorId} + LIMIT 1 + `; + + return result.rows.length > 0; +} + +export async function GET(req: NextRequest) { + try { + await ensurePinnedTable(); + + const creator = new URL(req.url).searchParams.get("creator")?.trim(); + if (!creator) { + return NextResponse.json( + { error: "creator is required" }, + { status: 400 } + ); + } + + const creatorResult = await sql<{ id: string; username: string }>` + SELECT id, username + FROM users + WHERE id::text = ${creator} OR LOWER(username) = LOWER(${creator}) + LIMIT 1 + `; + + const creatorRow = creatorResult.rows[0]; + if (!creatorRow) { + return NextResponse.json({ error: "Creator not found" }, { status: 404 }); + } + + const result = await sql<{ + pin_id: string; + item_id: string; + item_type: PinItemType; + position: number; + created_at: string | Date; + playback_id: string; + title: string | null; + duration: number | null; + recording_created_at: string | Date; + status: string; + needs_review: boolean | null; + }>` + SELECT + p.id AS pin_id, + p.item_id::text AS item_id, + p.item_type, + p.position, + p.created_at, + r.playback_id, + r.title, + r.duration, + r.created_at AS recording_created_at, + r.status, + r.needs_review + FROM creator_pins p + JOIN stream_recordings r ON r.id = p.item_id + WHERE p.creator_id = ${creatorRow.id} + ORDER BY p.position ASC, p.created_at ASC + `; + + return NextResponse.json({ + creator: creatorRow.username, + items: result.rows.map(row => ({ + id: row.pin_id, + item_id: row.item_id, + item_type: row.item_type, + position: row.position, + created_at: + row.created_at instanceof Date + ? row.created_at.toISOString() + : row.created_at, + item: { + id: row.item_id, + playback_id: row.playback_id, + title: row.title, + duration: row.duration, + created_at: + row.recording_created_at instanceof Date + ? row.recording_created_at.toISOString() + : row.recording_created_at, + status: row.status, + needs_review: row.needs_review ?? false, + thumbnail_url: `https://image.mux.com/${row.playback_id}/thumbnail.jpg`, + }, + })), + }); + } catch (error) { + console.error("[routes-f/pinned] GET error:", error); + return NextResponse.json( + { error: "Failed to fetch pinned content" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + await ensurePinnedTable(); + + const payload = await req.json(); + const itemId = payload?.item_id; + const itemType = payload?.item_type; + const requestedPosition = Number(payload?.position ?? Number.MAX_SAFE_INTEGER); + + if (typeof itemId !== "string" || !UUID_RE.test(itemId)) { + return NextResponse.json( + { error: "item_id must be a valid UUID" }, + { status: 400 } + ); + } + + if (itemType !== "clip" && itemType !== "recording") { + return NextResponse.json( + { error: "item_type must be clip or recording" }, + { status: 400 } + ); + } + + const ownsItem = await ensureOwnedRecording(itemId, session.userId); + if (!ownsItem) { + return NextResponse.json( + { error: "Item not found for creator" }, + { status: 404 } + ); + } + + const existingPins = await sql<{ id: string }>` + SELECT id + FROM creator_pins + WHERE creator_id = ${session.userId} + ORDER BY position ASC, created_at ASC + `; + + if (existingPins.rows.length >= 6) { + return NextResponse.json( + { error: "Creators can pin at most 6 items" }, + { status: 409 } + ); + } + + const alreadyPinned = await sql` + SELECT id + FROM creator_pins + WHERE creator_id = ${session.userId} + AND item_id = ${itemId} + AND item_type = ${itemType} + LIMIT 1 + `; + + if (alreadyPinned.rows.length > 0) { + return NextResponse.json( + { error: "Item is already pinned" }, + { status: 409 } + ); + } + + const insertResult = await sql<{ id: string }>` + INSERT INTO creator_pins (creator_id, item_id, item_type, position) + VALUES (${session.userId}, ${itemId}, ${itemType}, ${existingPins.rows.length}) + RETURNING id + `; + + const orderedIds = existingPins.rows.map(row => row.id); + const newPinId = insertResult.rows[0].id; + const insertAt = clampPosition(requestedPosition, orderedIds.length); + orderedIds.splice(insertAt, 0, newPinId); + await normalisePositions(session.userId, orderedIds); + + return NextResponse.json( + { + id: newPinId, + item_id: itemId, + item_type: itemType, + position: insertAt, + }, + { status: 201 } + ); + } catch (error) { + console.error("[routes-f/pinned] POST error:", error); + return NextResponse.json( + { error: "Failed to pin content" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/polls/[id]/route.ts b/app/api/routes-f/polls/[id]/route.ts new file mode 100644 index 00000000..bf6d76c3 --- /dev/null +++ b/app/api/routes-f/polls/[id]/route.ts @@ -0,0 +1,130 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { closeExpiredPolls, ensurePollSchema } from "../_lib/db"; + +const updatePollSchema = z.object({ + question: z.string().trim().min(1).max(200).optional(), + options: z.array(z.string().trim().min(1).max(80)).min(2).max(6).optional(), + duration_seconds: z.number().int().min(15).max(600).optional(), + status: z.enum(["active", "closed"]).optional(), +}); + +async function getOwnedPoll(id: string, userId: string) { + const { rows } = await sql<{ + id: string; + streamer_id: string; + options: Array<{ id: number; text: string }>; + status: string; + }>` + SELECT id, streamer_id, options, status + FROM stream_polls + WHERE id = ${id} + LIMIT 1 + `; + + if (rows.length === 0) { + return { + error: NextResponse.json({ error: "Poll not found" }, { status: 404 }), + }; + } + + if (rows[0].streamer_id !== userId) { + return { + error: NextResponse.json({ error: "Forbidden" }, { status: 403 }), + }; + } + + return { poll: rows[0] }; +} + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, updatePollSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + try { + await ensurePollSchema(); + await closeExpiredPolls(); + + const { id } = await params; + const owned = await getOwnedPoll(id, session.userId); + if (owned.error) { + return owned.error; + } + + const existing = owned.poll!; + const nextOptions = bodyResult.data.options + ? bodyResult.data.options.map((text, index) => ({ id: index + 1, text })) + : existing.options; + const nextStatus = bodyResult.data.status ?? existing.status; + const closesAt = bodyResult.data.duration_seconds + ? new Date( + Date.now() + bodyResult.data.duration_seconds * 1000 + ).toISOString() + : null; + + await sql` + UPDATE stream_polls + SET + question = COALESCE(${bodyResult.data.question ?? null}, question), + options = ${JSON.stringify(nextOptions)}::jsonb, + duration_seconds = COALESCE(${bodyResult.data.duration_seconds ?? null}, duration_seconds), + status = ${nextStatus}, + closes_at = COALESCE(${closesAt}, closes_at) + WHERE id = ${id} + `; + + return NextResponse.json({ id, status: nextStatus }); + } catch (error) { + console.error("[routes-f polls/:id PATCH]", error); + return NextResponse.json( + { error: "Failed to update poll" }, + { status: 500 } + ); + } +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + await ensurePollSchema(); + + const { id } = await params; + const owned = await getOwnedPoll(id, session.userId); + if (owned.error) { + return owned.error; + } + + await sql` + DELETE FROM stream_polls + WHERE id = ${id} + `; + + return NextResponse.json({ deleted: true }); + } catch (error) { + console.error("[routes-f polls/:id DELETE]", error); + return NextResponse.json( + { error: "Failed to delete poll" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/polls/[id]/vote/route.ts b/app/api/routes-f/polls/[id]/vote/route.ts new file mode 100644 index 00000000..75c58752 --- /dev/null +++ b/app/api/routes-f/polls/[id]/vote/route.ts @@ -0,0 +1,129 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { closeExpiredPolls, ensurePollSchema } from "../../_lib/db"; + +const voteSchema = z.object({ + option_id: z.number().int().min(1), +}); + +async function buildVoteResponse(pollId: string, viewerId: string) { + const { rows: pollRows } = await sql<{ + id: string; + question: string; + options: Array<{ id: number; text: string }>; + status: string; + closes_at: string; + }>` + SELECT id, question, options, status, closes_at + FROM stream_polls + WHERE id = ${pollId} + LIMIT 1 + `; + + if (pollRows.length === 0) { + return null; + } + + const { rows: voteRows } = await sql<{ option_id: number; votes: number }>` + SELECT option_id, COUNT(*)::int AS votes + FROM poll_votes + WHERE poll_id = ${pollId} + GROUP BY option_id + `; + + const { rows: viewerRows } = await sql<{ option_id: number }>` + SELECT option_id + FROM poll_votes + WHERE poll_id = ${pollId} + AND voter_id = ${viewerId} + LIMIT 1 + `; + + const totalVotes = voteRows.reduce((sum, row) => sum + Number(row.votes), 0); + const voteMap = new Map( + voteRows.map(row => [Number(row.option_id), Number(row.votes)]) + ); + + return { + id: pollRows[0].id, + question: pollRows[0].question, + options: (pollRows[0].options ?? []).map(option => { + const votes = voteMap.get(option.id) ?? 0; + return { + ...option, + votes, + percentage: totalVotes > 0 ? Math.round((votes / totalVotes) * 100) : 0, + }; + }), + status: pollRows[0].status, + closes_at: pollRows[0].closes_at, + viewer_voted: viewerRows[0]?.option_id ?? null, + }; +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, voteSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + try { + await ensurePollSchema(); + await closeExpiredPolls(); + + const { id } = await params; + const { rows: pollRows } = await sql<{ + id: string; + options: Array<{ id: number; text: string }>; + status: string; + closes_at: string; + }>` + SELECT id, options, status, closes_at + FROM stream_polls + WHERE id = ${id} + LIMIT 1 + `; + + if (pollRows.length === 0) { + return NextResponse.json({ error: "Poll not found" }, { status: 404 }); + } + + const poll = pollRows[0]; + if (poll.status !== "active" || new Date(poll.closes_at) <= new Date()) { + return NextResponse.json({ error: "Poll has closed" }, { status: 410 }); + } + + const validOptionIds = new Set( + (poll.options ?? []).map(option => option.id) + ); + if (!validOptionIds.has(bodyResult.data.option_id)) { + return NextResponse.json({ error: "Invalid option_id" }, { status: 400 }); + } + + await sql` + INSERT INTO poll_votes (poll_id, voter_id, option_id) + VALUES (${id}, ${session.userId}, ${bodyResult.data.option_id}) + ON CONFLICT (poll_id, voter_id) + DO UPDATE SET + option_id = EXCLUDED.option_id, + voted_at = NOW() + `; + + const payload = await buildVoteResponse(id, session.userId); + return NextResponse.json(payload); + } catch (error) { + console.error("[routes-f polls/:id/vote POST]", error); + return NextResponse.json({ error: "Failed to cast vote" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/polls/__tests__/route.test.ts b/app/api/routes-f/polls/__tests__/route.test.ts new file mode 100644 index 00000000..135e9534 --- /dev/null +++ b/app/api/routes-f/polls/__tests__/route.test.ts @@ -0,0 +1,130 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); +jest.mock("@/app/api/routes-f/_lib/session", () => ({ + getOptionalSession: jest.fn(), +})); +jest.mock("../_lib/db", () => ({ + ensurePollSchema: jest.fn().mockResolvedValue(undefined), + closeExpiredPolls: jest.fn().mockResolvedValue(undefined), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { getOptionalSession } from "@/app/api/routes-f/_lib/session"; +import { GET, POST } from "../route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; +const getOptionalSessionMock = getOptionalSession as jest.Mock; + +function makeRequest(method: string, path: string, body?: object) { + return new Request(`http://localhost${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; +} + +describe("routes-f polls", () => { + beforeEach(() => { + jest.clearAllMocks(); + verifySessionMock.mockResolvedValue({ + ok: true, + userId: "streamer-1", + wallet: null, + privyId: "did:privy:streamer", + username: "streamer", + email: "streamer@example.com", + }); + getOptionalSessionMock.mockResolvedValue(null); + }); + + it("returns the active poll for a stream", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [{ id: "poll-1" }] }) + .mockResolvedValueOnce({ + rows: [ + { + id: "poll-1", + question: "Which game next?", + options: [ + { id: 1, text: "Valorant" }, + { id: 2, text: "Minecraft" }, + ], + status: "active", + closes_at: "2026-03-29T12:00:00Z", + viewer_voted: null, + }, + ], + }) + .mockResolvedValueOnce({ + rows: [ + { option_id: 1, votes: 4 }, + { option_id: 2, votes: 2 }, + ], + }); + + const res = await GET( + makeRequest( + "GET", + "/api/routes-f/polls?stream_id=550e8400-e29b-41d4-a716-446655440000" + ) + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.options[0].votes).toBe(4); + expect(json.options[0].percentage).toBe(67); + }); + + it("creates a poll for the stream owner", async () => { + sqlMock + .mockResolvedValueOnce({ + rows: [{ user_id: "streamer-1" }], + }) + .mockResolvedValueOnce({ + rows: [{ id: "poll-1" }], + }) + .mockResolvedValueOnce({ + rows: [ + { + id: "poll-1", + question: "Which game next?", + options: [ + { id: 1, text: "Valorant" }, + { id: 2, text: "Minecraft" }, + ], + status: "active", + closes_at: "2026-03-29T12:00:00Z", + viewer_voted: null, + }, + ], + }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await POST( + makeRequest("POST", "/api/routes-f/polls", { + stream_id: "550e8400-e29b-41d4-a716-446655440000", + question: "Which game next?", + options: ["Valorant", "Minecraft"], + duration_seconds: 60, + }) + ); + const json = await res.json(); + + expect(res.status).toBe(201); + expect(json.question).toBe("Which game next?"); + }); +}); diff --git a/app/api/routes-f/polls/_lib/db.ts b/app/api/routes-f/polls/_lib/db.ts new file mode 100644 index 00000000..3688b980 --- /dev/null +++ b/app/api/routes-f/polls/_lib/db.ts @@ -0,0 +1,62 @@ +import { sql } from "@vercel/postgres"; + +export async function ensurePollSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS stream_polls ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stream_id UUID NOT NULL REFERENCES stream_sessions(id) ON DELETE CASCADE, + streamer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + question TEXT NOT NULL, + options JSONB NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + duration_seconds INTEGER NOT NULL DEFAULT 60, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + closes_at TIMESTAMPTZ NOT NULL + ) + `; + + await sql` + ALTER TABLE stream_polls + ADD COLUMN IF NOT EXISTS streamer_id UUID REFERENCES users(id) ON DELETE CASCADE + `; + + await sql` + ALTER TABLE stream_polls + ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active' + `; + + await sql` + ALTER TABLE stream_polls + ADD COLUMN IF NOT EXISTS closes_at TIMESTAMPTZ + `; + + await sql` + CREATE TABLE IF NOT EXISTS poll_votes ( + poll_id UUID NOT NULL REFERENCES stream_polls(id) ON DELETE CASCADE, + voter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + option_id INTEGER NOT NULL, + voted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (poll_id, voter_id) + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_stream_polls_stream_created + ON stream_polls (stream_id, created_at DESC) + `; + + await sql` + CREATE UNIQUE INDEX IF NOT EXISTS idx_stream_polls_one_active + ON stream_polls (stream_id) + WHERE status = 'active' + `; +} + +export async function closeExpiredPolls(): Promise { + await sql` + UPDATE stream_polls + SET status = 'closed' + WHERE status = 'active' + AND closes_at <= NOW() + `; +} diff --git a/app/api/routes-f/polls/route.ts b/app/api/routes-f/polls/route.ts new file mode 100644 index 00000000..210cc755 --- /dev/null +++ b/app/api/routes-f/polls/route.ts @@ -0,0 +1,208 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { uuidSchema } from "@/app/api/routes-f/_lib/schemas"; +import { getOptionalSession } from "@/app/api/routes-f/_lib/session"; +import { closeExpiredPolls, ensurePollSchema } from "./_lib/db"; + +const pollQuerySchema = z.object({ + stream_id: uuidSchema, +}); + +const createPollSchema = z.object({ + stream_id: uuidSchema, + question: z.string().trim().min(1).max(200), + options: z.array(z.string().trim().min(1).max(80)).min(2).max(6), + duration_seconds: z.number().int().min(15).max(600).default(60), +}); + +async function buildPollResponse(pollId: string, viewerId?: string | null) { + const { rows: pollRows } = await sql<{ + id: string; + question: string; + options: Array<{ id: number; text: string }>; + status: string; + closes_at: string; + viewer_voted: number | null; + }>` + SELECT + p.id, + p.question, + p.options, + p.status, + p.closes_at, + ( + SELECT pv.option_id + FROM poll_votes pv + WHERE pv.poll_id = p.id + AND pv.voter_id = ${viewerId ?? null} + LIMIT 1 + ) AS viewer_voted + FROM stream_polls p + WHERE p.id = ${pollId} + LIMIT 1 + `; + + if (pollRows.length === 0) { + return null; + } + + const poll = pollRows[0]; + const { rows: voteRows } = await sql<{ option_id: number; votes: number }>` + SELECT option_id, COUNT(*)::int AS votes + FROM poll_votes + WHERE poll_id = ${pollId} + GROUP BY option_id + `; + + const totalVotes = voteRows.reduce((sum, row) => sum + Number(row.votes), 0); + const voteMap = new Map( + voteRows.map(row => [Number(row.option_id), Number(row.votes)]) + ); + const options = (poll.options ?? []).map(option => { + const votes = voteMap.get(option.id) ?? 0; + const percentage = + totalVotes > 0 ? Math.round((votes / totalVotes) * 100) : 0; + return { ...option, votes, percentage }; + }); + + return { + id: poll.id, + question: poll.question, + options, + status: + poll.status === "active" && new Date(poll.closes_at) <= new Date() + ? "closed" + : poll.status, + closes_at: poll.closes_at, + viewer_voted: poll.viewer_voted ?? null, + }; +} + +export async function GET(req: NextRequest): Promise { + const queryResult = validateQuery( + new URL(req.url).searchParams, + pollQuerySchema + ); + if (queryResult instanceof Response) { + return queryResult; + } + + try { + await ensurePollSchema(); + await closeExpiredPolls(); + + const session = await getOptionalSession(req); + const { rows } = await sql<{ id: string }>` + SELECT id + FROM stream_polls + WHERE stream_id = ${queryResult.data.stream_id} + AND status = 'active' + ORDER BY created_at DESC + LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ poll: null }); + } + + const poll = await buildPollResponse(rows[0].id, session?.userId ?? null); + return NextResponse.json(poll); + } catch (error) { + console.error("[routes-f polls GET]", error); + return NextResponse.json( + { error: "Failed to fetch poll" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, createPollSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { stream_id, question, options } = bodyResult.data; + const durationSeconds = bodyResult.data.duration_seconds ?? 60; + + try { + await ensurePollSchema(); + await closeExpiredPolls(); + + const { rows: streamRows } = await sql<{ user_id: string }>` + SELECT user_id + FROM stream_sessions + WHERE id = ${stream_id} + LIMIT 1 + `; + + if (streamRows.length === 0) { + return NextResponse.json({ error: "Stream not found" }, { status: 404 }); + } + + if (streamRows[0].user_id !== session.userId) { + return NextResponse.json( + { error: "Only the stream owner can create polls" }, + { status: 403 } + ); + } + + const closesAt = new Date( + Date.now() + durationSeconds * 1000 + ).toISOString(); + const normalizedOptions = options.map((text, index) => ({ + id: index + 1, + text, + })); + + try { + const { rows } = await sql<{ id: string }>` + INSERT INTO stream_polls ( + stream_id, + streamer_id, + question, + options, + status, + duration_seconds, + closes_at + ) + VALUES ( + ${stream_id}, + ${session.userId}, + ${question}, + ${JSON.stringify(normalizedOptions)}::jsonb, + 'active', + ${durationSeconds}, + ${closesAt} + ) + RETURNING id + `; + + const poll = await buildPollResponse(rows[0].id, session.userId); + return NextResponse.json(poll, { status: 201 }); + } catch (error) { + const pgError = error as { code?: string }; + if (pgError.code === "23505") { + return NextResponse.json( + { error: "Only one active poll is allowed per stream" }, + { status: 409 } + ); + } + throw error; + } + } catch (error) { + console.error("[routes-f polls POST]", error); + return NextResponse.json( + { error: "Failed to create poll" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/privacy/__tests__/route.test.ts b/app/api/routes-f/privacy/__tests__/route.test.ts new file mode 100644 index 00000000..86e8d94a --- /dev/null +++ b/app/api/routes-f/privacy/__tests__/route.test.ts @@ -0,0 +1,149 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => { + return new Response(JSON.stringify(body), { + ...init, + headers: { + "Content-Type": "application/json", + ...(init?.headers || {}) + }, + }); + }, + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +jest.mock("@/app/api/routes-f/_lib/schema", () => ({ + ensureRoutesFSchema: jest.fn().mockResolvedValue(undefined), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { GET, PATCH } from "../route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +function makeRequest(method: string, path: string, body?: object) { + return new Request(`http://localhost${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) as any; +} + +describe("routes-f privacy settings", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns 401 for unauthenticated requests", async () => { + verifySessionMock.mockResolvedValue({ + ok: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }), + }); + + const res = await GET(makeRequest("GET", "/api/routes-f/privacy")); + expect(res.status).toBe(401); + }); + + it("returns default settings when none exist in DB", async () => { + verifySessionMock.mockResolvedValue({ + ok: true, + userId: "user-123", + }); + + sqlMock.mockResolvedValueOnce({ rows: [] }); + + const res = await GET(makeRequest("GET", "/api/routes-f/privacy")); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.show_in_viewer_list).toBe(true); + expect(json.searchable_by_email).toBe(false); + }); + + it("returns stored settings from DB", async () => { + verifySessionMock.mockResolvedValue({ + ok: true, + userId: "user-123", + }); + + sqlMock.mockResolvedValueOnce({ + rows: [ + { + show_in_viewer_list: false, + show_watch_history: true, + show_following_list: false, + allow_collab_requests: false, + searchable_by_email: true, + }, + ], + }); + + const res = await GET(makeRequest("GET", "/api/routes-f/privacy")); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.show_in_viewer_list).toBe(false); + expect(json.show_watch_history).toBe(true); + expect(json.searchable_by_email).toBe(true); + }); + + it("updates settings via PATCH (upsert)", async () => { + verifySessionMock.mockResolvedValue({ + ok: true, + userId: "user-123", + }); + + sqlMock.mockResolvedValueOnce({ + rows: [ + { + user_id: "user-123", + show_in_viewer_list: false, + show_watch_history: false, + show_following_list: true, + allow_collab_requests: true, + searchable_by_email: true, + }, + ], + }); + + const res = await PATCH( + makeRequest("PATCH", "/api/routes-f/privacy", { + show_in_viewer_list: false, + searchable_by_email: true, + }) + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.show_in_viewer_list).toBe(false); + expect(json.searchable_by_email).toBe(true); + expect(sqlMock).toHaveBeenCalled(); + }); + + it("validates field types in PATCH", async () => { + verifySessionMock.mockResolvedValue({ + ok: true, + userId: "user-123", + }); + + const res = await PATCH( + makeRequest("PATCH", "/api/routes-f/privacy", { + show_in_viewer_list: "yes", // should be boolean + }) + ); + + expect(res.status).toBe(400); + const json = await res.json(); + expect(json.error).toMatch(/Invalid type/); + }); +}); diff --git a/app/api/routes-f/privacy/route.ts b/app/api/routes-f/privacy/route.ts new file mode 100644 index 00000000..9215c716 --- /dev/null +++ b/app/api/routes-f/privacy/route.ts @@ -0,0 +1,145 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { ensureRoutesFSchema } from "../_lib/schema"; + +/** + * GET /api/routes-f/privacy + * Returns privacy settings for the authenticated user. + */ +export async function GET(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) return session.response; + + try { + // Ensure schema and table exist + await ensureRoutesFSchema(); + + const { rows } = await sql` + SELECT + show_in_viewer_list, + show_watch_history, + show_following_list, + allow_collab_requests, + searchable_by_email + FROM user_privacy + WHERE user_id = ${session.userId} + LIMIT 1 + `; + + if (rows.length === 0) { + // Return defaults if no entry exists yet + return NextResponse.json({ + show_in_viewer_list: true, + show_watch_history: false, + show_following_list: true, + allow_collab_requests: true, + searchable_by_email: false, + }); + } + + return NextResponse.json(rows[0]); + } catch (error) { + console.error("[privacy:GET] Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +/** + * PATCH /api/routes-f/privacy + * Partially update privacy settings for the authenticated user. + */ +export async function PATCH(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) return session.response; + + let body: any; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + // Define allowed fields and their types + const allowedFields: Record = { + show_in_viewer_list: "boolean", + show_watch_history: "boolean", + show_following_list: "boolean", + allow_collab_requests: "boolean", + searchable_by_email: "boolean", + }; + + const updates: Record = {}; + for (const [key, value] of Object.entries(body)) { + if (key in allowedFields) { + if (typeof value !== allowedFields[key]) { + return NextResponse.json( + { error: `Invalid type for ${key}. Expected ${allowedFields[key]}.` }, + { status: 400 } + ); + } + updates[key] = value; + } + } + + if (Object.keys(updates).length === 0) { + return NextResponse.json( + { error: "No valid fields to update" }, + { status: 400 } + ); + } + + try { + await ensureRoutesFSchema(); + + // Upsert the privacy settings + // Since we're using Postgres, we can use INSERT ... ON CONFLICT + + // Build the dynamic update part + const columns = Object.keys(updates); + const values = Object.values(updates); + + // We'll use a transaction or a single robust query + // Simple approach: UPSERT with defaults for missing columns + + const { rows } = await sql` + INSERT INTO user_privacy ( + user_id, + show_in_viewer_list, + show_watch_history, + show_following_list, + allow_collab_requests, + searchable_by_email, + updated_at + ) + VALUES ( + ${session.userId}, + ${updates.show_in_viewer_list ?? true}, + ${updates.show_watch_history ?? false}, + ${updates.show_following_list ?? true}, + ${updates.allow_collab_requests ?? true}, + ${updates.searchable_by_email ?? false}, + now() + ) + ON CONFLICT (user_id) DO UPDATE SET + show_in_viewer_list = COALESCE(${updates.show_in_viewer_list ?? null}, user_privacy.show_in_viewer_list), + show_watch_history = COALESCE(${updates.show_watch_history ?? null}, user_privacy.show_watch_history), + show_following_list = COALESCE(${updates.show_following_list ?? null}, user_privacy.show_following_list), + allow_collab_requests = COALESCE(${updates.allow_collab_requests ?? null}, user_privacy.allow_collab_requests), + searchable_by_email = COALESCE(${updates.searchable_by_email ?? null}, user_privacy.searchable_by_email), + updated_at = now() + RETURNING * + `; + + return NextResponse.json(rows[0]); + } catch (error) { + console.error("[privacy:PATCH] Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/rate-limits/route.ts b/app/api/routes-f/rate-limits/route.ts new file mode 100644 index 00000000..0083188b --- /dev/null +++ b/app/api/routes-f/rate-limits/route.ts @@ -0,0 +1,107 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifySession } from "@/lib/auth/verify-session"; + +interface RateLimitWindow { + resource: string; + limit: number; + remaining: number; + reset_at: string; +} + +interface ResourceConfig { + resource: string; + limit: number; + window_ms: number; +} + +const RESOURCES: ResourceConfig[] = [ + { resource: "chat_messages", limit: 30, window_ms: 60 * 1000 }, + { resource: "tips", limit: 10, window_ms: 60 * 1000 }, + { resource: "reports", limit: 5, window_ms: 10 * 60 * 1000 }, + { resource: "api_calls", limit: 100, window_ms: 60 * 1000 }, +]; + +const hasRedis = + !!process.env.UPSTASH_REDIS_REST_URL && + !!process.env.UPSTASH_REDIS_REST_TOKEN; + +async function redisGet(key: string): Promise { + const url = process.env.UPSTASH_REDIS_REST_URL!; + const token = process.env.UPSTASH_REDIS_REST_TOKEN!; + + try { + const res = await fetch(`${url}/GET/${encodeURIComponent(key)}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = (await res.json()) as { result: string | null }; + return data.result; + } catch { + return null; + } +} + +async function redisTtl(key: string): Promise { + const url = process.env.UPSTASH_REDIS_REST_URL!; + const token = process.env.UPSTASH_REDIS_REST_TOKEN!; + + try { + const res = await fetch(`${url}/TTL/${encodeURIComponent(key)}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const data = (await res.json()) as { result: number }; + return data.result > 0 ? data.result : 0; + } catch { + return 0; + } +} + +async function getWindowForResource( + userId: string, + config: ResourceConfig +): Promise { + if (!hasRedis) { + return { + resource: config.resource, + limit: config.limit, + remaining: config.limit, + reset_at: new Date(Date.now() + config.window_ms).toISOString(), + }; + } + + const key = `ratelimit:${config.resource}:${userId}`; + const [countStr, ttlSeconds] = await Promise.all([ + redisGet(key), + redisTtl(key), + ]); + + const count = countStr ? parseInt(countStr, 10) : 0; + const remaining = Math.max(0, config.limit - count); + const resetAt = + ttlSeconds > 0 + ? new Date(Date.now() + ttlSeconds * 1000).toISOString() + : new Date(Date.now() + config.window_ms).toISOString(); + + return { + resource: config.resource, + limit: config.limit, + remaining, + reset_at: resetAt, + }; +} + +/** + * GET /api/routes-f/rate-limits + * Returns per-resource rate limit windows for the authenticated user. + */ +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const windows = await Promise.all( + RESOURCES.map(config => getWindowForResource(session.userId, config)) + ); + + return NextResponse.json({ rate_limits: windows }); +} diff --git a/app/api/routes-f/reactions/[streamId]/route.ts b/app/api/routes-f/reactions/[streamId]/route.ts new file mode 100644 index 00000000..cd97de5a --- /dev/null +++ b/app/api/routes-f/reactions/[streamId]/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { getOptionalSession } from "@/app/api/routes-f/_lib/session"; +import { + enforceReactionRateLimit, + getReactionSummary, + incrementReaction, + reactionEmojiSchema, +} from "../_lib/reactions"; +import { z } from "zod"; + +const reactionBodySchema = z.object({ + emoji: reactionEmojiSchema, +}); + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ streamId: string }> } +): Promise { + const { streamId } = await params; + + try { + const summary = await getReactionSummary(streamId); + return NextResponse.json(summary, { + headers: { + "Cache-Control": "public, max-age=2, stale-while-revalidate=2", + }, + }); + } catch (error) { + console.error("[routes-f reactions/:streamId GET]", error); + return NextResponse.json( + { error: "Failed to fetch reactions" }, + { status: 500 } + ); + } +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ streamId: string }> } +): Promise { + const { streamId } = await params; + const bodyResult = await validateBody(req, reactionBodySchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + try { + const session = await getOptionalSession(req); + const isLimited = await enforceReactionRateLimit( + req, + streamId, + session?.userId ?? null + ); + + if (isLimited) { + return NextResponse.json( + { error: "Too many reactions. Please slow down." }, + { status: 429, headers: { "Retry-After": "10" } } + ); + } + + await incrementReaction(streamId, bodyResult.data.emoji); + const summary = await getReactionSummary(streamId); + + return NextResponse.json( + { + ok: true, + emoji: bodyResult.data.emoji, + reactions: summary.reactions, + total: summary.total, + }, + { status: 201 } + ); + } catch (error) { + console.error("[routes-f reactions/:streamId POST]", error); + return NextResponse.json( + { error: "Failed to record reaction" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/reactions/__tests__/route.test.ts b/app/api/routes-f/reactions/__tests__/route.test.ts new file mode 100644 index 00000000..b6ec4753 --- /dev/null +++ b/app/api/routes-f/reactions/__tests__/route.test.ts @@ -0,0 +1,101 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@/app/api/routes-f/_lib/session", () => ({ + getOptionalSession: jest.fn(), +})); + +jest.mock("../_lib/reactions", () => ({ + REACTIONS: ["❤️", "🔥", "😂", "👏", "💜", "🎉", "😮", "👑"], + reactionEmojiSchema: require("zod").z.enum([ + "❤️", + "🔥", + "😂", + "👏", + "💜", + "🎉", + "😮", + "👑", + ]), + enforceReactionRateLimit: jest.fn(), + getReactionSummary: jest.fn(), + incrementReaction: jest.fn(), +})); + +import { getOptionalSession } from "@/app/api/routes-f/_lib/session"; +import { + enforceReactionRateLimit, + getReactionSummary, + incrementReaction, +} from "../_lib/reactions"; +import { GET, POST } from "../[streamId]/route"; + +const getOptionalSessionMock = getOptionalSession as jest.Mock; +const enforceReactionRateLimitMock = enforceReactionRateLimit as jest.Mock; +const getReactionSummaryMock = getReactionSummary as jest.Mock; +const incrementReactionMock = incrementReaction as jest.Mock; + +function makeRequest(method: string, path: string, body?: object) { + return new Request(`http://localhost${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; +} + +describe("routes-f reactions", () => { + beforeEach(() => { + jest.clearAllMocks(); + getOptionalSessionMock.mockResolvedValue(null); + enforceReactionRateLimitMock.mockResolvedValue(false); + getReactionSummaryMock.mockResolvedValue({ + reactions: { "🔥": 3 }, + total: 3, + }); + incrementReactionMock.mockResolvedValue(undefined); + }); + + it("returns reaction counts for a stream", async () => { + const res = await GET( + makeRequest("GET", "/api/routes-f/reactions/stream-1"), + { + params: Promise.resolve({ streamId: "stream-1" }), + } + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.total).toBe(3); + expect(json.reactions["🔥"]).toBe(3); + }); + + it("records an anonymous reaction", async () => { + const res = await POST( + makeRequest("POST", "/api/routes-f/reactions/stream-1", { emoji: "🔥" }), + { params: Promise.resolve({ streamId: "stream-1" }) } + ); + const json = await res.json(); + + expect(res.status).toBe(201); + expect(incrementReactionMock).toHaveBeenCalledWith("stream-1", "🔥"); + expect(json.total).toBe(3); + }); + + it("returns 429 when reaction rate limit is hit", async () => { + enforceReactionRateLimitMock.mockResolvedValue(true); + + const res = await POST( + makeRequest("POST", "/api/routes-f/reactions/stream-1", { emoji: "🔥" }), + { params: Promise.resolve({ streamId: "stream-1" }) } + ); + + expect(res.status).toBe(429); + }); +}); diff --git a/app/api/routes-f/reactions/_lib/reactions.ts b/app/api/routes-f/reactions/_lib/reactions.ts new file mode 100644 index 00000000..78bbd317 --- /dev/null +++ b/app/api/routes-f/reactions/_lib/reactions.ts @@ -0,0 +1,219 @@ +import { NextRequest } from "next/server"; +import { sql } from "@vercel/postgres"; +import { Ratelimit } from "@upstash/ratelimit"; +import { z } from "zod"; +import { getUpstashRedis } from "@/lib/upstash"; + +export const REACTIONS = [ + "❤️", + "🔥", + "😂", + "👏", + "💜", + "🎉", + "😮", + "👑", +] as const; + +export const reactionEmojiSchema = z.enum(REACTIONS); + +type ReactionEmoji = (typeof REACTIONS)[number]; +type ReactionCounts = Record; + +const CACHE_TTL_SECONDS = 2; +const RATE_LIMIT_WINDOW_SECONDS = 10; +const RATE_LIMIT_MAX = 10; + +const memoryRateLimiter = new Map(); +const memoryCache = new Map< + string, + { expiresAt: number; value: ReactionSummary } +>(); + +let rateLimiter: Ratelimit | null | undefined; + +export type ReactionSummary = { + reactions: ReactionCounts; + total: number; +}; + +function getReactionKey(streamId: string, emoji: ReactionEmoji): string { + return `reactions:${streamId}:${emoji}`; +} + +function getReactionCacheKey(streamId: string): string { + return `reactions-cache:${streamId}`; +} + +function getRequesterId(req: NextRequest, userId?: string | null): string { + if (userId) { + return `user:${userId}`; + } + + const forwarded = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim(); + const realIp = req.headers.get("x-real-ip")?.trim(); + return `ip:${forwarded || realIp || "unknown"}`; +} + +async function getRateLimiter(): Promise { + if (rateLimiter !== undefined) { + return rateLimiter; + } + + const redis = await getUpstashRedis(); + rateLimiter = redis + ? new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow( + RATE_LIMIT_MAX, + `${RATE_LIMIT_WINDOW_SECONDS} s` + ), + analytics: false, + }) + : null; + + return rateLimiter; +} + +function getCachedMemorySummary(streamId: string): ReactionSummary | null { + const cached = memoryCache.get(streamId); + if (!cached) { + return null; + } + + if (cached.expiresAt <= Date.now()) { + memoryCache.delete(streamId); + return null; + } + + return cached.value; +} + +function setCachedMemorySummary( + streamId: string, + summary: ReactionSummary +): void { + memoryCache.set(streamId, { + expiresAt: Date.now() + CACHE_TTL_SECONDS * 1000, + value: summary, + }); +} + +export async function ensureStreamReactionColumns(): Promise { + await sql` + ALTER TABLE stream_sessions + ADD COLUMN IF NOT EXISTS reaction_counts JSONB NOT NULL DEFAULT '{}'::jsonb + `; + + await sql` + ALTER TABLE stream_sessions + ADD COLUMN IF NOT EXISTS reaction_total INTEGER NOT NULL DEFAULT 0 + `; +} + +export async function enforceReactionRateLimit( + req: NextRequest, + streamId: string, + userId?: string | null +): Promise { + const requesterId = `${streamId}:${getRequesterId(req, userId)}`; + const redisLimiter = await getRateLimiter(); + + if (redisLimiter) { + const { success } = await redisLimiter.limit(requesterId); + return !success; + } + + const now = Date.now(); + const windowStart = now - RATE_LIMIT_WINDOW_SECONDS * 1000; + const current = (memoryRateLimiter.get(requesterId) ?? []).filter( + timestamp => timestamp > windowStart + ); + current.push(now); + memoryRateLimiter.set(requesterId, current); + return current.length > RATE_LIMIT_MAX; +} + +export async function incrementReaction( + streamId: string, + emoji: ReactionEmoji +): Promise { + const redis = await getUpstashRedis(); + + if (redis) { + await redis.incr(getReactionKey(streamId, emoji)); + await redis.del(getReactionCacheKey(streamId)); + return; + } + + const current = getCachedMemorySummary(streamId) ?? { + reactions: {}, + total: 0, + }; + current.reactions[emoji] = (current.reactions[emoji] ?? 0) + 1; + current.total += 1; + setCachedMemorySummary(streamId, current); +} + +export async function getReactionSummary( + streamId: string +): Promise { + const redis = await getUpstashRedis(); + + if (redis) { + const cacheKey = getReactionCacheKey(streamId); + const cached = await redis.get(cacheKey); + if (cached) { + return cached; + } + + const counts = await Promise.all( + REACTIONS.map(async emoji => { + const value = await redis.get(getReactionKey(streamId, emoji)); + return [emoji, Number(value ?? 0)] as const; + }) + ); + + const reactions = counts.reduce((acc, [emoji, count]) => { + if (count > 0) { + acc[emoji] = count; + } + return acc; + }, {}); + + const total = counts.reduce((sum, [, count]) => sum + count, 0); + const summary = { reactions, total }; + + await redis.set(cacheKey, summary, { ex: CACHE_TTL_SECONDS }); + return summary; + } + + const cached = getCachedMemorySummary(streamId); + return cached ?? { reactions: {}, total: 0 }; +} + +export async function aggregateAndFlushStreamReactions( + streamSessionId: string +): Promise { + await ensureStreamReactionColumns(); + + const summary = await getReactionSummary(streamSessionId); + + await sql` + UPDATE stream_sessions + SET + reaction_counts = ${JSON.stringify(summary.reactions)}::jsonb, + reaction_total = ${summary.total}, + ended_at = COALESCE(ended_at, CURRENT_TIMESTAMP) + WHERE id = ${streamSessionId} + `; + + const redis = await getUpstashRedis(); + if (redis) { + const keys = REACTIONS.map(emoji => getReactionKey(streamSessionId, emoji)); + await redis.del(...keys, getReactionCacheKey(streamSessionId)); + } + + memoryCache.delete(streamSessionId); + return summary; +} diff --git a/app/api/routes-f/reactions/route.ts b/app/api/routes-f/reactions/route.ts new file mode 100644 index 00000000..ad3c24ec --- /dev/null +++ b/app/api/routes-f/reactions/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +import { REACTIONS } from "./_lib/reactions"; + +export async function GET(): Promise { + return NextResponse.json({ + allowed_reactions: REACTIONS, + }); +} diff --git a/app/api/routes-f/recommendations/__tests__/route.test.ts b/app/api/routes-f/recommendations/__tests__/route.test.ts new file mode 100644 index 00000000..f305dfd8 --- /dev/null +++ b/app/api/routes-f/recommendations/__tests__/route.test.ts @@ -0,0 +1,96 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); +jest.mock("@/app/api/routes-f/_lib/session", () => ({ + getOptionalSession: jest.fn(), +})); +jest.mock("@/lib/upstash", () => ({ + getUpstashRedis: jest.fn().mockResolvedValue(null), +})); + +import { sql } from "@vercel/postgres"; +import { getOptionalSession } from "@/app/api/routes-f/_lib/session"; +import { GET } from "../route"; + +const sqlMock = sql as unknown as jest.Mock; +const getOptionalSessionMock = getOptionalSession as jest.Mock; + +function makeRequest(path: string) { + return new Request( + `http://localhost${path}` + ) as unknown as import("next/server").NextRequest; +} + +describe("routes-f recommendations", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns anonymous trending recommendations", async () => { + getOptionalSessionMock.mockResolvedValue(null); + sqlMock.mockResolvedValueOnce({ + rows: [ + { + id: "user-1", + username: "alice", + avatar: null, + current_viewers: 120, + creator: { + streamTitle: "Ranked grind", + category: "Gaming", + tags: ["fps"], + }, + viewer_score: 0.12, + freshness_score: 0, + }, + ], + }); + + const res = await GET(makeRequest("/api/routes-f/recommendations")); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.anonymous).toBe(true); + expect(json.recommended[0].reason).toBe("Trending live stream"); + }); + + it("returns personalized recommendations for authenticated users", async () => { + getOptionalSessionMock.mockResolvedValue({ userId: "viewer-1" }); + sqlMock.mockResolvedValueOnce({ + rows: [ + { + id: "user-2", + username: "bob", + avatar: null, + current_viewers: 54, + creator: { + streamTitle: "Live coding", + category: "Technology", + tags: ["code"], + }, + follow_score: 1, + tip_score: 0, + category_score: 0, + tag_score: 0, + viewer_score: 0.054, + freshness_score: 0, + }, + ], + }); + + const res = await GET(makeRequest("/api/routes-f/recommendations")); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.anonymous).toBe(false); + expect(json.recommended[0].reason).toBe("Creator you follow is live"); + }); +}); diff --git a/app/api/routes-f/recommendations/route.ts b/app/api/routes-f/recommendations/route.ts new file mode 100644 index 00000000..7152e736 --- /dev/null +++ b/app/api/routes-f/recommendations/route.ts @@ -0,0 +1,249 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { getOptionalSession } from "@/app/api/routes-f/_lib/session"; +import { getUpstashRedis } from "@/lib/upstash"; + +const recommendationsQuerySchema = z.object({ + exclude_stream_id: z.string().uuid().optional(), +}); + +type RecommendationRow = { + id: string; + username: string; + avatar: string | null; + current_viewers: number | null; + creator: { streamTitle?: string; category?: string; tags?: string[] } | null; + follow_score?: number | null; + tip_score?: number | null; + category_score?: number | null; + tag_score?: number | null; + viewer_score?: number | null; + freshness_score?: number | null; +}; + +async function getCachedRecommendations( + key: string, + ttlSeconds: number, + loader: () => Promise +): Promise { + const redis = await getUpstashRedis(); + if (redis) { + const cached = await redis.get(key); + if (cached) { + return cached; + } + + const data = await loader(); + await redis.set(key, data, { ex: ttlSeconds }); + return data; + } + + return loader(); +} + +function toRecommendation(row: RecommendationRow) { + const category = row.creator?.category ?? "General"; + const reason = + Number(row.follow_score ?? 0) > 0 + ? "Creator you follow is live" + : Number(row.tip_score ?? 0) > 0 + ? "Creator you previously tipped" + : Number(row.category_score ?? 0) > 0 + ? "Category you watch" + : Number(row.tag_score ?? 0) > 0 + ? "Tags you watch" + : Number(row.freshness_score ?? 0) > 0 + ? "New creator to discover" + : "Trending live stream"; + + return { + username: row.username, + avatar: row.avatar, + stream_title: row.creator?.streamTitle ?? "Live now", + category, + viewer_count: Number(row.current_viewers ?? 0), + is_live: true, + reason, + }; +} + +export async function GET(req: NextRequest): Promise { + const queryResult = validateQuery( + new URL(req.url).searchParams, + recommendationsQuerySchema + ); + if (queryResult instanceof Response) { + return queryResult; + } + + try { + const session = await getOptionalSession(req); + const excludeStreamId = queryResult.data.exclude_stream_id ?? null; + const cacheKey = session + ? `recommendations:user:${session.userId}:${excludeStreamId ?? "none"}` + : `recommendations:anon:${excludeStreamId ?? "none"}`; + const cacheTtl = session ? 300 : 120; + + const payload = await getCachedRecommendations( + cacheKey, + cacheTtl, + async () => { + if (!session) { + const { rows } = await sql` + SELECT + u.id, + u.username, + u.avatar, + u.current_viewers, + u.creator, + (LEAST(COALESCE(u.current_viewers, 0), 1000) * 0.001)::float AS viewer_score, + CASE + WHEN u.created_at >= NOW() - INTERVAL '30 days' THEN 0.2 + ELSE 0 + END::float AS freshness_score + FROM users u + WHERE u.is_live = true + AND (${excludeStreamId}::uuid IS NULL OR u.id <> ${excludeStreamId}) + ORDER BY + ((LEAST(COALESCE(u.current_viewers, 0), 1000) * 0.001) + + CASE WHEN u.created_at >= NOW() - INTERVAL '30 days' THEN 0.2 ELSE 0 END) DESC, + u.current_viewers DESC, + u.username ASC + LIMIT 20 + `; + + return { + recommended: rows.map(toRecommendation), + anonymous: true, + }; + } + + const { rows } = await sql` + WITH user_categories AS ( + SELECT DISTINCT LOWER(category) AS category + FROM route_f_watch_events + WHERE user_id = ${session.userId} + ), + user_tags AS ( + SELECT DISTINCT LOWER(tag) AS tag + FROM route_f_watch_events e + JOIN users watched_creator ON watched_creator.id = e.stream_id + CROSS JOIN LATERAL UNNEST( + COALESCE( + ARRAY( + SELECT jsonb_array_elements_text( + CASE + WHEN jsonb_typeof(COALESCE(watched_creator.creator, '{}'::jsonb)->'tags', '[]'::jsonb) = 'array' + THEN COALESCE(watched_creator.creator, '{}'::jsonb)->'tags' + ELSE '[]'::jsonb + END + ) + ), + ARRAY[]::text[] + ) + ) AS tag + WHERE e.user_id = ${session.userId} + ), + tipped_creators AS ( + SELECT DISTINCT recipient_id + FROM tip_transactions + WHERE sender_id = ${session.userId} + ) + SELECT + u.id, + u.username, + u.avatar, + u.current_viewers, + u.creator, + CASE WHEN uf.follower_id IS NOT NULL THEN 1.0 ELSE 0 END::float AS follow_score, + CASE WHEN tc.recipient_id IS NOT NULL THEN 0.9 ELSE 0 END::float AS tip_score, + CASE + WHEN uc.category IS NOT NULL + AND LOWER(COALESCE(u.creator->>'category', '')) = uc.category + THEN 0.7 + ELSE 0 + END::float AS category_score, + CASE + WHEN ut.tag IS NOT NULL THEN 0.6 + ELSE 0 + END::float AS tag_score, + (LEAST(COALESCE(u.current_viewers, 0), 1000) * 0.001)::float AS viewer_score, + CASE + WHEN u.created_at >= NOW() - INTERVAL '30 days' THEN 0.2 + ELSE 0 + END::float AS freshness_score + FROM users u + LEFT JOIN user_follows uf + ON uf.followee_id = u.id AND uf.follower_id = ${session.userId} + LEFT JOIN tipped_creators tc + ON tc.recipient_id = u.id + LEFT JOIN user_categories uc + ON LOWER(COALESCE(u.creator->>'category', '')) = uc.category + LEFT JOIN user_tags ut + ON EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text( + CASE + WHEN jsonb_typeof(COALESCE(u.creator, '{}'::jsonb)->'tags', '[]'::jsonb) = 'array' + THEN COALESCE(u.creator, '{}'::jsonb)->'tags' + ELSE '[]'::jsonb + END + ) AS creator_tag + WHERE LOWER(creator_tag) = ut.tag + ) + WHERE u.is_live = true + AND u.id <> ${session.userId} + AND (${excludeStreamId}::uuid IS NULL OR u.id <> ${excludeStreamId}) + GROUP BY + u.id, + u.username, + u.avatar, + u.current_viewers, + u.creator, + uf.follower_id, + tc.recipient_id, + uc.category, + ut.tag + ORDER BY + ( + CASE WHEN uf.follower_id IS NOT NULL THEN 1.0 ELSE 0 END + + CASE WHEN tc.recipient_id IS NOT NULL THEN 0.9 ELSE 0 END + + CASE + WHEN uc.category IS NOT NULL + AND LOWER(COALESCE(u.creator->>'category', '')) = uc.category + THEN 0.7 + ELSE 0 + END + + CASE WHEN ut.tag IS NOT NULL THEN 0.6 ELSE 0 END + + (LEAST(COALESCE(u.current_viewers, 0), 1000) * 0.001) + + CASE WHEN u.created_at >= NOW() - INTERVAL '30 days' THEN 0.2 ELSE 0 END + ) DESC, + u.current_viewers DESC, + u.username ASC + LIMIT 20 + `; + + return { + recommended: rows.map(toRecommendation), + anonymous: false, + }; + } + ); + + return NextResponse.json(payload, { + headers: { + "Cache-Control": session + ? "private, max-age=300" + : "public, max-age=120", + }, + }); + } catch (error) { + console.error("[routes-f recommendations GET]", error); + return NextResponse.json( + { error: "Failed to fetch recommendations" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/recommendations/similar/route.ts b/app/api/routes-f/recommendations/similar/route.ts new file mode 100644 index 00000000..c84ba4c6 --- /dev/null +++ b/app/api/routes-f/recommendations/similar/route.ts @@ -0,0 +1,143 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { usernameSchema } from "@/app/api/routes-f/_lib/schemas"; +import { validateQuery } from "@/app/api/routes-f/_lib/validate"; + +const similarQuerySchema = z.object({ + username: usernameSchema, +}); + +type SimilarCreatorRow = { + username: string; + avatar: string | null; + creator: { category?: string; tags?: string[] } | null; + current_viewers: number | null; + shared_tags: string[] | null; + tag_overlap_count: number | null; +}; + +export async function GET(req: NextRequest): Promise { + const queryResult = validateQuery( + new URL(req.url).searchParams, + similarQuerySchema + ); + if (queryResult instanceof Response) { + return queryResult; + } + + try { + const { rows: creatorRows } = await sql<{ + id: string; + username: string; + creator: { category?: string; tags?: string[] } | null; + }>` + SELECT id, username, creator + FROM users + WHERE username = ${queryResult.data.username} + LIMIT 1 + `; + + if (creatorRows.length === 0) { + return NextResponse.json({ error: "Creator not found" }, { status: 404 }); + } + + const creator = creatorRows[0]; + const category = creator.creator?.category ?? null; + const serializedTags = JSON.stringify(creator.creator?.tags ?? []); + + const { rows } = await sql` + SELECT + u.username, + u.avatar, + u.creator, + u.current_viewers, + COALESCE( + ARRAY( + SELECT DISTINCT tag + FROM jsonb_array_elements_text( + CASE + WHEN jsonb_typeof(COALESCE(u.creator, '{}'::jsonb)->'tags', '[]'::jsonb) = 'array' + THEN COALESCE(u.creator, '{}'::jsonb)->'tags' + ELSE '[]'::jsonb + END + ) AS tag + WHERE tag IN ( + SELECT jsonb_array_elements_text(${serializedTags}::jsonb) + ) + ), + ARRAY[]::text[] + ) AS shared_tags, + COALESCE( + ARRAY_LENGTH( + ARRAY( + SELECT DISTINCT tag + FROM jsonb_array_elements_text( + CASE + WHEN jsonb_typeof(COALESCE(u.creator, '{}'::jsonb)->'tags', '[]'::jsonb) = 'array' + THEN COALESCE(u.creator, '{}'::jsonb)->'tags' + ELSE '[]'::jsonb + END + ) AS tag + WHERE tag IN ( + SELECT jsonb_array_elements_text(${serializedTags}::jsonb) + ) + ), + 1 + ), + 0 + )::int AS tag_overlap_count + FROM users u + WHERE u.id <> ${creator.id} + AND ( + (${category} IS NOT NULL AND LOWER(COALESCE(u.creator->>'category', '')) = LOWER(${category})) + OR EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text( + CASE + WHEN jsonb_typeof(COALESCE(u.creator, '{}'::jsonb)->'tags', '[]'::jsonb) = 'array' + THEN COALESCE(u.creator, '{}'::jsonb)->'tags' + ELSE '[]'::jsonb + END + ) AS tag + WHERE tag IN ( + SELECT jsonb_array_elements_text(${serializedTags}::jsonb) + ) + ) + ) + ORDER BY + CASE + WHEN ${category} IS NOT NULL + AND LOWER(COALESCE(u.creator->>'category', '')) = LOWER(${category}) + THEN 1 + ELSE 0 + END DESC, + tag_overlap_count DESC, + COALESCE(u.current_viewers, 0) DESC, + u.username ASC + LIMIT 20 + `; + + return NextResponse.json({ + creator: queryResult.data.username, + similar: rows.map(row => ({ + username: row.username, + avatar: row.avatar, + category: row.creator?.category ?? "General", + tags: row.creator?.tags ?? [], + viewer_count: Number(row.current_viewers ?? 0), + reason: + row.tag_overlap_count && row.tag_overlap_count > 0 + ? "Overlapping category and tags" + : "Overlapping category", + shared_tags: row.shared_tags ?? [], + })), + }); + } catch (error) { + console.error("[routes-f recommendations/similar GET]", error); + return NextResponse.json( + { error: "Failed to fetch similar creators" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/reports/__tests__/route.test.ts b/app/api/routes-f/reports/__tests__/route.test.ts new file mode 100644 index 00000000..2c34bab2 --- /dev/null +++ b/app/api/routes-f/reports/__tests__/route.test.ts @@ -0,0 +1,105 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +let mockRateLimited = false; +jest.mock("@/lib/rate-limit", () => ({ + createRateLimiter: () => async () => mockRateLimited, +})); + +import { sql } from "@vercel/postgres"; +import { POST } from "../route"; + +const sqlMock = sql as unknown as jest.Mock; + +const makeRequest = ( + body: object, + headers?: Record +): import("next/server").NextRequest => + new Request("http://localhost/api/routes-f/reports", { + method: "POST", + headers: { "Content-Type": "application/json", ...(headers ?? {}) }, + body: JSON.stringify(body), + }) as unknown as import("next/server").NextRequest; + +describe("POST /api/routes-f/reports", () => { + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + mockRateLimited = false; + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy?.mockRestore(); + }); + + it("returns 429 when IP exceeds rate limit", async () => { + mockRateLimited = true; + + const req = makeRequest({ + stream_id: "stream_123", + streamer: "alice", + reason: "spam", + }); + + const res = await POST(req); + const body = await res.json(); + + expect(res.status).toBe(429); + expect(body.error).toMatch(/Too many reports/i); + }); + + it("returns 400 for invalid reason", async () => { + const req = makeRequest({ + stream_id: "stream_123", + streamer: "alice", + reason: "abuse", + }); + + const res = await POST(req); + const body = await res.json(); + + expect(res.status).toBe(400); + expect(body.error).toMatch(/reason must be one of/i); + }); + + it("stores report with hashed IP and returns 201 with confirmation ID", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [], rowCount: 0 }) // CREATE TABLE + .mockResolvedValueOnce({ rows: [{ id: "report-abc" }], rowCount: 1 }); // INSERT + + const req = makeRequest( + { + stream_id: "stream_123", + streamer: "alice", + reason: "harassment", + details: "Viewer repeatedly posting abuse in chat", + }, + { "x-forwarded-for": "198.51.100.22" } + ); + + const res = await POST(req); + const body = await res.json(); + + expect(res.status).toBe(201); + expect(body.confirmationId).toBe("report-abc"); + + expect(sqlMock).toHaveBeenCalledTimes(2); + const insertArgs = sqlMock.mock.calls[1]; + const insertValues = insertArgs.slice(1); + + expect(insertValues).not.toContain("198.51.100.22"); + const ipHash = insertValues[4]; + expect(ipHash).toMatch(/^[a-f0-9]{64}$/); + }); +}); diff --git a/app/api/routes-f/reports/route.ts b/app/api/routes-f/reports/route.ts new file mode 100644 index 00000000..9224e832 --- /dev/null +++ b/app/api/routes-f/reports/route.ts @@ -0,0 +1,149 @@ +import { createHash } from "crypto"; +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { createRateLimiter } from "@/lib/rate-limit"; + +const REPORT_REASONS = [ + "spam", + "harassment", + "inappropriate_content", + "copyright", + "other", +] as const; + +type ReportReason = (typeof REPORT_REASONS)[number]; + +const isIpRateLimited = createRateLimiter(10 * 60 * 1000, 5); // 5 reports per 10 minutes per IP + +function getClientIp(req: NextRequest): string { + return ( + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + "unknown" + ); +} + +function hashReporterIp(ip: string): string { + const salt = + process.env.REPORT_IP_HASH_SALT ?? + process.env.SESSION_SECRET ?? + "streamfi-report-ip-salt"; + + return createHash("sha256").update(`${salt}:${ip}`).digest("hex"); +} + +async function ensureReportsTable(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS stream_reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stream_id TEXT NOT NULL, + streamer TEXT NOT NULL, + reason TEXT NOT NULL CHECK (reason IN ('spam', 'harassment', 'inappropriate_content', 'copyright', 'other')), + details TEXT, + reporter_ip_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; +} + +function isValidReason(reason: unknown): reason is ReportReason { + return ( + typeof reason === "string" && + (REPORT_REASONS as readonly string[]).includes(reason) + ); +} + +export async function POST(req: NextRequest) { + const ip = getClientIp(req); + + if (await isIpRateLimited(ip)) { + return NextResponse.json( + { error: "Too many reports. Please try again later." }, + { status: 429, headers: { "Retry-After": "600" } } + ); + } + + let body: { + stream_id?: unknown; + streamer?: unknown; + reason?: unknown; + details?: unknown; + }; + + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const streamId = + typeof body.stream_id === "string" ? body.stream_id.trim() : ""; + const streamer = typeof body.streamer === "string" ? body.streamer.trim() : ""; + const reason = body.reason; + const details = + typeof body.details === "string" ? body.details.trim() : undefined; + + if (!streamId) { + return NextResponse.json({ error: "stream_id is required" }, { status: 400 }); + } + + if (!streamer) { + return NextResponse.json({ error: "streamer is required" }, { status: 400 }); + } + + if (!isValidReason(reason)) { + return NextResponse.json( + { + error: + "reason must be one of: spam, harassment, inappropriate_content, copyright, other", + }, + { status: 400 } + ); + } + + if (details !== undefined && details.length > 2000) { + return NextResponse.json( + { error: "details must be 2000 characters or fewer" }, + { status: 400 } + ); + } + + try { + await ensureReportsTable(); + + const reporterIpHash = hashReporterIp(ip); + + const { rows } = await sql` + INSERT INTO stream_reports ( + stream_id, + streamer, + reason, + details, + reporter_ip_hash + ) + VALUES ( + ${streamId}, + ${streamer}, + ${reason}, + ${details ?? null}, + ${reporterIpHash} + ) + RETURNING id + `; + + return NextResponse.json( + { + message: "Report submitted successfully", + confirmationId: rows[0].id, + }, + { status: 201 } + ); + } catch (error) { + console.error("[reports] submit error:", error); + return NextResponse.json( + { error: "Failed to submit report" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/sessions/[id]/route.ts b/app/api/routes-f/sessions/[id]/route.ts new file mode 100644 index 00000000..768c4436 --- /dev/null +++ b/app/api/routes-f/sessions/[id]/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +async function ensureSessionsTable() { + await sql` + CREATE TABLE IF NOT EXISTS user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL UNIQUE, + device TEXT, + ip_region TEXT, + last_seen TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; +} + +function getSessionTokenHash(req: NextRequest): string | null { + const token = + req.cookies.get("wallet_session")?.value ?? + req.cookies.get("privy_session")?.value ?? + req.cookies.get("wallet")?.value ?? + null; + if (!token) return null; + let h = 0; + for (let i = 0; i < token.length; i++) { + h = (Math.imul(31, h) + token.charCodeAt(i)) | 0; + } + return Math.abs(h).toString(16).padStart(8, "0") + token.slice(-24).replace(/[^a-zA-Z0-9]/g, "x"); +} + +/** DELETE /api/routes-f/sessions/[id] — revoke a specific session */ +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const { id } = await params; + + try { + await ensureSessionsTable(); + + const { rows } = await sql` + SELECT id, token_hash, user_id + FROM user_sessions + WHERE id = ${id} + LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ error: "Session not found" }, { status: 404 }); + } + + const target = rows[0]; + + if (String(target.user_id) !== session.userId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + // Prevent revoking the current session via this endpoint + const currentHash = getSessionTokenHash(req); + if (currentHash && target.token_hash === currentHash) { + return NextResponse.json( + { error: "Cannot revoke your current session via this endpoint" }, + { status: 400 } + ); + } + + await sql`DELETE FROM user_sessions WHERE id = ${id}`; + + return NextResponse.json({ message: "Session revoked" }); + } catch (error) { + console.error("[routes-f sessions/:id DELETE]", error); + return NextResponse.json({ error: "Failed to revoke session" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/sessions/all/route.ts b/app/api/routes-f/sessions/all/route.ts new file mode 100644 index 00000000..a22c344f --- /dev/null +++ b/app/api/routes-f/sessions/all/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +async function ensureSessionsTable() { + await sql` + CREATE TABLE IF NOT EXISTS user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL UNIQUE, + device TEXT, + ip_region TEXT, + last_seen TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; +} + +function getSessionTokenHash(req: NextRequest): string | null { + const token = + req.cookies.get("wallet_session")?.value ?? + req.cookies.get("privy_session")?.value ?? + req.cookies.get("wallet")?.value ?? + null; + if (!token) return null; + let h = 0; + for (let i = 0; i < token.length; i++) { + h = (Math.imul(31, h) + token.charCodeAt(i)) | 0; + } + return Math.abs(h).toString(16).padStart(8, "0") + token.slice(-24).replace(/[^a-zA-Z0-9]/g, "x"); +} + +/** DELETE /api/routes-f/sessions/all — revoke all sessions except current */ +export async function DELETE(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + try { + await ensureSessionsTable(); + + const currentHash = getSessionTokenHash(req); + + if (currentHash) { + await sql` + DELETE FROM user_sessions + WHERE user_id = ${session.userId} + AND token_hash != ${currentHash} + `; + } else { + // No identifiable current session — revoke all + await sql` + DELETE FROM user_sessions + WHERE user_id = ${session.userId} + `; + } + + return NextResponse.json({ message: "All other sessions revoked" }); + } catch (error) { + console.error("[routes-f sessions/all DELETE]", error); + return NextResponse.json({ error: "Failed to revoke sessions" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/sessions/route.ts b/app/api/routes-f/sessions/route.ts new file mode 100644 index 00000000..7af131c6 --- /dev/null +++ b/app/api/routes-f/sessions/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +async function ensureSessionsTable() { + await sql` + CREATE TABLE IF NOT EXISTS user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(64) NOT NULL UNIQUE, + device TEXT, + ip_region TEXT, + last_seen TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + await sql` + CREATE INDEX IF NOT EXISTS idx_user_sessions_user + ON user_sessions (user_id, last_seen DESC) + `; +} + +/** Derive a stable session identifier from the request cookie token. */ +function getSessionTokenHash(req: NextRequest): string | null { + const token = + req.cookies.get("wallet_session")?.value ?? + req.cookies.get("privy_session")?.value ?? + req.cookies.get("wallet")?.value ?? + null; + if (!token) return null; + // Simple deterministic hash — not crypto-sensitive, just for row lookup + let h = 0; + for (let i = 0; i < token.length; i++) { + h = (Math.imul(31, h) + token.charCodeAt(i)) | 0; + } + return Math.abs(h).toString(16).padStart(8, "0") + token.slice(-24).replace(/[^a-zA-Z0-9]/g, "x"); +} + +/** Parse a rough region string from X-Forwarded-For / CF-IPCountry headers. */ +function parseRegion(req: NextRequest): string { + const country = req.headers.get("cf-ipcountry"); + const region = req.headers.get("cf-region") ?? req.headers.get("x-vercel-ip-city"); + if (country && region) return `${country}, ${region}`; + if (country) return country; + return "Unknown"; +} + +/** GET /api/routes-f/sessions — list all active sessions for authenticated user */ +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + try { + await ensureSessionsTable(); + + const currentHash = getSessionTokenHash(req); + + const { rows } = await sql` + SELECT id, device, ip_region, last_seen, created_at + FROM user_sessions + WHERE user_id = ${session.userId} + ORDER BY last_seen DESC + `; + + const sessions = rows.map((row: Record) => ({ + ...row, + is_current: currentHash + ? row.token_hash === currentHash + : false, + })); + + // Upsert current session so it appears in the list + if (currentHash) { + const device = req.headers.get("user-agent")?.slice(0, 200) ?? "Unknown"; + const ip_region = parseRegion(req); + await sql` + INSERT INTO user_sessions (user_id, token_hash, device, ip_region, last_seen) + VALUES (${session.userId}, ${currentHash}, ${device}, ${ip_region}, now()) + ON CONFLICT (token_hash) DO UPDATE + SET last_seen = now(), ip_region = EXCLUDED.ip_region + `; + } + + return NextResponse.json({ sessions }); + } catch (error) { + console.error("[routes-f sessions GET]", error); + return NextResponse.json({ error: "Failed to fetch sessions" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/social/share/__tests__/route.test.ts b/app/api/routes-f/social/share/__tests__/route.test.ts new file mode 100644 index 00000000..cee86f8a --- /dev/null +++ b/app/api/routes-f/social/share/__tests__/route.test.ts @@ -0,0 +1,62 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +import { sql } from "@vercel/postgres"; +import { POST } from "../route"; + +const sqlMock = sql as unknown as jest.Mock; + +const makeRequest = (body: object) => + new Request("http://localhost/api/routes-f/social/share", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) as unknown as import("next/server").NextRequest; + +describe("routes-f social share", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("generates Mux thumbnail metadata for clips", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ + rows: [ + { + id: "clip-1", + playback_id: "mux-playback-id", + title: "My clip", + username: "creator", + }, + ], + }) + .mockResolvedValueOnce({ + rows: [{ code: "Ab12Cd" }], + }); + + const res = await POST( + makeRequest({ + item_id: "clip-1", + item_type: "clip", + }) + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.short_url).toMatch(/^\/s\/[0-9A-Za-z]{6}$/); + expect(body.og_image_url).toBe( + "https://image.mux.com/mux-playback-id/thumbnail.jpg" + ); + expect(body.twitter_card).toBe("summary_large_image"); + }); +}); diff --git a/app/api/routes-f/social/share/route.ts b/app/api/routes-f/social/share/route.ts new file mode 100644 index 00000000..1ef9b044 --- /dev/null +++ b/app/api/routes-f/social/share/route.ts @@ -0,0 +1,238 @@ +import { randomBytes } from "crypto"; +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; + +type ShareItemType = "stream" | "clip" | "profile"; + +const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function makeBase62Code(length = 6) { + const bytes = randomBytes(length); + let code = ""; + for (let i = 0; i < length; i += 1) { + code += BASE62[bytes[i] % BASE62.length]; + } + return code; +} + +async function ensureSocialShareTable() { + await sql` + CREATE TABLE IF NOT EXISTS social_share_aliases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(6) NOT NULL UNIQUE, + item_id TEXT NOT NULL, + item_type VARCHAR(20) NOT NULL, + target_path TEXT NOT NULL, + og_title TEXT NOT NULL, + og_description TEXT NOT NULL, + og_image_url TEXT, + twitter_card TEXT NOT NULL DEFAULT 'summary_large_image', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT social_share_aliases_item_type_check + CHECK (item_type IN ('stream', 'clip', 'profile')) + ) + `; +} + +async function reserveCode(payload: { + itemId: string; + itemType: ShareItemType; + targetPath: string; + ogTitle: string; + ogDescription: string; + ogImageUrl: string | null; + twitterCard: string; +}) { + for (let attempt = 0; attempt < 10; attempt += 1) { + const code = makeBase62Code(6); + const result = await sql<{ code: string }>` + INSERT INTO social_share_aliases ( + code, + item_id, + item_type, + target_path, + og_title, + og_description, + og_image_url, + twitter_card + ) + SELECT + ${code}, + ${payload.itemId}, + ${payload.itemType}, + ${payload.targetPath}, + ${payload.ogTitle}, + ${payload.ogDescription}, + ${payload.ogImageUrl}, + ${payload.twitterCard} + WHERE NOT EXISTS ( + SELECT 1 FROM social_share_aliases WHERE code = ${code} + ) + RETURNING code + `; + + if (result.rows.length > 0) { + return code; + } + } + + throw new Error("Failed to generate a unique share code"); +} + +async function resolveSharePayload(itemId: string, itemType: ShareItemType) { + if (itemType === "profile") { + const result = await sql<{ + id: string; + username: string; + avatar: string | null; + bio: string | null; + creator: Record | null; + }>` + SELECT id, username, avatar, bio, creator + FROM users + WHERE id::text = ${itemId} OR LOWER(username) = LOWER(${itemId}) + LIMIT 1 + `; + + const user = result.rows[0]; + if (!user) { + throw new Error("Profile not found"); + } + + return { + targetPath: `/${user.username}`, + ogTitle: `@${user.username} on StreamFi`, + ogDescription: + user.bio?.trim() || + (typeof user.creator?.description === "string" + ? user.creator.description + : `Watch ${user.username} on StreamFi`), + ogImageUrl: user.avatar || null, + twitterCard: "summary_large_image", + }; + } + + if (itemType === "stream") { + const result = await sql<{ + id: string; + username: string; + mux_playback_id: string | null; + creator: Record | null; + }>` + SELECT id, username, mux_playback_id, creator + FROM users + WHERE id::text = ${itemId} + OR LOWER(username) = LOWER(${itemId}) + OR mux_playback_id = ${itemId} + LIMIT 1 + `; + + const user = result.rows[0]; + if (!user || !user.mux_playback_id) { + throw new Error("Stream not found"); + } + + const title = + (typeof user.creator?.streamTitle === "string" && + user.creator.streamTitle.trim()) || + `${user.username} on StreamFi`; + const description = + (typeof user.creator?.description === "string" && + user.creator.description.trim()) || + `Watch ${user.username}'s stream on StreamFi`; + + return { + targetPath: `/${user.username}/watch`, + ogTitle: title, + ogDescription: description, + ogImageUrl: `https://image.mux.com/${user.mux_playback_id}/thumbnail.jpg`, + twitterCard: "summary_large_image", + }; + } + + const clipResult = await sql<{ + id: string; + playback_id: string; + title: string | null; + username: string; + }>` + SELECT r.id, r.playback_id, r.title, u.username + FROM stream_recordings r + JOIN users u ON u.id = r.user_id + WHERE r.id::text = ${itemId} OR r.playback_id = ${itemId} + LIMIT 1 + `; + + const clip = clipResult.rows[0]; + if (!clip) { + throw new Error("Clip not found"); + } + + return { + targetPath: `/${clip.username}/clips/${clip.id}`, + ogTitle: clip.title?.trim() || `Clip from ${clip.username}`, + ogDescription: `Watch this clip from @${clip.username} on StreamFi`, + ogImageUrl: `https://image.mux.com/${clip.playback_id}/thumbnail.jpg`, + twitterCard: "summary_large_image", + }; +} + +export async function POST(req: NextRequest) { + try { + await ensureSocialShareTable(); + + const payload = await req.json(); + if (!isRecord(payload)) { + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } + + const itemId = payload.item_id; + const itemType = payload.item_type; + + if (typeof itemId !== "string" || !itemId.trim()) { + return NextResponse.json( + { error: "item_id is required" }, + { status: 400 } + ); + } + + if (!["stream", "clip", "profile"].includes(String(itemType))) { + return NextResponse.json( + { error: "item_type must be stream, clip, or profile" }, + { status: 400 } + ); + } + + const share = await resolveSharePayload( + itemId.trim(), + itemType as ShareItemType + ); + const code = await reserveCode({ + itemId: itemId.trim(), + itemType: itemType as ShareItemType, + ...share, + }); + + return NextResponse.json({ + short_url: `/s/${code}`, + og_title: share.ogTitle, + og_description: share.ogDescription, + og_image_url: share.ogImageUrl, + twitter_card: share.twitterCard, + }); + } catch (error) { + if (error instanceof Error && /not found/i.test(error.message)) { + return NextResponse.json({ error: error.message }, { status: 404 }); + } + + console.error("[routes-f/social/share] POST error:", error); + return NextResponse.json( + { error: "Failed to generate share payload" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/stream/chapters/[id]/route.ts b/app/api/routes-f/stream/chapters/[id]/route.ts new file mode 100644 index 00000000..6e98beba --- /dev/null +++ b/app/api/routes-f/stream/chapters/[id]/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +async function ensureChaptersTable() { + await sql` + CREATE TABLE IF NOT EXISTS stream_chapters ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stream_id UUID NOT NULL REFERENCES stream_sessions(id) ON DELETE CASCADE, + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + timestamp_seconds INTEGER NOT NULL CHECK (timestamp_seconds >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (stream_id, timestamp_seconds, title) + ) + `; +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { id } = await params; + + try { + await ensureChaptersTable(); + + const { rows } = await sql` + SELECT creator_id + FROM stream_chapters + WHERE id = ${id} + LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ error: "Chapter not found" }, { status: 404 }); + } + + if (String(rows[0].creator_id) !== session.userId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + await sql` + DELETE FROM stream_chapters + WHERE id = ${id} + `; + + return NextResponse.json({ message: "Chapter removed" }); + } catch (error) { + console.error("[routes-f stream/chapters/:id DELETE]", error); + return NextResponse.json( + { error: "Failed to remove chapter" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/stream/chapters/route.ts b/app/api/routes-f/stream/chapters/route.ts new file mode 100644 index 00000000..de493585 --- /dev/null +++ b/app/api/routes-f/stream/chapters/route.ts @@ -0,0 +1,185 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; + +const chaptersQuerySchema = z.object({ + stream_id: z.string().uuid(), +}); + +const createChapterSchema = z.object({ + stream_id: z.string().uuid(), + title: z.string().trim().min(1).max(200), + timestamp_seconds: z.number().int().min(0), +}); + +const MAX_CHAPTERS_PER_STREAM = 50; + +async function ensureChaptersTable() { + await sql` + CREATE TABLE IF NOT EXISTS stream_chapters ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stream_id UUID NOT NULL REFERENCES stream_sessions(id) ON DELETE CASCADE, + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + timestamp_seconds INTEGER NOT NULL CHECK (timestamp_seconds >= 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (stream_id, timestamp_seconds, title) + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_stream_chapters_stream_timestamp + ON stream_chapters (stream_id, timestamp_seconds ASC) + `; +} + +function resolveElapsedSeconds(startedAt: Date, endedAt: Date | null): number { + const startMs = startedAt.getTime(); + if (Number.isNaN(startMs)) { + return 0; + } + + const endMs = endedAt ? endedAt.getTime() : Date.now(); + if (Number.isNaN(endMs) || endMs <= startMs) { + return 0; + } + + return Math.floor((endMs - startMs) / 1000); +} + +export async function GET(req: NextRequest): Promise { + const queryResult = validateQuery( + new URL(req.url).searchParams, + chaptersQuerySchema + ); + if (queryResult instanceof Response) { + return queryResult; + } + + const { stream_id } = queryResult.data; + + try { + await ensureChaptersTable(); + + const { rows: streamRows } = await sql` + SELECT id + FROM stream_sessions + WHERE id = ${stream_id} + LIMIT 1 + `; + + if (streamRows.length === 0) { + return NextResponse.json({ error: "Stream not found" }, { status: 404 }); + } + + const { rows } = await sql` + SELECT id, stream_id, creator_id, title, timestamp_seconds, created_at + FROM stream_chapters + WHERE stream_id = ${stream_id} + ORDER BY timestamp_seconds ASC, created_at ASC + `; + + return NextResponse.json({ chapters: rows }); + } catch (error) { + console.error("[routes-f stream/chapters GET]", error); + return NextResponse.json( + { error: "Failed to fetch stream chapters" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, createChapterSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { stream_id, title, timestamp_seconds } = bodyResult.data; + + try { + await ensureChaptersTable(); + + const { rows: streamRows } = await sql<{ + id: string; + user_id: string; + started_at: string; + ended_at: string | null; + }>` + SELECT id, user_id, started_at, ended_at + FROM stream_sessions + WHERE id = ${stream_id} + LIMIT 1 + `; + + if (streamRows.length === 0) { + return NextResponse.json({ error: "Stream not found" }, { status: 404 }); + } + + const stream = streamRows[0]; + + if (String(stream.user_id) !== session.userId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const startedAt = new Date(stream.started_at); + const endedAt = stream.ended_at ? new Date(stream.ended_at) : null; + const elapsedSeconds = resolveElapsedSeconds(startedAt, endedAt); + + if (timestamp_seconds > elapsedSeconds) { + return NextResponse.json( + { + error: + "timestamp_seconds must be less than or equal to current stream elapsed time", + elapsed_seconds: elapsedSeconds, + }, + { status: 400 } + ); + } + + const { rows: countRows } = await sql` + SELECT COUNT(*)::int AS total + FROM stream_chapters + WHERE stream_id = ${stream_id} + `; + + const count = Number(countRows[0]?.total ?? 0); + if (count >= MAX_CHAPTERS_PER_STREAM) { + return NextResponse.json( + { error: "Maximum of 50 chapters per stream reached" }, + { status: 400 } + ); + } + + const { rows } = await sql` + INSERT INTO stream_chapters ( + stream_id, + creator_id, + title, + timestamp_seconds + ) + VALUES ( + ${stream_id}, + ${session.userId}, + ${title}, + ${timestamp_seconds} + ) + RETURNING id, stream_id, creator_id, title, timestamp_seconds, created_at + `; + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + console.error("[routes-f stream/chapters POST]", error); + return NextResponse.json( + { error: "Failed to create chapter" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/stream/embed/route.ts b/app/api/routes-f/stream/embed/route.ts new file mode 100644 index 00000000..6c4e2de2 --- /dev/null +++ b/app/api/routes-f/stream/embed/route.ts @@ -0,0 +1,176 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { signToken } from "@/lib/auth/sign-token"; + +/** + * Valid hostname regex covering standard domain names. + */ +const HOSTNAME_REGEX = /^(?:[a-zA-Z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,10}$/i; + +function isValidHostname(hostname: string): boolean { + return HOSTNAME_REGEX.test(hostname); +} + +/** + * Generates the embed URL and the HTML snippet for a creator. + */ +function generateEmbedData(username: string, origin: string, settings: any) { + const secret = process.env.SESSION_SECRET || "fallback-secret-streamfi"; + const token = signToken( + { + creator: username, + exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30 // 30 day expiration + }, + secret + ); + + const params = new URLSearchParams(); + params.set("token", token); + + // Embed viewer flags + if (settings.autoplay) params.set("autoplay", "1"); + if (settings.muted_by_default) params.set("muted", "1"); + if (settings.chat_enabled === false) params.set("chat", "0"); + + const embed_url = `${origin}/embed/${username}?${params.toString()}`; + const suggested_html = ``; + + return { embed_url, suggested_html }; +} + +// ── GET /api/routes-f/stream/embed?creator= ───────────────────────────────── +// Returns embed configuration for a creator's stream (public route). +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const username = searchParams.get("creator"); + + if (!username) { + return NextResponse.json({ error: "Missing creator parameter" }, { status: 400 }); + } + + try { + const { rows } = await sql` + SELECT creator, username FROM users WHERE username = ${username} LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ error: "Creator not found" }, { status: 404 }); + } + + const { creator = {} } = rows[0]; + const embedSettings = creator.embed_settings || { + allowed_domains: [], + chat_enabled: true, + autoplay: false, + muted_by_default: true, + }; + + const origin = req.nextUrl.origin; + const { embed_url, suggested_html } = generateEmbedData(username, origin, embedSettings); + + return NextResponse.json({ + settings: embedSettings, + embed_url, + suggested_html + }); + } catch (err) { + console.error("[stream/embed:GET] error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// ── PATCH /api/routes-f/stream/embed ─────────────────────────────────────── +// Creator updates their embed settings. +export async function PATCH(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) return session.response; + + let body: { + allowed_domains?: string[]; + chat_enabled?: boolean; + autoplay?: boolean; + muted_by_default?: boolean; + }; + + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { allowed_domains, chat_enabled, autoplay, muted_by_default } = body; + + // ── Validation ──────────────────────────────────────────────────────────── + if (allowed_domains !== undefined) { + if (!Array.isArray(allowed_domains)) { + return NextResponse.json({ error: "allowed_domains must be an array" }, { status: 400 }); + } + for (const domain of allowed_domains) { + if (typeof domain !== "string" || !isValidHostname(domain)) { + return NextResponse.json({ error: `Invalid domain format: ${domain}` }, { status: 400 }); + } + } + } + + if (chat_enabled !== undefined && typeof chat_enabled !== "boolean") { + return NextResponse.json({ error: "chat_enabled must be a boolean" }, { status: 400 }); + } + if (autoplay !== undefined && typeof autoplay !== "boolean") { + return NextResponse.json({ error: "autoplay must be a boolean" }, { status: 400 }); + } + if (muted_by_default !== undefined && typeof muted_by_default !== "boolean") { + return NextResponse.json({ error: "muted_by_default must be a boolean" }, { status: 400 }); + } + + try { + const { rows } = await sql` + SELECT creator, username FROM users WHERE id = ${session.userId} LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const { creator = {}, username } = rows[0]; + const currentEmbedSettings = creator.embed_settings || { + allowed_domains: [], + chat_enabled: true, + autoplay: false, + muted_by_default: true, + }; + + const updatedEmbedSettings = { + ...currentEmbedSettings, + ...(allowed_domains !== undefined && { allowed_domains }), + ...(chat_enabled !== undefined && { chat_enabled }), + ...(autoplay !== undefined && { autoplay }), + ...(muted_by_default !== undefined && { muted_by_default }), + }; + + const updatedCreator = { + ...creator, + embed_settings: updatedEmbedSettings, + }; + + await sql` + UPDATE users SET + creator = ${JSON.stringify(updatedCreator)}, + updated_at = CURRENT_TIMESTAMP + WHERE id = ${session.userId} + `; + + const origin = req.nextUrl.origin; + const { embed_url, suggested_html } = generateEmbedData(username, origin, updatedEmbedSettings); + + return NextResponse.json({ + ok: true, + settings: updatedEmbedSettings, + embed_url, + suggested_html + }); + } catch (err) { + console.error("[stream/embed:PATCH] error:", err); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/stream/goals/[id]/route.ts b/app/api/routes-f/stream/goals/[id]/route.ts new file mode 100644 index 00000000..4610b15e --- /dev/null +++ b/app/api/routes-f/stream/goals/[id]/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { uuidSchema } from "@/app/api/routes-f/_lib/schemas"; + +interface RouteParams { + params: Promise<{ id: string }> | { id: string }; +} + +async function ensureStreamGoalsSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS route_f_stream_goals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stream_id UUID NOT NULL, + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL CHECK (type IN ('tip_amount', 'new_subs', 'viewer_count')), + target NUMERIC(20, 7) NOT NULL CHECK (target > 0), + title VARCHAR(160) NOT NULL, + completed_at TIMESTAMPTZ, + stream_started_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; +} + +function validateId(id: string): NextResponse | null { + const result = uuidSchema.safeParse(id); + if (!result.success) { + return NextResponse.json({ error: "Invalid goal id" }, { status: 400 }); + } + return null; +} + +export async function DELETE( + req: NextRequest, + context: RouteParams +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { id } = await context.params; + const idError = validateId(id); + if (idError) { + return idError; + } + + try { + await ensureStreamGoalsSchema(); + + const { rows: goalRows } = await sql` + SELECT id, creator_id + FROM route_f_stream_goals + WHERE id = ${id} + LIMIT 1 + `; + + if (goalRows.length === 0) { + return NextResponse.json({ error: "Goal not found" }, { status: 404 }); + } + + if (String(goalRows[0].creator_id) !== session.userId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + await sql` + DELETE FROM route_f_stream_goals + WHERE id = ${id} + `; + + return NextResponse.json({ + id, + deleted: true, + }); + } catch (error) { + console.error("[routes-f stream/goals/[id] DELETE]", error); + return NextResponse.json( + { error: "Failed to delete stream goal" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/stream/goals/__tests__/route.test.ts b/app/api/routes-f/stream/goals/__tests__/route.test.ts new file mode 100644 index 00000000..bdbef981 --- /dev/null +++ b/app/api/routes-f/stream/goals/__tests__/route.test.ts @@ -0,0 +1,164 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { GET, POST } from "../route"; +import { DELETE } from "../[id]/route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +const AUTHED_SESSION = { + ok: true as const, + userId: "creator-id", + wallet: null, + privyId: "did:privy:abc", + username: "creator", + email: "creator@example.com", +}; + +const STREAM_ID = "550e8400-e29b-41d4-a716-446655440000"; +const GOAL_ID = "660e8400-e29b-41d4-a716-446655440000"; + +function makeRequest(method: string, path: string, body?: object) { + return new Request(`http://localhost${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; +} + +describe("routes-f stream/goals", () => { + beforeEach(() => { + jest.clearAllMocks(); + verifySessionMock.mockResolvedValue(AUTHED_SESSION); + }); + + it("returns goals with computed progress and marks completed_at", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ + rows: [ + { + id: GOAL_ID, + stream_id: STREAM_ID, + creator_id: "creator-id", + type: "tip_amount", + target: "100", + title: "100 tip goal", + completed_at: null, + stream_started_at: "2026-03-28T00:00:00Z", + created_at: "2026-03-28T00:00:00Z", + updated_at: "2026-03-28T00:00:00Z", + }, + ], + }) + .mockResolvedValueOnce({ rows: [{ progress: "120" }] }) + .mockResolvedValueOnce({ rows: [{ progress: 3 }] }) + .mockResolvedValueOnce({ rows: [{ progress: 40 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await GET( + makeRequest("GET", `/api/routes-f/stream/goals?stream_id=${STREAM_ID}`) + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.goals).toHaveLength(1); + expect(json.goals[0].progress).toBe(120); + expect(json.goals[0].is_completed).toBe(true); + }); + + it("enforces max 2 active goals per stream", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ active_count: 2 }] }); + + const res = await POST( + makeRequest("POST", "/api/routes-f/stream/goals", { + stream_id: STREAM_ID, + type: "new_subs", + target: 10, + title: "10 new subs", + }) + ); + + expect(res.status).toBe(409); + }); + + it("creates a stream goal and returns computed progress", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ active_count: 1 }] }) + .mockResolvedValueOnce({ + rows: [{ started_at: "2026-03-28T00:00:00Z", user_id: "creator-id" }], + }) + .mockResolvedValueOnce({ + rows: [ + { + id: GOAL_ID, + stream_id: STREAM_ID, + creator_id: "creator-id", + type: "new_subs", + target: "10", + title: "10 new subs", + completed_at: null, + stream_started_at: "2026-03-28T00:00:00Z", + created_at: "2026-03-28T00:00:00Z", + updated_at: "2026-03-28T00:00:00Z", + }, + ], + }) + .mockResolvedValueOnce({ rows: [{ progress: "40" }] }) + .mockResolvedValueOnce({ rows: [{ progress: 6 }] }) + .mockResolvedValueOnce({ rows: [{ progress: 100 }] }); + + const res = await POST( + makeRequest("POST", "/api/routes-f/stream/goals", { + stream_id: STREAM_ID, + type: "new_subs", + target: 10, + title: "10 new subs", + }) + ); + const json = await res.json(); + + expect(res.status).toBe(201); + expect(json.target).toBe(10); + expect(json.progress).toBe(6); + expect(json.is_completed).toBe(false); + }); + + it("forbids deleting someone else's goal", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ + rows: [{ id: GOAL_ID, creator_id: "another-creator-id" }], + }); + + const res = await DELETE( + makeRequest("DELETE", `/api/routes-f/stream/goals/${GOAL_ID}`), + { params: { id: GOAL_ID } } + ); + const json = await res.json(); + + expect(res.status).toBe(403); + expect(json.error).toBe("Forbidden"); + }); +}); diff --git a/app/api/routes-f/stream/goals/route.ts b/app/api/routes-f/stream/goals/route.ts new file mode 100644 index 00000000..15a2867d --- /dev/null +++ b/app/api/routes-f/stream/goals/route.ts @@ -0,0 +1,325 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; + +const GOAL_TYPES = ["tip_amount", "new_subs", "viewer_count"] as const; + +type GoalType = (typeof GOAL_TYPES)[number]; + +type GoalMetrics = Record; + +type GoalRow = { + id: string; + stream_id: string; + creator_id: string; + type: GoalType; + target: string | number; + title: string; + completed_at: string | null; + stream_started_at: string; + created_at: string; + updated_at: string; +}; + +const listGoalQuerySchema = z.object({ + stream_id: z.string().uuid(), +}); + +const createGoalSchema = z.object({ + stream_id: z.string().uuid(), + type: z.enum(GOAL_TYPES), + target: z.number().positive(), + title: z.string().trim().min(1).max(160), +}); + +async function ensureStreamGoalsSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS route_f_stream_goals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stream_id UUID NOT NULL, + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL CHECK (type IN ('tip_amount', 'new_subs', 'viewer_count')), + target NUMERIC(20, 7) NOT NULL CHECK (target > 0), + title VARCHAR(160) NOT NULL, + completed_at TIMESTAMPTZ, + stream_started_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; +} + +function toNumber(value: string | number): number { + return typeof value === "number" ? value : Number(value); +} + +async function getLiveGoalMetrics( + creatorId: string, + streamStartedAt: string +): Promise { + const [tipsResult, subsResult, viewersResult] = await Promise.all([ + sql<{ progress: string | null }>` + SELECT COALESCE(SUM(amount_xlm), 0)::text AS progress + FROM tip_transactions + WHERE creator_id = ${creatorId} + AND created_at >= ${streamStartedAt} + `, + sql<{ progress: number | null }>` + SELECT COALESCE(COUNT(*)::int, 0) AS progress + FROM subscriptions + WHERE creator_id = ${creatorId} + AND created_at >= ${streamStartedAt} + `, + sql<{ progress: number | null }>` + SELECT COALESCE(current_viewers, 0)::int AS progress + FROM users + WHERE id = ${creatorId} + LIMIT 1 + `, + ]); + + return { + tip_amount: Number(tipsResult.rows[0]?.progress ?? 0), + new_subs: Number(subsResult.rows[0]?.progress ?? 0), + viewer_count: Number(viewersResult.rows[0]?.progress ?? 0), + }; +} + +async function markCompletedGoals( + goals: GoalRow[], + metrics: GoalMetrics +): Promise { + const completedGoalIds = goals + .filter(goal => !goal.completed_at && metrics[goal.type] >= toNumber(goal.target)) + .map(goal => goal.id); + + if (completedGoalIds.length === 0) { + return; + } + + await Promise.all( + completedGoalIds.map(goalId => sql` + UPDATE route_f_stream_goals + SET completed_at = NOW(), updated_at = NOW() + WHERE id = ${goalId} + AND completed_at IS NULL + `) + ); +} + +function toGoalPayload(goal: GoalRow, metrics: GoalMetrics) { + const target = toNumber(goal.target); + const progress = metrics[goal.type] ?? 0; + const completedAt = + goal.completed_at ?? (progress >= target ? new Date().toISOString() : null); + + return { + ...goal, + target, + progress, + completed_at: completedAt, + is_completed: Boolean(completedAt), + }; +} + +async function resolveStreamStartForCreator( + streamId: string, + creatorId: string +): Promise { + try { + const { rows } = await sql` + SELECT started_at, user_id + FROM stream_sessions + WHERE id = ${streamId} + LIMIT 1 + `; + + if (rows.length === 0) { + return new Date().toISOString(); + } + + const stream = rows[0]; + if (String(stream.user_id) !== creatorId) { + return ""; + } + + return stream.started_at + ? new Date(stream.started_at).toISOString() + : new Date().toISOString(); + } catch { + return new Date().toISOString(); + } +} + +export async function GET(req: NextRequest): Promise { + const queryResult = validateQuery( + new URL(req.url).searchParams, + listGoalQuerySchema + ); + if (queryResult instanceof Response) { + return queryResult; + } + + const { stream_id } = queryResult.data; + + try { + await ensureStreamGoalsSchema(); + + const { rows } = await sql` + SELECT + id, + stream_id, + creator_id, + type, + target, + title, + completed_at, + stream_started_at, + created_at, + updated_at + FROM route_f_stream_goals + WHERE stream_id = ${stream_id} + ORDER BY created_at ASC + `; + + if (rows.length === 0) { + return NextResponse.json({ stream_id, goals: [] }); + } + + const goals = rows as GoalRow[]; + const metrics = await getLiveGoalMetrics( + goals[0].creator_id, + goals[0].stream_started_at + ); + + await markCompletedGoals(goals, metrics); + const goalPayload = goals.map(goal => toGoalPayload(goal, metrics)); + + return NextResponse.json({ + stream_id, + goals: goalPayload, + active_count: goalPayload.filter(goal => !goal.is_completed).length, + }); + } catch (error) { + console.error("[routes-f stream/goals GET]", error); + return NextResponse.json( + { error: "Failed to fetch stream goals" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, createGoalSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { stream_id, type, target, title } = bodyResult.data; + + try { + await ensureStreamGoalsSchema(); + + const { rows: ownerRows } = await sql` + SELECT creator_id + FROM route_f_stream_goals + WHERE stream_id = ${stream_id} + LIMIT 1 + `; + + if ( + ownerRows.length > 0 && + String(ownerRows[0].creator_id) !== session.userId + ) { + return NextResponse.json( + { error: "Only the stream owner can manage goals" }, + { status: 403 } + ); + } + + const { rows: activeRows } = await sql` + SELECT COUNT(*)::int AS active_count + FROM route_f_stream_goals + WHERE stream_id = ${stream_id} + AND creator_id = ${session.userId} + AND completed_at IS NULL + `; + + if (Number(activeRows[0]?.active_count ?? 0) >= 2) { + return NextResponse.json( + { error: "A stream can have at most 2 active goals" }, + { status: 409 } + ); + } + + const streamStartedAt = await resolveStreamStartForCreator( + stream_id, + session.userId + ); + + if (!streamStartedAt) { + return NextResponse.json( + { error: "Only the stream owner can create goals for this stream" }, + { status: 403 } + ); + } + + const { rows } = await sql` + INSERT INTO route_f_stream_goals ( + stream_id, + creator_id, + type, + target, + title, + stream_started_at + ) + VALUES ( + ${stream_id}, + ${session.userId}, + ${type}, + ${target}, + ${title}, + ${streamStartedAt} + ) + RETURNING + id, + stream_id, + creator_id, + type, + target, + title, + completed_at, + stream_started_at, + created_at, + updated_at + `; + + const goal = rows[0] as GoalRow; + const metrics = await getLiveGoalMetrics(goal.creator_id, goal.stream_started_at); + + if (!goal.completed_at && metrics[goal.type] >= toNumber(goal.target)) { + await sql` + UPDATE route_f_stream_goals + SET completed_at = NOW(), updated_at = NOW() + WHERE id = ${goal.id} + AND completed_at IS NULL + `; + goal.completed_at = new Date().toISOString(); + } + + return NextResponse.json(toGoalPayload(goal, metrics), { status: 201 }); + } catch (error) { + console.error("[routes-f stream/goals POST]", error); + return NextResponse.json( + { error: "Failed to create stream goal" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/stream/polls/[id]/route.ts b/app/api/routes-f/stream/polls/[id]/route.ts new file mode 100644 index 00000000..c211b325 --- /dev/null +++ b/app/api/routes-f/stream/polls/[id]/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +async function ensurePollsTables() { + await sql` + CREATE TABLE IF NOT EXISTS stream_polls ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stream_id UUID NOT NULL, + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + question TEXT NOT NULL, + options JSONB NOT NULL, + duration_seconds INTEGER NOT NULL CHECK (duration_seconds BETWEEN 1 AND 300), + ends_at TIMESTAMPTZ NOT NULL, + ended_early BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; +} + +/** DELETE /api/routes-f/stream/polls/[id] — creator ends a poll early */ +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const { id } = await params; + + try { + await ensurePollsTables(); + + const { rows } = await sql` + SELECT id, creator_id, ended_early, ends_at + FROM stream_polls + WHERE id = ${id} + LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ error: "Poll not found" }, { status: 404 }); + } + + const poll = rows[0]; + + if (String(poll.creator_id) !== session.userId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + if (poll.ended_early || new Date(poll.ends_at) <= new Date()) { + return NextResponse.json({ error: "Poll has already ended" }, { status: 409 }); + } + + await sql` + UPDATE stream_polls SET ended_early = true WHERE id = ${id} + `; + + return NextResponse.json({ message: "Poll ended" }); + } catch (error) { + console.error("[routes-f stream/polls/:id DELETE]", error); + return NextResponse.json({ error: "Failed to end poll" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/stream/polls/[id]/vote/route.ts b/app/api/routes-f/stream/polls/[id]/vote/route.ts new file mode 100644 index 00000000..f1875db5 --- /dev/null +++ b/app/api/routes-f/stream/polls/[id]/vote/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +const voteSchema = z.object({ + option_index: z.number().int().min(0), +}); + +async function ensurePollsTables() { + await sql` + CREATE TABLE IF NOT EXISTS stream_polls ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stream_id UUID NOT NULL, + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + question TEXT NOT NULL, + options JSONB NOT NULL, + duration_seconds INTEGER NOT NULL CHECK (duration_seconds BETWEEN 1 AND 300), + ends_at TIMESTAMPTZ NOT NULL, + ended_early BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + await sql` + CREATE TABLE IF NOT EXISTS stream_poll_votes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + poll_id UUID NOT NULL REFERENCES stream_polls(id) ON DELETE CASCADE, + voter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + option_index INTEGER NOT NULL, + voted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (poll_id, voter_id) + ) + `; +} + +/** POST /api/routes-f/stream/polls/[id]/vote — viewer casts a vote */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const { id } = await params; + + const bodyResult = await validateBody(req, voteSchema); + if (bodyResult instanceof Response) return bodyResult; + + const { option_index } = bodyResult.data; + + try { + await ensurePollsTables(); + + const { rows: pollRows } = await sql` + SELECT id, options, ends_at, ended_early + FROM stream_polls + WHERE id = ${id} + LIMIT 1 + `; + + if (pollRows.length === 0) { + return NextResponse.json({ error: "Poll not found" }, { status: 404 }); + } + + const poll = pollRows[0]; + + // Check poll is still active + if (poll.ended_early || new Date(poll.ends_at) <= new Date()) { + return NextResponse.json({ error: "Poll has ended" }, { status: 410 }); + } + + // Validate option_index is within bounds + const options: string[] = Array.isArray(poll.options) ? poll.options : JSON.parse(poll.options); + if (option_index < 0 || option_index >= options.length) { + return NextResponse.json( + { error: `option_index must be between 0 and ${options.length - 1}` }, + { status: 400 } + ); + } + + // Insert vote — UNIQUE constraint handles duplicate prevention + try { + await sql` + INSERT INTO stream_poll_votes (poll_id, voter_id, option_index) + VALUES (${id}, ${session.userId}, ${option_index}) + `; + } catch (err: unknown) { + const pgErr = err as { code?: string }; + if (pgErr?.code === "23505") { + return NextResponse.json({ error: "You have already voted on this poll" }, { status: 409 }); + } + throw err; + } + + return NextResponse.json({ message: "Vote recorded", option_index }, { status: 201 }); + } catch (error) { + console.error("[routes-f stream/polls/:id/vote POST]", error); + return NextResponse.json({ error: "Failed to cast vote" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/stream/polls/route.ts b/app/api/routes-f/stream/polls/route.ts new file mode 100644 index 00000000..db1ef1b2 --- /dev/null +++ b/app/api/routes-f/stream/polls/route.ts @@ -0,0 +1,140 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; + +const MAX_DURATION_SECONDS = 300; + +const pollsQuerySchema = z.object({ + stream_id: z.string().uuid(), +}); + +const createPollSchema = z.object({ + stream_id: z.string().uuid(), + question: z.string().trim().min(1).max(200), + options: z + .array(z.string().trim().min(1).max(60)) + .min(2, "At least 2 options required") + .max(4, "At most 4 options allowed"), + duration_seconds: z.number().int().min(1).max(MAX_DURATION_SECONDS), +}); + +async function ensurePollsTables() { + await sql` + CREATE TABLE IF NOT EXISTS stream_polls ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stream_id UUID NOT NULL, + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + question TEXT NOT NULL, + options JSONB NOT NULL, + duration_seconds INTEGER NOT NULL CHECK (duration_seconds BETWEEN 1 AND 300), + ends_at TIMESTAMPTZ NOT NULL, + ended_early BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + await sql` + CREATE TABLE IF NOT EXISTS stream_poll_votes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + poll_id UUID NOT NULL REFERENCES stream_polls(id) ON DELETE CASCADE, + voter_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + option_index INTEGER NOT NULL, + voted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (poll_id, voter_id) + ) + `; + await sql` + CREATE INDEX IF NOT EXISTS idx_stream_polls_stream + ON stream_polls (stream_id, created_at DESC) + `; + await sql` + CREATE INDEX IF NOT EXISTS idx_stream_poll_votes_poll + ON stream_poll_votes (poll_id, option_index) + `; +} + +function isPollActive(poll: { ends_at: string; ended_early: boolean }): boolean { + return !poll.ended_early && new Date(poll.ends_at) > new Date(); +} + +/** GET /api/routes-f/stream/polls?stream_id= — list active and recent polls (public) */ +export async function GET(req: NextRequest): Promise { + const queryResult = validateQuery(new URL(req.url).searchParams, pollsQuerySchema); + if (queryResult instanceof Response) return queryResult; + + const { stream_id } = queryResult.data; + + try { + await ensurePollsTables(); + + const { rows: polls } = await sql` + SELECT + p.id, + p.stream_id, + p.creator_id, + p.question, + p.options, + p.duration_seconds, + p.ends_at, + p.ended_early, + p.created_at, + COALESCE( + json_agg( + json_build_object('option_index', v.option_index, 'count', v.cnt) + ) FILTER (WHERE v.option_index IS NOT NULL), + '[]' + ) AS vote_counts + FROM stream_polls p + LEFT JOIN ( + SELECT poll_id, option_index, COUNT(*)::int AS cnt + FROM stream_poll_votes + GROUP BY poll_id, option_index + ) v ON v.poll_id = p.id + WHERE p.stream_id = ${stream_id} + GROUP BY p.id + ORDER BY p.created_at DESC + LIMIT 20 + `; + + return NextResponse.json({ polls }); + } catch (error) { + console.error("[routes-f stream/polls GET]", error); + return NextResponse.json({ error: "Failed to fetch polls" }, { status: 500 }); + } +} + +/** POST /api/routes-f/stream/polls — creator creates a poll */ +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const bodyResult = await validateBody(req, createPollSchema); + if (bodyResult instanceof Response) return bodyResult; + + const { stream_id, question, options, duration_seconds } = bodyResult.data; + + try { + await ensurePollsTables(); + + const ends_at = new Date(Date.now() + duration_seconds * 1000).toISOString(); + + const { rows } = await sql` + INSERT INTO stream_polls (stream_id, creator_id, question, options, duration_seconds, ends_at) + VALUES ( + ${stream_id}, + ${session.userId}, + ${question}, + ${JSON.stringify(options)}::jsonb, + ${duration_seconds}, + ${ends_at} + ) + RETURNING id, stream_id, creator_id, question, options, duration_seconds, ends_at, created_at + `; + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + console.error("[routes-f stream/polls POST]", error); + return NextResponse.json({ error: "Failed to create poll" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/stream/recap/route.ts b/app/api/routes-f/stream/recap/route.ts new file mode 100644 index 00000000..b1b7577e --- /dev/null +++ b/app/api/routes-f/stream/recap/route.ts @@ -0,0 +1,220 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { validateQuery } from "@/app/api/routes-f/_lib/validate"; + +const recapQuerySchema = z.object({ + stream_id: z.string().uuid(), +}); + +type StreamRow = { + id: string; + user_id: string; + peak_viewers: number | null; + total_messages: number | null; + started_at: string; + ended_at: string | null; +}; + +type RecapPayload = { + stream_id: string; + peak_viewers: number; + avg_viewers: number; + total_watch_minutes: number; + total_tips_received: string; + new_followers: number; + top_gifters: Array<{ + supporter_id: string; + username: string | null; + avatar: string | null; + total_amount_usdc: string; + }>; + chat_message_count: number; + stream_duration_seconds: number; + generated_at: string; +}; + +async function ensureRecapTable() { + await sql` + CREATE TABLE IF NOT EXISTS stream_recaps ( + stream_id UUID PRIMARY KEY REFERENCES stream_sessions(id) ON DELETE CASCADE, + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + recap JSONB NOT NULL, + generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_stream_recaps_creator_updated + ON stream_recaps (creator_id, updated_at DESC) + `; +} + +function secondsBetween(startedAt: string, endedAt: string): number { + const startMs = new Date(startedAt).getTime(); + const endMs = new Date(endedAt).getTime(); + if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs <= startMs) { + return 0; + } + return Math.floor((endMs - startMs) / 1000); +} + +export async function GET(req: NextRequest): Promise { + const queryResult = validateQuery( + new URL(req.url).searchParams, + recapQuerySchema + ); + if (queryResult instanceof Response) { + return queryResult; + } + + const { stream_id } = queryResult.data; + + try { + await ensureRecapTable(); + + const { rows: streamRows } = await sql` + SELECT id, user_id, peak_viewers, total_messages, started_at, ended_at + FROM stream_sessions + WHERE id = ${stream_id} + LIMIT 1 + `; + + if (streamRows.length === 0 || !streamRows[0].ended_at) { + return NextResponse.json( + { error: "Completed stream not found" }, + { status: 404 } + ); + } + + const stream = streamRows[0]; + const endedAt = String(stream.ended_at); + + const { rows: cachedRows } = await sql<{ recap: RecapPayload }>` + SELECT recap + FROM stream_recaps + WHERE stream_id = ${stream.id} + LIMIT 1 + `; + + if (cachedRows.length > 0) { + return NextResponse.json(cachedRows[0].recap, { + headers: { "Cache-Control": "public, max-age=86400" }, + }); + } + + const streamDurationSeconds = secondsBetween(stream.started_at, endedAt); + + const [watchResult, tipResult, followerResult, gifterResult, chatResult] = + await Promise.all([ + sql<{ watch_seconds: string | null }>` + SELECT COALESCE( + SUM( + GREATEST( + 0, + EXTRACT( + EPOCH FROM ( + LEAST(COALESCE(left_at, ${endedAt}), ${endedAt}) + - GREATEST(joined_at, ${stream.started_at}) + ) + ) + ) + ), + 0 + ) AS watch_seconds + FROM stream_viewers + WHERE stream_session_id = ${stream.id} + AND joined_at < ${endedAt} + `, + sql<{ total_tips_received: string | null }>` + SELECT COALESCE(SUM(amount_xlm), 0)::text AS total_tips_received + FROM tip_transactions + WHERE creator_id = ${stream.user_id} + AND created_at >= ${stream.started_at} + AND created_at <= ${endedAt} + `, + sql<{ new_followers: number | null }>` + SELECT COUNT(*)::int AS new_followers + FROM user_follows + WHERE followee_id = ${stream.user_id} + AND created_at >= ${stream.started_at} + AND created_at <= ${endedAt} + `, + sql<{ + supporter_id: string; + username: string | null; + avatar: string | null; + total_amount_usdc: string; + }>` + SELECT + gt.supporter_id, + u.username, + u.avatar, + COALESCE(SUM(gt.amount_usdc), 0)::text AS total_amount_usdc + FROM gift_transactions gt + LEFT JOIN users u ON u.id = gt.supporter_id + WHERE gt.creator_id = ${stream.user_id} + AND gt.created_at >= ${stream.started_at} + AND gt.created_at <= ${endedAt} + AND gt.supporter_id IS NOT NULL + GROUP BY gt.supporter_id, u.username, u.avatar + ORDER BY COALESCE(SUM(gt.amount_usdc), 0) DESC, gt.supporter_id ASC + LIMIT 5 + `, + sql<{ chat_count: number | null }>` + SELECT COUNT(*)::int AS chat_count + FROM chat_messages + WHERE stream_session_id = ${stream.id} + AND is_deleted = false + `, + ]); + + const watchRows = watchResult.rows; + const tipRows = tipResult.rows; + const followerRows = followerResult.rows; + const gifterRows = gifterResult.rows; + const chatRows = chatResult.rows; + + const watchSeconds = Number(watchRows[0]?.watch_seconds ?? 0); + const totalWatchMinutes = Math.round(watchSeconds / 60); + + const avgViewers = + streamDurationSeconds > 0 + ? Number((watchSeconds / streamDurationSeconds).toFixed(2)) + : 0; + + const recap: RecapPayload = { + stream_id: stream.id, + peak_viewers: Number(stream.peak_viewers ?? 0), + avg_viewers: avgViewers, + total_watch_minutes: totalWatchMinutes, + total_tips_received: tipRows[0]?.total_tips_received ?? "0", + new_followers: Number(followerRows[0]?.new_followers ?? 0), + top_gifters: gifterRows, + chat_message_count: Number( + stream.total_messages ?? chatRows[0]?.chat_count ?? 0 + ), + stream_duration_seconds: streamDurationSeconds, + generated_at: new Date().toISOString(), + }; + + await sql` + INSERT INTO stream_recaps (stream_id, creator_id, recap, generated_at, updated_at) + VALUES (${stream.id}, ${stream.user_id}, ${JSON.stringify(recap)}::jsonb, NOW(), NOW()) + ON CONFLICT (stream_id) DO UPDATE SET + recap = EXCLUDED.recap, + updated_at = NOW() + `; + + return NextResponse.json(recap, { + headers: { "Cache-Control": "public, max-age=86400" }, + }); + } catch (error) { + console.error("[routes-f stream recap GET]", error); + return NextResponse.json( + { error: "Failed to fetch stream recap" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/stream/recording/_lib/db.ts b/app/api/routes-f/stream/recording/_lib/db.ts new file mode 100644 index 00000000..0d05ad79 --- /dev/null +++ b/app/api/routes-f/stream/recording/_lib/db.ts @@ -0,0 +1,30 @@ +import { sql } from "@vercel/postgres"; + +export type RecordingVisibility = "public" | "unlisted" | "private"; + +export async function ensureRecordingSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS route_f_stream_recordings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + mux_playback_id VARCHAR(255) NOT NULL, + mux_asset_id VARCHAR(255), + title VARCHAR(255) NOT NULL, + visibility VARCHAR(20) NOT NULL DEFAULT 'private', + thumbnail_url TEXT, + duration_seconds INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_route_f_stream_recordings_user + ON route_f_stream_recordings (user_id, created_at DESC) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_route_f_stream_recordings_visibility + ON route_f_stream_recordings (visibility, created_at DESC) + `; +} diff --git a/app/api/routes-f/stream/recording/route.ts b/app/api/routes-f/stream/recording/route.ts new file mode 100644 index 00000000..447e957c --- /dev/null +++ b/app/api/routes-f/stream/recording/route.ts @@ -0,0 +1,213 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { uuidSchema, paginationSchema } from "@/app/api/routes-f/_lib/schemas"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { ensureRecordingSchema } from "./_lib/db"; + +const visibilitySchema = z.enum(["public", "unlisted", "private"]); + +const updateRecordingSchema = z.object({ + title: z.string().trim().min(1).max(255).optional(), + visibility: visibilitySchema.optional(), + thumbnail_url: z.string().url().optional(), +}); + +const deleteRecordingSchema = z.object({ + confirm: z.boolean().refine(v => v === true, { + message: "Must confirm deletion with confirm: true", + }), +}); + +const listRecordingsQuerySchema = paginationSchema; + +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { searchParams } = new URL(req.url); + const queryResult = validateQuery(searchParams, listRecordingsQuerySchema); + if (queryResult instanceof Response) { + return queryResult; + } + + try { + await ensureRecordingSchema(); + + const limit = queryResult.data.limit ?? 20; + const cursor = queryResult.data.cursor; + + let queryResult2; + + if (cursor) { + queryResult2 = await sql` + SELECT id, mux_playback_id, title, visibility, thumbnail_url, duration_seconds, created_at + FROM route_f_stream_recordings + WHERE user_id = ${session.userId} AND created_at < ( + SELECT created_at FROM route_f_stream_recordings WHERE id = ${cursor} + ) + ORDER BY created_at DESC + LIMIT ${limit + 1} + `; + } else { + queryResult2 = await sql` + SELECT id, mux_playback_id, title, visibility, thumbnail_url, duration_seconds, created_at + FROM route_f_stream_recordings + WHERE user_id = ${session.userId} + ORDER BY created_at DESC + LIMIT ${limit + 1} + `; + } + + const rows = queryResult2.rows; + const hasMore = rows.length > limit; + const recordings = rows.slice(0, limit); + const nextCursor = hasMore ? recordings[recordings.length - 1]?.id : null; + + return NextResponse.json({ + recordings: recordings.map((r: any) => ({ + id: r.id, + mux_playback_id: r.mux_playback_id, + title: r.title, + visibility: r.visibility, + thumbnail_url: r.thumbnail_url ?? null, + duration_seconds: r.duration_seconds ?? null, + created_at: r.created_at, + })), + next_cursor: nextCursor, + has_more: hasMore, + }); + } catch (error) { + console.error("[stream/recording] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function PATCH(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { pathname } = new URL(req.url); + const recordingId = pathname.split("/").pop(); + + if (!recordingId || !uuidSchema.safeParse(recordingId).success) { + return NextResponse.json( + { error: "Invalid recording ID" }, + { status: 400 } + ); + } + + const bodyResult = await validateBody(req, updateRecordingSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + try { + await ensureRecordingSchema(); + + // Verify ownership + const { rows: ownerRows } = await sql` + SELECT id FROM route_f_stream_recordings + WHERE id = ${recordingId} AND user_id = ${session.userId} + LIMIT 1 + `; + + if (ownerRows.length === 0) { + return NextResponse.json( + { error: "Recording not found or unauthorized" }, + { status: 404 } + ); + } + + const { title, visibility, thumbnail_url } = bodyResult.data; + + const { rows } = await sql` + UPDATE route_f_stream_recordings + SET + title = COALESCE(${title ?? null}, title), + visibility = COALESCE(${visibility ?? null}, visibility), + thumbnail_url = COALESCE(${thumbnail_url ?? null}, thumbnail_url), + updated_at = now() + WHERE id = ${recordingId} + RETURNING id, mux_playback_id, title, visibility, thumbnail_url, duration_seconds, created_at + `; + + const updated = rows[0]; + return NextResponse.json({ + id: updated.id, + mux_playback_id: updated.mux_playback_id, + title: updated.title, + visibility: updated.visibility, + thumbnail_url: updated.thumbnail_url ?? null, + duration_seconds: updated.duration_seconds ?? null, + created_at: updated.created_at, + }); + } catch (error) { + console.error("[stream/recording] PATCH error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function DELETE(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { pathname } = new URL(req.url); + const recordingId = pathname.split("/").pop(); + + if (!recordingId || !uuidSchema.safeParse(recordingId).success) { + return NextResponse.json( + { error: "Invalid recording ID" }, + { status: 400 } + ); + } + + const bodyResult = await validateBody(req, deleteRecordingSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + try { + await ensureRecordingSchema(); + + // Verify ownership + const { rows: ownerRows } = await sql` + SELECT id FROM route_f_stream_recordings + WHERE id = ${recordingId} AND user_id = ${session.userId} + LIMIT 1 + `; + + if (ownerRows.length === 0) { + return NextResponse.json( + { error: "Recording not found or unauthorized" }, + { status: 404 } + ); + } + + await sql` + DELETE FROM route_f_stream_recordings + WHERE id = ${recordingId} + `; + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[stream/recording] DELETE error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/stream/rerun/[id]/route.ts b/app/api/routes-f/stream/rerun/[id]/route.ts new file mode 100644 index 00000000..0204bbe6 --- /dev/null +++ b/app/api/routes-f/stream/rerun/[id]/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +async function ensureRerunsTable() { + await sql` + CREATE TABLE IF NOT EXISTS stream_reruns ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + recording_id VARCHAR(255) NOT NULL, + scheduled_at TIMESTAMPTZ NOT NULL, + notify_followers BOOLEAN NOT NULL DEFAULT false, + notified BOOLEAN NOT NULL DEFAULT false, + cancelled BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; +} + +/** DELETE /api/routes-f/stream/rerun/[id] — cancel a scheduled rerun */ +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const { id } = await params; + + try { + await ensureRerunsTable(); + + const { rows } = await sql` + SELECT id, creator_id, cancelled, scheduled_at + FROM stream_reruns + WHERE id = ${id} + LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ error: "Rerun not found" }, { status: 404 }); + } + + const rerun = rows[0]; + + if (String(rerun.creator_id) !== session.userId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + if (rerun.cancelled) { + return NextResponse.json({ error: "Rerun already cancelled" }, { status: 409 }); + } + + await sql` + UPDATE stream_reruns SET cancelled = true WHERE id = ${id} + `; + + return NextResponse.json({ message: "Rerun cancelled" }); + } catch (error) { + console.error("[routes-f stream/rerun/:id DELETE]", error); + return NextResponse.json({ error: "Failed to cancel rerun" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/stream/rerun/route.ts b/app/api/routes-f/stream/rerun/route.ts new file mode 100644 index 00000000..f4330d29 --- /dev/null +++ b/app/api/routes-f/stream/rerun/route.ts @@ -0,0 +1,125 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; + +const MAX_SCHEDULED_RERUNS = 3; + +const scheduleRerunSchema = z.object({ + recording_id: z.string().min(1).max(255), + scheduled_at: z.string().datetime({ message: "scheduled_at must be an ISO 8601 datetime" }), + notify_followers: z.boolean().default(false), +}); + +async function ensureRerunsTable() { + await sql` + CREATE TABLE IF NOT EXISTS stream_reruns ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + recording_id VARCHAR(255) NOT NULL, + scheduled_at TIMESTAMPTZ NOT NULL, + notify_followers BOOLEAN NOT NULL DEFAULT false, + notified BOOLEAN NOT NULL DEFAULT false, + cancelled BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + await sql` + CREATE INDEX IF NOT EXISTS idx_stream_reruns_creator + ON stream_reruns (creator_id, scheduled_at ASC) + WHERE cancelled = false + `; +} + +/** GET /api/routes-f/stream/rerun — list scheduled reruns for authenticated creator */ +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + try { + await ensureRerunsTable(); + + const { rows } = await sql` + SELECT id, recording_id, scheduled_at, notify_followers, notified, created_at + FROM stream_reruns + WHERE creator_id = ${session.userId} + AND cancelled = false + ORDER BY scheduled_at ASC + `; + + return NextResponse.json({ reruns: rows }); + } catch (error) { + console.error("[routes-f stream/rerun GET]", error); + return NextResponse.json({ error: "Failed to fetch reruns" }, { status: 500 }); + } +} + +/** POST /api/routes-f/stream/rerun — schedule a rerun */ +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) return session.response; + + const bodyResult = await validateBody(req, scheduleRerunSchema); + if (bodyResult instanceof Response) return bodyResult; + + const { recording_id, scheduled_at, notify_followers } = bodyResult.data; + + // Validate scheduled_at is in the future + const scheduledDate = new Date(scheduled_at); + if (scheduledDate <= new Date()) { + return NextResponse.json( + { error: "scheduled_at must be in the future" }, + { status: 400 } + ); + } + + try { + await ensureRerunsTable(); + + // Enforce max 3 active reruns + const { rows: countRows } = await sql` + SELECT COUNT(*)::int AS total + FROM stream_reruns + WHERE creator_id = ${session.userId} + AND cancelled = false + AND scheduled_at > now() + `; + + const count = Number(countRows[0]?.total ?? 0); + if (count >= MAX_SCHEDULED_RERUNS) { + return NextResponse.json( + { error: `Maximum of ${MAX_SCHEDULED_RERUNS} scheduled reruns allowed at once` }, + { status: 400 } + ); + } + + const { rows } = await sql` + INSERT INTO stream_reruns (creator_id, recording_id, scheduled_at, notify_followers) + VALUES (${session.userId}, ${recording_id}, ${scheduled_at}, ${notify_followers}) + RETURNING id, recording_id, scheduled_at, notify_followers, notified, created_at + `; + + const rerun = rows[0]; + + // Queue notification job if requested + if (notify_followers) { + await sql` + INSERT INTO notification_jobs (type, payload, created_at) + VALUES ( + 'rerun_scheduled', + ${JSON.stringify({ rerun_id: rerun.id, creator_id: session.userId, scheduled_at })}::jsonb, + now() + ) + `.catch(() => { + // notification_jobs table may not exist yet — non-fatal + console.warn("[routes-f stream/rerun] Could not queue notification job"); + }); + } + + return NextResponse.json(rerun, { status: 201 }); + } catch (error) { + console.error("[routes-f stream/rerun POST]", error); + return NextResponse.json({ error: "Failed to schedule rerun" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/stream/settings/route.ts b/app/api/routes-f/stream/settings/route.ts new file mode 100644 index 00000000..16c2adac --- /dev/null +++ b/app/api/routes-f/stream/settings/route.ts @@ -0,0 +1,194 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +// ── Validation constants ────────────────────────────────────────────────────── +const TITLE_MAX = 140; +const TAGS_MAX_COUNT = 5; +const TAG_MAX_LEN = 25; +const VALID_SLOW_MODES = [0, 3, 10, 30, 60] as const; + +// ── GET /api/routes-f/stream/settings ──────────────────────────────────────── +// Returns current stream settings for the authenticated creator. +export async function GET(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) return session.response; + + try { + const { rows } = await sql` + SELECT + creator, + slow_mode_seconds, + is_live + FROM users + WHERE id = ${session.userId} + LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const { creator = {}, slow_mode_seconds, is_live } = rows[0]; + + return NextResponse.json({ + settings: { + title: creator.streamTitle ?? "", + category: creator.category ?? "", + tags: creator.tags ?? [], + mature_content: creator.mature_content ?? false, + chat_enabled: creator.chat_enabled ?? true, + slow_mode_seconds: slow_mode_seconds ?? 0, + }, + is_live, + }); + } catch (err) { + console.error("[stream/settings:GET] error:", err); + return NextResponse.json( + { error: "Failed to fetch settings" }, + { status: 500 } + ); + } +} + +// ── PATCH /api/routes-f/stream/settings ────────────────────────────────────── +// Update stream settings. All fields are optional — only provided fields change. +// Changes take effect immediately (live or not). +export async function PATCH(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) return session.response; + + let body: { + title?: string; + category?: string; + tags?: string[]; + mature_content?: boolean; + chat_enabled?: boolean; + slow_mode_seconds?: number; + }; + + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { + title, + category, + tags, + mature_content, + chat_enabled, + slow_mode_seconds, + } = body; + + // ── Validation ────────────────────────────────────────────────────────────── + if (title !== undefined) { + if (typeof title !== "string" || title.length > TITLE_MAX) { + return NextResponse.json( + { error: `title must be a string of at most ${TITLE_MAX} characters` }, + { status: 400 } + ); + } + } + + if (tags !== undefined) { + if (!Array.isArray(tags) || tags.length > TAGS_MAX_COUNT) { + return NextResponse.json( + { error: `tags must be an array of at most ${TAGS_MAX_COUNT} items` }, + { status: 400 } + ); + } + const badTag = tags.find( + t => typeof t !== "string" || t.length > TAG_MAX_LEN + ); + if (badTag !== undefined) { + return NextResponse.json( + { + error: `each tag must be a string of at most ${TAG_MAX_LEN} characters`, + }, + { status: 400 } + ); + } + } + + if (slow_mode_seconds !== undefined) { + if (!(VALID_SLOW_MODES as readonly number[]).includes(slow_mode_seconds)) { + return NextResponse.json( + { + error: `slow_mode_seconds must be one of: ${VALID_SLOW_MODES.join(", ")}`, + }, + { status: 400 } + ); + } + } + + if (mature_content !== undefined && typeof mature_content !== "boolean") { + return NextResponse.json( + { error: "mature_content must be a boolean" }, + { status: 400 } + ); + } + + if (chat_enabled !== undefined && typeof chat_enabled !== "boolean") { + return NextResponse.json( + { error: "chat_enabled must be a boolean" }, + { status: 400 } + ); + } + + // ── Fetch current state ───────────────────────────────────────────────────── + try { + const { rows } = await sql` + SELECT creator, slow_mode_seconds FROM users WHERE id = ${session.userId} LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const current = rows[0]; + const currentCreator = current.creator ?? {}; + + // Merge only provided fields + const updatedCreator = { + ...currentCreator, + ...(title !== undefined && { streamTitle: title }), + ...(category !== undefined && { category }), + ...(tags !== undefined && { tags }), + ...(mature_content !== undefined && { mature_content }), + ...(chat_enabled !== undefined && { chat_enabled }), + }; + + const newSlowMode = + slow_mode_seconds !== undefined + ? slow_mode_seconds + : current.slow_mode_seconds; + + await sql` + UPDATE users SET + creator = ${JSON.stringify(updatedCreator)}, + slow_mode_seconds = ${newSlowMode}, + updated_at = CURRENT_TIMESTAMP + WHERE id = ${session.userId} + `; + + return NextResponse.json({ + ok: true, + settings: { + title: updatedCreator.streamTitle ?? "", + category: updatedCreator.category ?? "", + tags: updatedCreator.tags ?? [], + mature_content: updatedCreator.mature_content ?? false, + chat_enabled: updatedCreator.chat_enabled ?? true, + slow_mode_seconds: newSlowMode, + }, + }); + } catch (err) { + console.error("[stream/settings:PATCH] error:", err); + return NextResponse.json( + { error: "Failed to update settings" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/stream/soundtrack/_lib/db.ts b/app/api/routes-f/stream/soundtrack/_lib/db.ts new file mode 100644 index 00000000..b3db5fb8 --- /dev/null +++ b/app/api/routes-f/stream/soundtrack/_lib/db.ts @@ -0,0 +1,51 @@ +import { sql } from "@vercel/postgres"; + +export async function ensureSoundtrackSchema(): Promise { + // Soundtrack catalog table + await sql` + CREATE TABLE IF NOT EXISTS route_f_soundtrack_catalog ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(255) NOT NULL, + artist VARCHAR(255) NOT NULL, + duration_seconds INT NOT NULL, + url TEXT NOT NULL, + royalty_free BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + + // Stream now playing table + await sql` + CREATE TABLE IF NOT EXISTS route_f_stream_now_playing ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stream_id UUID NOT NULL UNIQUE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + track_id UUID NOT NULL REFERENCES route_f_soundtrack_catalog(id) ON DELETE CASCADE, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + + // Stream playlist table + await sql` + CREATE TABLE IF NOT EXISTS route_f_stream_playlists ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stream_id UUID NOT NULL, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + track_id UUID NOT NULL REFERENCES route_f_soundtrack_catalog(id) ON DELETE CASCADE, + position INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(stream_id, position) + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_route_f_stream_now_playing_stream + ON route_f_stream_now_playing (stream_id) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_route_f_stream_playlists_stream + ON route_f_stream_playlists (stream_id, position) + `; +} diff --git a/app/api/routes-f/stream/soundtrack/route.ts b/app/api/routes-f/stream/soundtrack/route.ts new file mode 100644 index 00000000..47d7e871 --- /dev/null +++ b/app/api/routes-f/stream/soundtrack/route.ts @@ -0,0 +1,255 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { uuidSchema } from "@/app/api/routes-f/_lib/schemas"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { ensureSoundtrackSchema } from "./_lib/db"; + +const streamIdSchema = z.object({ + stream_id: uuidSchema, +}); + +const playTrackSchema = z.object({ + track_id: uuidSchema, + stream_id: uuidSchema, +}); + +const skipTrackSchema = z.object({ + stream_id: uuidSchema, +}); + +const stopPlaybackSchema = z.object({ + stream_id: uuidSchema, +}); + +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { searchParams } = new URL(req.url); + const queryResult = validateQuery(searchParams, streamIdSchema); + if (queryResult instanceof Response) { + return queryResult; + } + + try { + await ensureSoundtrackSchema(); + + const { stream_id } = queryResult.data; + + // Get now playing track + const { rows: nowPlayingRows } = await sql` + SELECT + np.id, + np.track_id, + sc.title, + sc.artist, + sc.duration_seconds, + np.started_at + FROM route_f_stream_now_playing np + JOIN route_f_soundtrack_catalog sc ON np.track_id = sc.id + WHERE np.stream_id = ${stream_id} AND np.user_id = ${session.userId} + LIMIT 1 + `; + + // Get playlist + const { rows: playlistRows } = await sql` + SELECT + sp.id, + sp.track_id, + sp.position, + sc.title, + sc.artist, + sc.duration_seconds + FROM route_f_stream_playlists sp + JOIN route_f_soundtrack_catalog sc ON sp.track_id = sc.id + WHERE sp.stream_id = ${stream_id} AND sp.user_id = ${session.userId} + ORDER BY sp.position ASC + `; + + return NextResponse.json({ + now_playing: nowPlayingRows.length > 0 ? { + track_id: nowPlayingRows[0].track_id, + title: nowPlayingRows[0].title, + artist: nowPlayingRows[0].artist, + duration_seconds: nowPlayingRows[0].duration_seconds, + started_at: nowPlayingRows[0].started_at, + } : null, + playlist: playlistRows.map(row => ({ + id: row.id, + track_id: row.track_id, + position: row.position, + title: row.title, + artist: row.artist, + duration_seconds: row.duration_seconds, + })), + }); + } catch (error) { + console.error("[stream/soundtrack] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, playTrackSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + try { + await ensureSoundtrackSchema(); + + const { track_id, stream_id } = bodyResult.data; + + // Verify track exists + const { rows: trackRows } = await sql` + SELECT id FROM route_f_soundtrack_catalog WHERE id = ${track_id} LIMIT 1 + `; + + if (trackRows.length === 0) { + return NextResponse.json( + { error: "Track not found" }, + { status: 404 } + ); + } + + // Upsert now playing + const { rows } = await sql` + INSERT INTO route_f_stream_now_playing (stream_id, user_id, track_id, started_at, updated_at) + VALUES (${stream_id}, ${session.userId}, ${track_id}, now(), now()) + ON CONFLICT (stream_id) DO UPDATE SET + track_id = EXCLUDED.track_id, + started_at = now(), + updated_at = now() + RETURNING track_id, started_at + `; + + return NextResponse.json({ + track_id: rows[0].track_id, + started_at: rows[0].started_at, + }); + } catch (error) { + console.error("[stream/soundtrack] POST play error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function PUT(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { pathname } = new URL(req.url); + const action = pathname.split("/").pop(); + + if (action === "skip") { + const bodyResult = await validateBody(req, skipTrackSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + try { + await ensureSoundtrackSchema(); + + const { stream_id } = bodyResult.data; + + // Get current position in playlist + const { rows: currentRows } = await sql` + SELECT np.track_id, sp.position + FROM route_f_stream_now_playing np + LEFT JOIN route_f_stream_playlists sp ON sp.track_id = np.track_id AND sp.stream_id = np.stream_id + WHERE np.stream_id = ${stream_id} AND np.user_id = ${session.userId} + LIMIT 1 + `; + + if (currentRows.length === 0) { + return NextResponse.json( + { error: "No track currently playing" }, + { status: 400 } + ); + } + + const currentPosition = currentRows[0].position ?? 0; + + // Get next track + const { rows: nextRows } = await sql` + SELECT track_id FROM route_f_stream_playlists + WHERE stream_id = ${stream_id} AND user_id = ${session.userId} AND position > ${currentPosition} + ORDER BY position ASC + LIMIT 1 + `; + + if (nextRows.length === 0) { + return NextResponse.json( + { error: "No next track in playlist" }, + { status: 400 } + ); + } + + // Update now playing + const { rows } = await sql` + UPDATE route_f_stream_now_playing + SET track_id = ${nextRows[0].track_id}, started_at = now(), updated_at = now() + WHERE stream_id = ${stream_id} + RETURNING track_id, started_at + `; + + return NextResponse.json({ + track_id: rows[0].track_id, + started_at: rows[0].started_at, + }); + } catch (error) { + console.error("[stream/soundtrack] PUT skip error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + } + + if (action === "stop") { + const bodyResult = await validateBody(req, stopPlaybackSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + try { + await ensureSoundtrackSchema(); + + const { stream_id } = bodyResult.data; + + await sql` + DELETE FROM route_f_stream_now_playing + WHERE stream_id = ${stream_id} AND user_id = ${session.userId} + `; + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[stream/soundtrack] PUT stop error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + } + + return NextResponse.json( + { error: "Invalid action" }, + { status: 400 } + ); +} diff --git a/app/api/routes-f/subscriptions/[id]/route.ts b/app/api/routes-f/subscriptions/[id]/route.ts new file mode 100644 index 00000000..496f5cbb --- /dev/null +++ b/app/api/routes-f/subscriptions/[id]/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession, assertOwnership } from "@/lib/auth/verify-session"; +import { ensureRoutesFSchema } from "../../_lib/schema"; + +/** + * DELETE /api/routes-f/subscriptions/[id] — cancel subscription + */ +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + await ensureRoutesFSchema(); + const session = await verifySession(req); + if (!session.ok) return session.response; + + const { id } = await params; + + const { rows } = await sql` + SELECT user_id, status FROM subscriptions WHERE id = ${id} + LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json( + { error: "Subscription not found" }, + { status: 404 } + ); + } + + const subscription = rows[0]; + const ownershipError = assertOwnership(session, null, subscription.user_id); + if (ownershipError) return ownershipError; + + if (subscription.status === "cancelled") { + return NextResponse.json({ message: "Subscription already cancelled" }); + } + + await sql` + UPDATE subscriptions SET status = 'cancelled' WHERE id = ${id} + `; + + return NextResponse.json({ + message: "Subscription cancelled successfully", + }); + } catch (error) { + console.error("Subscription DELETE error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/subscriptions/route.ts b/app/api/routes-f/subscriptions/route.ts new file mode 100644 index 00000000..74f9e96d --- /dev/null +++ b/app/api/routes-f/subscriptions/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { ensureRoutesFSchema } from "../_lib/schema"; + +/** + * Subscriptions management endpoint. + */ + +// GET /api/routes-f/subscriptions — list active subscribers for authenticated creator +export async function GET(req: NextRequest) { + try { + await ensureRoutesFSchema(); + const session = await verifySession(req); + if (!session.ok) return session.response; + + // List active subscribers for authenticated creator + const { rows } = await sql` + SELECT s.id, u.username, u.avatar, s.created_at, s.expires_at, u.wallet + FROM subscriptions s + JOIN users u ON s.user_id = u.id + WHERE s.creator_id = ${session.userId} + AND s.status = 'active' + AND s.expires_at > NOW() + ORDER BY s.created_at DESC + `; + + const subscriberCount = rows.length; + // Assume 5 USDC flat fee for MRR + const mrrEstimate = subscriberCount * 5; + + return NextResponse.json({ + subscribers: rows, + count: subscriberCount, + mrr_estimate: mrrEstimate + }); + } catch (error) { + console.error("Subscription GET error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} + +// POST /api/routes-f/subscriptions — subscribe to a creator +export async function POST(req: NextRequest) { + try { + await ensureRoutesFSchema(); + const session = await verifySession(req); + if (!session.ok) return session.response; + + const { creator_id } = await req.json(); + if (!creator_id) { + return NextResponse.json({ error: "creator_id is required" }, { status: 400 }); + } + + // Check for existing active subscription + const existing = await sql` + SELECT id FROM subscriptions + WHERE user_id = ${session.userId} + AND creator_id = ${creator_id} + AND status = 'active' + AND expires_at > NOW() + LIMIT 1 + `; + + if (existing.rows.length > 0) { + return NextResponse.json({ error: "Already subscribed" }, { status: 409 }); + } + + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 30); // 30 days + + const { rows } = await sql` + INSERT INTO subscriptions (user_id, creator_id, status, expires_at) + VALUES (${session.userId}, ${creator_id}, 'active', ${expiresAt.toISOString()}) + RETURNING * + `; + + return NextResponse.json(rows[0]); + } catch (error) { + console.error("Subscription POST error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/subscriptions/status/route.ts b/app/api/routes-f/subscriptions/status/route.ts new file mode 100644 index 00000000..23ca7d60 --- /dev/null +++ b/app/api/routes-f/subscriptions/status/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { ensureRoutesFSchema } from "../../_lib/schema"; + +/** + * GET /api/routes-f/subscriptions/status?creator= — viewer checks own subscription status to a creator + */ +export async function GET(req: NextRequest) { + try { + await ensureRoutesFSchema(); + const session = await verifySession(req); + if (!session.ok) return session.response; + + const { searchParams } = new URL(req.url); + const creatorId = searchParams.get("creator"); + + if (!creatorId) { + return NextResponse.json({ error: "creator query param is required" }, { status: 400 }); + } + + // Check subscription status to a specific creator + const { rows } = await sql` + SELECT id, expires_at, status + FROM subscriptions + WHERE user_id = ${session.userId} + AND creator_id = ${creatorId} + AND status = 'active' + AND expires_at > NOW() + LIMIT 1 + `; + + return NextResponse.json({ + is_subscribed: rows.length > 0, + subscription: rows[0] || null + }); + } catch (error) { + console.error("Subscription status GET error:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/app/api/routes-f/templates/[id]/route.ts b/app/api/routes-f/templates/[id]/route.ts new file mode 100644 index 00000000..4e46d572 --- /dev/null +++ b/app/api/routes-f/templates/[id]/route.ts @@ -0,0 +1,141 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { ensureTemplatesSchema } from "../_lib/db"; + +const updateTemplateSchema = z + .object({ + name: z.string().trim().min(1).max(50).optional(), + title: z.string().trim().min(1).max(140).optional(), + category: z.string().trim().max(80).optional(), + tags: z.array(z.string().trim().min(1).max(25)).max(5).optional(), + description: z.string().trim().max(500).optional(), + }) + .refine(value => Object.keys(value).length > 0, { + message: "At least one field must be provided", + }); + +type RouteContext = { + params: Promise<{ id: string }>; +}; + +export async function PATCH( + req: NextRequest, + { params }: RouteContext +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, updateTemplateSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { id } = await params; + const { name, title, category, tags, description } = bodyResult.data; + + try { + await ensureTemplatesSchema(); + + const { rows: existingRows } = await sql` + SELECT id, name, title, category, tags, description + FROM route_f_stream_templates + WHERE id = ${id} AND user_id = ${session.userId} + LIMIT 1 + `; + + if (existingRows.length === 0) { + return NextResponse.json( + { error: "Template not found" }, + { status: 404 } + ); + } + + if (name) { + const { rows: duplicateRows } = await sql` + SELECT id + FROM route_f_stream_templates + WHERE user_id = ${session.userId} + AND LOWER(name) = LOWER(${name}) + AND id <> ${id} + LIMIT 1 + `; + + if (duplicateRows.length > 0) { + return NextResponse.json( + { error: "Template name must be unique per creator" }, + { status: 409 } + ); + } + } + + const existing = existingRows[0]; + const nextName = name ?? existing.name; + const nextTitle = title ?? existing.title; + const nextCategory = category ?? existing.category; + const nextTags = tags ?? existing.tags ?? []; + const nextDescription = description ?? existing.description; + + const { rows } = await sql` + UPDATE route_f_stream_templates + SET + name = ${nextName}, + title = ${nextTitle}, + category = ${nextCategory || null}, + tags = ${JSON.stringify(nextTags)}, + description = ${nextDescription || null}, + updated_at = now() + WHERE id = ${id} AND user_id = ${session.userId} + RETURNING id, user_id, name, title, category, tags, description, created_at, updated_at + `; + + return NextResponse.json(rows[0]); + } catch (error) { + console.error("[templates] PATCH error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function DELETE( + req: NextRequest, + { params }: RouteContext +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { id } = await params; + + try { + await ensureTemplatesSchema(); + + const { rows } = await sql` + DELETE FROM route_f_stream_templates + WHERE id = ${id} AND user_id = ${session.userId} + RETURNING id + `; + + if (rows.length === 0) { + return NextResponse.json( + { error: "Template not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ deleted: true, id }); + } catch (error) { + console.error("[templates] DELETE error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/templates/__tests__/route.test.ts b/app/api/routes-f/templates/__tests__/route.test.ts new file mode 100644 index 00000000..3be80787 --- /dev/null +++ b/app/api/routes-f/templates/__tests__/route.test.ts @@ -0,0 +1,144 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +jest.mock("../_lib/db", () => ({ + ensureTemplatesSchema: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock("../../templates/_lib/db", () => ({ + ensureTemplatesSchema: jest.fn().mockResolvedValue(undefined), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { GET, POST } from "../route"; +import { PATCH, DELETE } from "../[id]/route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; +const TEMPLATE_ID = "550e8400-e29b-41d4-a716-446655440000"; + +function makeRequest(method: string, path: string, body?: object) { + return new Request(`http://localhost${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; +} + +describe("routes-f templates", () => { + beforeEach(() => { + jest.clearAllMocks(); + verifySessionMock.mockResolvedValue({ + ok: true, + userId: "user-id", + wallet: null, + privyId: "did:privy:abc", + username: "alice", + email: "alice@example.com", + }); + jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("lists saved templates for the authenticated creator", async () => { + sqlMock.mockResolvedValueOnce({ + rows: [{ id: TEMPLATE_ID, name: "Default setup" }], + }); + + const res = await GET(makeRequest("GET", "/api/routes-f/templates")); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.templates).toHaveLength(1); + }); + + it("rejects creating more than 10 templates", async () => { + sqlMock.mockResolvedValueOnce({ + rows: [{ template_count: 10 }], + }); + + const res = await POST( + makeRequest("POST", "/api/routes-f/templates", { + name: "Default setup", + title: "My stream", + category: "Tech", + tags: ["nextjs"], + description: "Test", + }) + ); + + expect(res.status).toBe(409); + }); + + it("updates a template", async () => { + sqlMock + .mockResolvedValueOnce({ + rows: [ + { + id: TEMPLATE_ID, + name: "Default setup", + title: "Old title", + category: "Tech", + tags: ["nextjs"], + description: "Old", + }, + ], + }) + .mockResolvedValueOnce({ + rows: [ + { + id: TEMPLATE_ID, + user_id: "user-id", + name: "Default setup", + title: "New title", + category: "Tech", + tags: ["nextjs"], + description: "Old", + created_at: "2026-03-28T00:00:00Z", + updated_at: "2026-03-28T01:00:00Z", + }, + ], + }); + + const res = await PATCH( + makeRequest("PATCH", `/api/routes-f/templates/${TEMPLATE_ID}`, { + title: "New title", + }), + { params: Promise.resolve({ id: TEMPLATE_ID }) } + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.title).toBe("New title"); + }); + + it("deletes a template", async () => { + sqlMock.mockResolvedValueOnce({ rows: [{ id: TEMPLATE_ID }] }); + + const res = await DELETE( + makeRequest("DELETE", `/api/routes-f/templates/${TEMPLATE_ID}`), + { params: Promise.resolve({ id: TEMPLATE_ID }) } + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.deleted).toBe(true); + }); +}); diff --git a/app/api/routes-f/templates/_lib/db.ts b/app/api/routes-f/templates/_lib/db.ts new file mode 100644 index 00000000..c5cc00c8 --- /dev/null +++ b/app/api/routes-f/templates/_lib/db.ts @@ -0,0 +1,27 @@ +import { sql } from "@vercel/postgres"; + +export async function ensureTemplatesSchema(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS route_f_stream_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(50) NOT NULL, + title VARCHAR(140) NOT NULL, + category VARCHAR(80), + tags JSONB NOT NULL DEFAULT '[]'::jsonb, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + + await sql` + CREATE UNIQUE INDEX IF NOT EXISTS idx_route_f_stream_templates_user_name + ON route_f_stream_templates (user_id, LOWER(name)) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_route_f_stream_templates_user_created + ON route_f_stream_templates (user_id, created_at DESC) + `; +} diff --git a/app/api/routes-f/templates/route.ts b/app/api/routes-f/templates/route.ts new file mode 100644 index 00000000..975e0fd5 --- /dev/null +++ b/app/api/routes-f/templates/route.ts @@ -0,0 +1,122 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { ensureTemplatesSchema } from "./_lib/db"; + +const TAG_LIMIT = 5; +const TAG_LENGTH_LIMIT = 25; +const TEMPLATE_LIMIT = 10; + +const tagsSchema = z + .array(z.string().trim().min(1).max(TAG_LENGTH_LIMIT)) + .max(TAG_LIMIT); + +const createTemplateSchema = z.object({ + name: z.string().trim().min(1).max(50), + title: z.string().trim().min(1).max(140), + category: z.string().trim().max(80).optional().default(""), + tags: tagsSchema.optional().default([]), + description: z.string().trim().max(500).optional().default(""), +}); + +export async function GET(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + await ensureTemplatesSchema(); + + const { rows } = await sql` + SELECT id, name, title, category, tags, description, created_at, updated_at + FROM route_f_stream_templates + WHERE user_id = ${session.userId} + ORDER BY created_at DESC + `; + + return NextResponse.json({ templates: rows }); + } catch (error) { + console.error("[templates] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, createTemplateSchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { name, title, category, tags, description } = bodyResult.data; + + try { + await ensureTemplatesSchema(); + + const { rows: countRows } = await sql` + SELECT COUNT(*)::int AS template_count + FROM route_f_stream_templates + WHERE user_id = ${session.userId} + `; + + if (Number(countRows[0]?.template_count ?? 0) >= TEMPLATE_LIMIT) { + return NextResponse.json( + { error: `Creators may only have ${TEMPLATE_LIMIT} templates` }, + { status: 409 } + ); + } + + const { rows: duplicateRows } = await sql` + SELECT id + FROM route_f_stream_templates + WHERE user_id = ${session.userId} + AND LOWER(name) = LOWER(${name}) + LIMIT 1 + `; + + if (duplicateRows.length > 0) { + return NextResponse.json( + { error: "Template name must be unique per creator" }, + { status: 409 } + ); + } + + const { rows } = await sql` + INSERT INTO route_f_stream_templates ( + user_id, + name, + title, + category, + tags, + description + ) + VALUES ( + ${session.userId}, + ${name}, + ${title}, + ${category || null}, + ${JSON.stringify(tags)}, + ${description || null} + ) + RETURNING id, user_id, name, title, category, tags, description, created_at, updated_at + `; + + return NextResponse.json(rows[0], { status: 201 }); + } catch (error) { + console.error("[templates] POST error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/translations/locales/route.ts b/app/api/routes-f/translations/locales/route.ts new file mode 100644 index 00000000..be61eb57 --- /dev/null +++ b/app/api/routes-f/translations/locales/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; + +/** + * GET /api/routes-f/translations/locales + * Returns a list of all locale codes that have at least one translation entry. + */ +export async function GET(): Promise { + try { + const { rows } = await sql` + SELECT DISTINCT locale + FROM route_f_translations + ORDER BY locale ASC + `; + + return NextResponse.json( + { locales: rows.map(r => r.locale) }, + { + headers: { + "Cache-Control": "public, max-age=3600", + }, + } + ); + } catch (error) { + console.error("[translations/locales] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/translations/route.ts b/app/api/routes-f/translations/route.ts new file mode 100644 index 00000000..1c758816 --- /dev/null +++ b/app/api/routes-f/translations/route.ts @@ -0,0 +1,127 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody, validateQuery } from "@/app/api/routes-f/_lib/validate"; + +/** + * GET /api/routes-f/translations?locale=en + * Returns all translation key/value pairs for the given locale. + * Defaults to "en" if locale not found. + * Response is served with Cache-Control: public, max-age=3600. + * + * POST /api/routes-f/translations + * Upserts a translation key/value pair. Admin-only. + * Body: { locale: string; key: string; value: string } + */ + +async function ensureTranslationsSchema() { + await sql` + CREATE TABLE IF NOT EXISTS route_f_translations ( + id BIGSERIAL PRIMARY KEY, + locale TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (locale, key) + ) + `; + await sql` + CREATE INDEX IF NOT EXISTS route_f_translations_locale_idx + ON route_f_translations (locale) + `; +} + +const getQuerySchema = z.object({ + locale: z.string().min(2).max(10).default("en"), +}); + +const upsertBodySchema = z.object({ + locale: z.string().min(2).max(10), + key: z.string().min(1).max(255), + value: z.string().min(0), +}); + +export async function GET(req: NextRequest): Promise { + const { searchParams } = new URL(req.url); + const queryResult = validateQuery(searchParams, getQuerySchema); + if (queryResult instanceof Response) return queryResult; + + const { locale } = queryResult.data; + + try { + await ensureTranslationsSchema(); + + // Try requested locale; fall back to "en" if empty + let { rows } = await sql` + SELECT key, value + FROM route_f_translations + WHERE locale = ${locale} + ORDER BY key ASC + `; + + if (rows.length === 0 && locale !== "en") { + const fallback = await sql` + SELECT key, value + FROM route_f_translations + WHERE locale = 'en' + ORDER BY key ASC + `; + rows = fallback.rows; + } + + const translations = Object.fromEntries(rows.map(r => [r.key, r.value])); + + return NextResponse.json( + { locale: rows.length > 0 ? locale : "en", translations }, + { + headers: { + "Cache-Control": "public, max-age=3600", + }, + } + ); + } catch (error) { + console.error("[translations] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest): Promise { + // Admin-only + const session = await verifySession(req); + if (!session.ok) return session.response; + + const adminCheck = await sql` + SELECT 1 FROM users WHERE id = ${session.userId} AND is_admin = TRUE LIMIT 1 + `; + if (adminCheck.rows.length === 0) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const bodyResult = await validateBody(req, upsertBodySchema); + if (bodyResult instanceof Response) return bodyResult; + + const { locale, key, value } = bodyResult.data; + + try { + await ensureTranslationsSchema(); + + await sql` + INSERT INTO route_f_translations (locale, key, value, updated_at) + VALUES (${locale}, ${key}, ${value}, NOW()) + ON CONFLICT (locale, key) + DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + `; + + return NextResponse.json({ locale, key, value }, { status: 200 }); + } catch (error) { + console.error("[translations] POST error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/uploads/sign/__tests__/route.test.ts b/app/api/routes-f/uploads/sign/__tests__/route.test.ts new file mode 100644 index 00000000..52cece24 --- /dev/null +++ b/app/api/routes-f/uploads/sign/__tests__/route.test.ts @@ -0,0 +1,401 @@ +/** + * Tests for POST /api/routes-f/uploads/sign + * + * Mocks: + * - @aws-sdk/client-s3 — no real S3/R2 calls + * - @aws-sdk/s3-request-presigner — returns a deterministic fake URL + * - next/server — standard Response shim + * - @/lib/rate-limit — always allows by default + * - @/lib/auth/verify-session — controllable session + */ + +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@aws-sdk/client-s3", () => ({ + S3Client: jest.fn().mockImplementation(() => ({})), + PutObjectCommand: jest.fn().mockImplementation((input: unknown) => ({ + input, + })), +})); + +jest.mock("@aws-sdk/s3-request-presigner", () => ({ + getSignedUrl: jest.fn(), +})); + +jest.mock("@/lib/rate-limit", () => ({ + createRateLimiter: () => async () => false, // never rate-limited by default +})); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; +import { verifySession } from "@/lib/auth/verify-session"; +import { POST } from "../route"; + +const getSignedUrlMock = getSignedUrl as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; +const S3ClientMock = S3Client as unknown as jest.Mock; +const PutObjectCommandMock = PutObjectCommand as unknown as jest.Mock; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const makeRequest = (body?: object): import("next/server").NextRequest => + new Request("http://localhost/api/routes-f/uploads/sign", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; + +const authedSession = { + ok: true as const, + userId: "user-abc", + wallet: null, + privyId: "did:privy:abc", + username: "testuser", + email: "test@example.com", +}; + +const r2Env = { + R2_ACCOUNT_ID: "test-account", + R2_ACCESS_KEY_ID: "key-id", + R2_SECRET_ACCESS_KEY: "secret-key", + R2_BUCKET_NAME: "streamfi-uploads", + CDN_BASE_URL: "https://cdn.streamfi.media", +}; + +function setR2Env() { + Object.assign(process.env, r2Env); +} + +function clearR2Env() { + for (const key of Object.keys(r2Env)) { + delete process.env[key]; + } +} + +let consoleErrorSpy: jest.SpyInstance; + +// ── Test suite ───────────────────────────────────────────────────────────────── + +describe("POST /api/routes-f/uploads/sign", () => { + beforeEach(() => { + jest.clearAllMocks(); + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + verifySessionMock.mockResolvedValue(authedSession); + getSignedUrlMock.mockResolvedValue( + "https://test-account.r2.cloudflarestorage.com/avatars/user-abc/uuid.jpg?X-Amz-Signature=abc" + ); + setR2Env(); + }); + + afterEach(() => { + consoleErrorSpy?.mockRestore(); + clearR2Env(); + }); + + // ── Auth ──────────────────────────────────────────────────────────────────── + + it("returns 401 when session is invalid", async () => { + verifySessionMock.mockResolvedValue({ + ok: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }), + }); + const res = await POST( + makeRequest({ + type: "avatar", + filename: "photo.jpg", + content_type: "image/jpeg", + }) + ); + expect(res.status).toBe(401); + }); + + // ── Input validation ──────────────────────────────────────────────────────── + + it("returns 400 for invalid JSON body", async () => { + const req = new Request("http://localhost/api/routes-f/uploads/sign", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not-json", + }) as unknown as import("next/server").NextRequest; + const res = await POST(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/invalid json/i); + }); + + it("returns 400 for invalid type", async () => { + const res = await POST( + makeRequest({ + type: "video", + filename: "clip.mp4", + content_type: "image/jpeg", + }) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/type must be one of/i); + }); + + it("returns 400 when filename is missing", async () => { + const res = await POST( + makeRequest({ type: "avatar", content_type: "image/jpeg" }) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/filename/i); + }); + + it("returns 400 when filename is an empty string", async () => { + const res = await POST( + makeRequest({ + type: "avatar", + filename: " ", + content_type: "image/jpeg", + }) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/filename/i); + }); + + it("returns 400 when content_type is not allowed", async () => { + const res = await POST( + makeRequest({ + type: "avatar", + filename: "photo.gif", + content_type: "image/gif", + }) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/content_type must be one of/i); + // Must list accepted types + expect(body.error).toContain("image/jpeg"); + expect(body.error).toContain("image/png"); + expect(body.error).toContain("image/webp"); + }); + + it("returns 400 when content_type is missing", async () => { + const res = await POST( + makeRequest({ type: "avatar", filename: "photo.jpg" }) + ); + expect(res.status).toBe(400); + }); + + // ── R2 config ─────────────────────────────────────────────────────────────── + + it("returns 500 when R2 env vars are not set", async () => { + clearR2Env(); + const res = await POST( + makeRequest({ + type: "avatar", + filename: "photo.jpg", + content_type: "image/jpeg", + }) + ); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toMatch(/storage is not configured/i); + }); + + it("returns 500 when only some R2 env vars are set", async () => { + delete process.env.R2_SECRET_ACCESS_KEY; + const res = await POST( + makeRequest({ + type: "avatar", + filename: "photo.jpg", + content_type: "image/jpeg", + }) + ); + expect(res.status).toBe(500); + }); + + // ── Happy paths ───────────────────────────────────────────────────────────── + + it("returns 200 with upload_url, public_url, and expires_in for avatar", async () => { + const res = await POST( + makeRequest({ + type: "avatar", + filename: "photo.jpg", + content_type: "image/jpeg", + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.upload_url).toBeDefined(); + expect(body.public_url).toBeDefined(); + expect(body.expires_in).toBe(300); + }); + + it("returns 200 for banner type", async () => { + const res = await POST( + makeRequest({ + type: "banner", + filename: "banner.png", + content_type: "image/png", + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.expires_in).toBe(300); + }); + + it("returns 200 for thumbnail type", async () => { + const res = await POST( + makeRequest({ + type: "thumbnail", + filename: "thumb.webp", + content_type: "image/webp", + }) + ); + expect(res.status).toBe(200); + }); + + // ── Object key structure ──────────────────────────────────────────────────── + + it("includes userId in the object key path", async () => { + await POST( + makeRequest({ + type: "avatar", + filename: "photo.jpg", + content_type: "image/jpeg", + }) + ); + const commandArg = PutObjectCommandMock.mock.calls[0][0]; + expect(commandArg.Key).toContain("user-abc"); + expect(commandArg.Key).toMatch(/^avatars\/user-abc\/.+\.jpg$/); + }); + + it("uses correct folder prefix for banner", async () => { + await POST( + makeRequest({ + type: "banner", + filename: "banner.png", + content_type: "image/png", + }) + ); + const commandArg = PutObjectCommandMock.mock.calls[0][0]; + expect(commandArg.Key).toMatch(/^banners\/user-abc\/.+\.png$/); + }); + + it("uses correct folder prefix for thumbnail", async () => { + await POST( + makeRequest({ + type: "thumbnail", + filename: "thumb.webp", + content_type: "image/webp", + }) + ); + const commandArg = PutObjectCommandMock.mock.calls[0][0]; + expect(commandArg.Key).toMatch(/^thumbnails\/user-abc\/.+\.webp$/); + }); + + it("sets the correct ContentType on the PutObjectCommand", async () => { + await POST( + makeRequest({ + type: "avatar", + filename: "photo.png", + content_type: "image/png", + }) + ); + const commandArg = PutObjectCommandMock.mock.calls[0][0]; + expect(commandArg.ContentType).toBe("image/png"); + }); + + it("generates a unique UUID per request (two calls produce different keys)", async () => { + await POST( + makeRequest({ + type: "avatar", + filename: "a.jpg", + content_type: "image/jpeg", + }) + ); + await POST( + makeRequest({ + type: "avatar", + filename: "b.jpg", + content_type: "image/jpeg", + }) + ); + const key1 = PutObjectCommandMock.mock.calls[0][0].Key; + const key2 = PutObjectCommandMock.mock.calls[1][0].Key; + expect(key1).not.toBe(key2); + }); + + // ── public_url construction ───────────────────────────────────────────────── + + it("constructs public_url from CDN_BASE_URL and object key", async () => { + const res = await POST( + makeRequest({ + type: "avatar", + filename: "photo.jpg", + content_type: "image/jpeg", + }) + ); + const body = await res.json(); + expect(body.public_url).toMatch( + /^https:\/\/cdn\.streamfi\.media\/avatars\/user-abc\/.+\.jpg$/ + ); + }); + + // ── S3 client configuration ───────────────────────────────────────────────── + + it("initialises S3Client with the R2 endpoint derived from R2_ACCOUNT_ID", async () => { + await POST( + makeRequest({ + type: "avatar", + filename: "photo.jpg", + content_type: "image/jpeg", + }) + ); + const s3Config = S3ClientMock.mock.calls[0][0]; + expect(s3Config.endpoint).toBe( + "https://test-account.r2.cloudflarestorage.com" + ); + expect(s3Config.region).toBe("auto"); + expect(s3Config.credentials.accessKeyId).toBe("key-id"); + expect(s3Config.credentials.secretAccessKey).toBe("secret-key"); + }); + + it("passes expiresIn: 300 to getSignedUrl", async () => { + await POST( + makeRequest({ + type: "avatar", + filename: "photo.jpg", + content_type: "image/jpeg", + }) + ); + const options = getSignedUrlMock.mock.calls[0][2]; + expect(options.expiresIn).toBe(300); + }); + + // ── Error from presigner ──────────────────────────────────────────────────── + + it("returns 500 when getSignedUrl throws", async () => { + getSignedUrlMock.mockRejectedValueOnce(new Error("network failure")); + const res = await POST( + makeRequest({ + type: "avatar", + filename: "photo.jpg", + content_type: "image/jpeg", + }) + ); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toMatch(/failed to generate upload url/i); + }); +}); diff --git a/app/api/routes-f/uploads/sign/route.ts b/app/api/routes-f/uploads/sign/route.ts new file mode 100644 index 00000000..e15a2efa --- /dev/null +++ b/app/api/routes-f/uploads/sign/route.ts @@ -0,0 +1,214 @@ +/** + * POST /api/routes-f/uploads/sign — generate a pre-signed PUT URL for direct uploads + * + * Supported types: avatar | banner | thumbnail + * + * Security: + * - Session cookie required (privy or wallet) + * - Rate limited: 5 requests/min per IP (general) + 10 uploads/user/hour (user-level) + * - content_type validated against an explicit allowlist server-side + * - Storage path namespaced by user ID: {type}s/{userId}/{uuid}.{ext} + * - Pre-signed URL expires in 5 minutes (300 s) + * + * Size limits are enforced at the R2 bucket policy level; the max sizes below + * are documented for client guidance only — PUT presigned URLs cannot embed a + * Content-Length-Range condition (that requires presigned POST). + * + * Env vars required: + * R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME, CDN_BASE_URL + */ + +import { NextRequest, NextResponse } from "next/server"; +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { createRateLimiter } from "@/lib/rate-limit"; +import { verifySession } from "@/lib/auth/verify-session"; + +// 5 requests per minute per IP +const isIpRateLimited = createRateLimiter(60_000, 5); +// 10 uploads per hour per user +const isUserRateLimited = createRateLimiter(3_600_000, 10); + +// ── Constants ────────────────────────────────────────────────────────────────── + +const UPLOAD_TTL_SECONDS = 300; // 5 minutes + +type UploadType = "avatar" | "banner" | "thumbnail"; +type AllowedContentType = "image/jpeg" | "image/png" | "image/webp"; + +const ALLOWED_CONTENT_TYPES: AllowedContentType[] = [ + "image/jpeg", + "image/png", + "image/webp", +]; + +const CONTENT_TYPE_TO_EXT: Record = { + "image/jpeg": "jpg", + "image/png": "png", + "image/webp": "webp", +}; + +// Documented limits — enforced at the bucket level, not in this route. +export const MAX_SIZE_BYTES: Record = { + avatar: 5 * 1024 * 1024, + banner: 10 * 1024 * 1024, + thumbnail: 10 * 1024 * 1024, +}; + +// ── Types ────────────────────────────────────────────────────────────────────── + +interface SignRequest { + type: UploadType; + filename: string; + content_type: AllowedContentType; +} + +interface R2Config { + accountId: string; + accessKeyId: string; + secretAccessKey: string; + bucketName: string; + cdnBaseUrl: string; +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function getR2Config(): R2Config { + const accountId = process.env.R2_ACCOUNT_ID; + const accessKeyId = process.env.R2_ACCESS_KEY_ID; + const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY; + const bucketName = process.env.R2_BUCKET_NAME; + const cdnBaseUrl = process.env.CDN_BASE_URL; + + if ( + !accountId || + !accessKeyId || + !secretAccessKey || + !bucketName || + !cdnBaseUrl + ) { + throw new Error("R2 storage is not configured"); + } + + return { accountId, accessKeyId, secretAccessKey, bucketName, cdnBaseUrl }; +} + +export function createS3Client(config: R2Config): S3Client { + return new S3Client({ + region: "auto", + endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, + }, + }); +} + +// ── POST /api/routes-f/uploads/sign ─────────────────────────────────────────── + +export async function POST(req: NextRequest) { + // 1. IP-level rate limit + const ip = + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + "unknown"; + + if (await isIpRateLimited(ip)) { + return NextResponse.json( + { error: "Too many requests" }, + { status: 429, headers: { "Retry-After": "60" } } + ); + } + + // 2. Session auth + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + // 3. User-level rate limit: 10 uploads per hour + if (await isUserRateLimited(session.userId)) { + return NextResponse.json( + { error: "Upload limit reached. You may upload 10 files per hour." }, + { status: 429, headers: { "Retry-After": "3600" } } + ); + } + + // 4. Parse and validate body + let body: SignRequest; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { type, filename, content_type } = body; + + if (!["avatar", "banner", "thumbnail"].includes(type)) { + return NextResponse.json( + { error: "type must be one of: avatar, banner, thumbnail" }, + { status: 400 } + ); + } + + if (typeof filename !== "string" || !filename.trim()) { + return NextResponse.json( + { error: "filename must be a non-empty string" }, + { status: 400 } + ); + } + + if (!ALLOWED_CONTENT_TYPES.includes(content_type)) { + return NextResponse.json( + { + error: `content_type must be one of: ${ALLOWED_CONTENT_TYPES.join(", ")}`, + }, + { status: 400 } + ); + } + + // 5. Resolve R2 config + let r2Config: R2Config; + try { + r2Config = getR2Config(); + } catch (err) { + console.error("[uploads/sign] R2 config error:", err); + return NextResponse.json( + { error: "Storage is not configured" }, + { status: 500 } + ); + } + + // 6. Generate pre-signed URL + const uuid = crypto.randomUUID(); + const ext = CONTENT_TYPE_TO_EXT[content_type]; + // Path namespaced by userId — prevents overwriting other users' files. + const objectKey = `${type}s/${session.userId}/${uuid}.${ext}`; + + try { + const client = createS3Client(r2Config); + const command = new PutObjectCommand({ + Bucket: r2Config.bucketName, + Key: objectKey, + ContentType: content_type, + }); + + const uploadUrl = await getSignedUrl(client, command, { + expiresIn: UPLOAD_TTL_SECONDS, + }); + + const publicUrl = `${r2Config.cdnBaseUrl}/${objectKey}`; + + return NextResponse.json({ + upload_url: uploadUrl, + public_url: publicUrl, + expires_in: UPLOAD_TTL_SECONDS, + }); + } catch (err) { + console.error("[uploads/sign] Failed to generate pre-signed URL:", err); + return NextResponse.json( + { error: "Failed to generate upload URL" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/validate/route.ts b/app/api/routes-f/validate/route.ts new file mode 100644 index 00000000..4dcd47ad --- /dev/null +++ b/app/api/routes-f/validate/route.ts @@ -0,0 +1,97 @@ +/** + * POST /api/routes-f/validate + * + * Dev-only endpoint for testing shared Zod schemas interactively. + * Returns 404 in production (NODE_ENV !== 'development'). + * + * Request body: + * { "schema_name": "username" | "stellarPublicKey" | "usdcAmount" | "pagination" | "period" | "email" | "url" | "uuid", + * "data": } + * + * Success response (valid): + * { "valid": true, "parsed": } + * + * Error response (invalid): + * { "valid": false, "issues": [{ "field": "...", "message": "..." }] } + */ + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { + stellarPublicKeySchema, + usernameSchema, + usdcAmountSchema, + paginationSchema, + periodSchema, + emailSchema, + urlSchema, + uuidSchema, +} from "@/app/api/routes-f/_lib/schemas"; + +const SCHEMA_MAP: Record> = { + stellarPublicKey: stellarPublicKeySchema, + username: usernameSchema, + usdcAmount: usdcAmountSchema, + pagination: paginationSchema, + period: periodSchema, + email: emailSchema, + url: urlSchema, + uuid: uuidSchema, +}; + +const requestSchema = z.object({ + schema_name: z.string().min(1, "schema_name is required"), + data: z.unknown(), +}); + +export async function POST(req: NextRequest): Promise { + if (process.env.NODE_ENV !== "development") { + return NextResponse.json({ error: "Not Found" }, { status: 404 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = requestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { + error: "Validation failed", + issues: parsed.error.issues.map(i => ({ + field: i.path.join(".") || "body", + message: i.message, + })), + }, + { status: 400 } + ); + } + + const { schema_name, data } = parsed.data; + const schema = SCHEMA_MAP[schema_name]; + + if (!schema) { + return NextResponse.json( + { + error: `Unknown schema_name "${schema_name}". Available: ${Object.keys(SCHEMA_MAP).join(", ")}`, + }, + { status: 400 } + ); + } + + const result = schema.safeParse(data); + if (result.success) { + return NextResponse.json({ valid: true, parsed: result.data }); + } + + return NextResponse.json({ + valid: false, + issues: result.error.issues.map(i => ({ + field: i.path.join(".") || "value", + message: i.message, + })), + }); +} diff --git a/app/api/routes-f/verification/__tests__/route.test.ts b/app/api/routes-f/verification/__tests__/route.test.ts new file mode 100644 index 00000000..dcf7d45c --- /dev/null +++ b/app/api/routes-f/verification/__tests__/route.test.ts @@ -0,0 +1,128 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { GET as getVerification, POST } from "../route"; +import { GET as getAdmin } from "../admin/route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +const authedSession = { + ok: true as const, + userId: "user-123", + wallet: null, + privyId: "did:privy:abc", + username: "creator", + email: "creator@example.com", +}; + +const makeRequest = (method: string, path: string, body?: object) => + new Request(`http://localhost${path}`, { + method, + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; + +describe("routes-f verification", () => { + beforeEach(() => { + jest.clearAllMocks(); + verifySessionMock.mockResolvedValue(authedSession); + delete process.env.ROUTES_F_ADMIN_EMAILS; + }); + + it("returns unverified when no request exists", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await getVerification( + makeRequest("GET", "/api/routes-f/verification") + ); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + status: "unverified", + request: null, + }); + }); + + it("blocks resubmission while pending", async () => { + sqlMock + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [{ status: "pending" }] }); + + const res = await POST( + makeRequest("POST", "/api/routes-f/verification", { + social_links: [], + reason: "I have built an audience and want profile verification.", + }) + ); + + expect(res.status).toBe(409); + await expect(res.json()).resolves.toMatchObject({ + error: expect.stringMatching(/pending/i), + }); + }); + + it("requires admin allowlist email for admin route", async () => { + process.env.ROUTES_F_ADMIN_EMAILS = "admin@example.com"; + const res = await getAdmin( + makeRequest("GET", "/api/routes-f/verification/admin") + ); + expect(res.status).toBe(403); + }); + + it("returns pending requests for allowed admins", async () => { + process.env.ROUTES_F_ADMIN_EMAILS = "admin@example.com"; + verifySessionMock.mockResolvedValue({ + ...authedSession, + email: "admin@example.com", + }); + + sqlMock + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ + rows: [ + { + id: "req-1", + status: "pending", + social_links: [{ socialTitle: "x", socialLink: "https://x.com/a" }], + reason: "Please verify my profile for creator credibility.", + id_document_url: null, + created_at: "2026-03-27T10:00:00.000Z", + updated_at: "2026-03-27T10:00:00.000Z", + creator_id: "user-123", + username: "creator", + avatar: "/Images/user.png", + email: "creator@example.com", + }, + ], + }); + + const res = await getAdmin( + makeRequest("GET", "/api/routes-f/verification/admin") + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.requests).toHaveLength(1); + expect(body.requests[0].creator.username).toBe("creator"); + }); +}); diff --git a/app/api/routes-f/verification/admin/route.ts b/app/api/routes-f/verification/admin/route.ts new file mode 100644 index 00000000..fd952f5f --- /dev/null +++ b/app/api/routes-f/verification/admin/route.ts @@ -0,0 +1,117 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +function getAdminEmails(): string[] { + return ( + process.env.ROUTES_F_ADMIN_EMAILS ?? + process.env.ADMIN_EMAILS ?? + process.env.STREAMFI_ADMIN_EMAILS ?? + "" + ) + .split(",") + .map(email => email.trim().toLowerCase()) + .filter(Boolean); +} + +async function ensureVerificationTable() { + await sql` + CREATE TABLE IF NOT EXISTS creator_verification_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + creator_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + social_links JSONB NOT NULL DEFAULT '[]'::jsonb, + reason TEXT NOT NULL, + id_document_url TEXT, + rejection_reason TEXT, + reviewed_by UUID REFERENCES users(id) ON DELETE SET NULL, + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT creator_verification_requests_status_check + CHECK (status IN ('pending', 'verified', 'rejected')) + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_creator_verification_requests_status + ON creator_verification_requests (status, created_at) + `; +} + +export async function GET(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const adminEmails = getAdminEmails(); + if (!session.email || !adminEmails.includes(session.email.toLowerCase())) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + try { + await ensureVerificationTable(); + + const result = await sql<{ + id: string; + status: string; + social_links: unknown; + reason: string; + id_document_url: string | null; + created_at: string | Date; + updated_at: string | Date; + creator_id: string; + username: string; + avatar: string | null; + email: string | null; + }>` + SELECT + r.id, + r.status, + r.social_links, + r.reason, + r.id_document_url, + r.created_at, + r.updated_at, + u.id AS creator_id, + u.username, + u.avatar, + u.email + FROM creator_verification_requests r + JOIN users u ON u.id = r.creator_id + WHERE r.status = 'pending' + ORDER BY r.created_at ASC + `; + + return NextResponse.json({ + requests: result.rows.map(row => ({ + id: row.id, + status: row.status, + social_links: Array.isArray(row.social_links) ? row.social_links : [], + reason: row.reason, + id_document_url: row.id_document_url, + created_at: + row.created_at instanceof Date + ? row.created_at.toISOString() + : row.created_at, + updated_at: + row.updated_at instanceof Date + ? row.updated_at.toISOString() + : row.updated_at, + creator: { + id: row.creator_id, + username: row.username, + avatar: row.avatar, + email: row.email, + }, + })), + }); + } catch (error) { + console.error("[routes-f/verification/admin] GET error:", error); + return NextResponse.json( + { error: "Failed to fetch verification requests" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/verification/route.ts b/app/api/routes-f/verification/route.ts new file mode 100644 index 00000000..9279a4ff --- /dev/null +++ b/app/api/routes-f/verification/route.ts @@ -0,0 +1,280 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +type VerificationStatus = "unverified" | "pending" | "verified" | "rejected"; + +type SocialLink = { + socialTitle: string; + socialLink: string; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normaliseSocialLinks(input: unknown): SocialLink[] { + if (input === undefined) { + return []; + } + + if (!Array.isArray(input)) { + throw new Error("social_links must be an array"); + } + + return input.map(link => { + if (!isRecord(link)) { + throw new Error("Each social link must be an object"); + } + + const rawTitle = link.socialTitle ?? link.title; + const rawUrl = link.socialLink ?? link.url; + + if (typeof rawTitle !== "string" || !rawTitle.trim()) { + throw new Error("Each social link must include a socialTitle"); + } + + if (typeof rawUrl !== "string" || !rawUrl.trim()) { + throw new Error("Each social link must include a socialLink"); + } + + try { + const parsed = new URL(rawUrl); + if (!["http:", "https:"].includes(parsed.protocol)) { + throw new Error("invalid protocol"); + } + } catch { + throw new Error("Each social link must have a valid http(s) URL"); + } + + return { + socialTitle: rawTitle.trim(), + socialLink: rawUrl.trim(), + }; + }); +} + +function normaliseReason(value: unknown): string { + if (typeof value !== "string" || !value.trim()) { + throw new Error("reason is required"); + } + + const reason = value.trim(); + if (reason.length < 20) { + throw new Error("reason must be at least 20 characters"); + } + if (reason.length > 2000) { + throw new Error("reason must be 2000 characters or fewer"); + } + + return reason; +} + +function normaliseIdDocumentUrl(value: unknown): string | null { + if (value === undefined || value === null || value === "") { + return null; + } + + if (typeof value !== "string") { + throw new Error("id_document_url must be a string"); + } + + try { + const parsed = new URL(value); + if (!["http:", "https:"].includes(parsed.protocol)) { + throw new Error("invalid protocol"); + } + return value.trim(); + } catch { + throw new Error("id_document_url must be a valid http(s) URL"); + } +} + +async function ensureVerificationTable() { + await sql` + CREATE TABLE IF NOT EXISTS creator_verification_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + creator_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + social_links JSONB NOT NULL DEFAULT '[]'::jsonb, + reason TEXT NOT NULL, + id_document_url TEXT, + rejection_reason TEXT, + reviewed_by UUID REFERENCES users(id) ON DELETE SET NULL, + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT creator_verification_requests_status_check + CHECK (status IN ('pending', 'verified', 'rejected')) + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_creator_verification_requests_status + ON creator_verification_requests (status, created_at) + `; +} + +async function getCurrentStatus(userId: string) { + const result = await sql<{ + id: string; + status: Exclude; + social_links: unknown; + reason: string; + id_document_url: string | null; + rejection_reason: string | null; + reviewed_at: string | Date | null; + created_at: string | Date; + updated_at: string | Date; + }>` + SELECT + id, + status, + social_links, + reason, + id_document_url, + rejection_reason, + reviewed_at, + created_at, + updated_at + FROM creator_verification_requests + WHERE creator_id = ${userId} + LIMIT 1 + `; + + if (result.rows.length === 0) { + return { + status: "unverified" as const, + request: null, + }; + } + + const row = result.rows[0]; + return { + status: row.status, + request: { + id: row.id, + status: row.status, + social_links: Array.isArray(row.social_links) ? row.social_links : [], + reason: row.reason, + id_document_url: row.id_document_url, + rejection_reason: row.rejection_reason, + reviewed_at: + row.reviewed_at instanceof Date + ? row.reviewed_at.toISOString() + : row.reviewed_at, + created_at: + row.created_at instanceof Date + ? row.created_at.toISOString() + : row.created_at, + updated_at: + row.updated_at instanceof Date + ? row.updated_at.toISOString() + : row.updated_at, + }, + }; +} + +export async function GET(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + await ensureVerificationTable(); + const status = await getCurrentStatus(session.userId); + return NextResponse.json(status); + } catch (error) { + console.error("[routes-f/verification] GET error:", error); + return NextResponse.json( + { error: "Failed to fetch verification status" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + await ensureVerificationTable(); + + const payload = await req.json(); + const socialLinks = normaliseSocialLinks(payload?.social_links); + const reason = normaliseReason(payload?.reason); + const idDocumentUrl = normaliseIdDocumentUrl(payload?.id_document_url); + + const existing = await sql<{ status: VerificationStatus }>` + SELECT status + FROM creator_verification_requests + WHERE creator_id = ${session.userId} + LIMIT 1 + `; + + const currentStatus = existing.rows[0]?.status; + if (currentStatus === "pending") { + return NextResponse.json( + { error: "Verification request is already pending" }, + { status: 409 } + ); + } + + if (currentStatus === "verified") { + return NextResponse.json( + { error: "Creator is already verified" }, + { status: 409 } + ); + } + + await sql` + INSERT INTO creator_verification_requests ( + creator_id, + status, + social_links, + reason, + id_document_url, + rejection_reason, + reviewed_by, + reviewed_at, + updated_at + ) + VALUES ( + ${session.userId}, + 'pending', + ${JSON.stringify(socialLinks)}::jsonb, + ${reason}, + ${idDocumentUrl}, + NULL, + NULL, + NULL, + NOW() + ) + ON CONFLICT (creator_id) DO UPDATE SET + status = 'pending', + social_links = EXCLUDED.social_links, + reason = EXCLUDED.reason, + id_document_url = EXCLUDED.id_document_url, + rejection_reason = NULL, + reviewed_by = NULL, + reviewed_at = NULL, + updated_at = NOW() + `; + + const status = await getCurrentStatus(session.userId); + return NextResponse.json(status, { status: 201 }); + } catch (error) { + if (error instanceof Error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + console.error("[routes-f/verification] POST error:", error); + return NextResponse.json( + { error: "Failed to submit verification request" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/vod/chapters/[id]/__tests__/route.test.ts b/app/api/routes-f/vod/chapters/[id]/__tests__/route.test.ts new file mode 100644 index 00000000..fe83ae80 --- /dev/null +++ b/app/api/routes-f/vod/chapters/[id]/__tests__/route.test.ts @@ -0,0 +1,163 @@ +/** + * Tests for DELETE /api/routes-f/vod/chapters/[id] + * + * Mocks: + * - @vercel/postgres — no real DB + * - next/server — minimal polyfill + * - @/lib/rate-limit — always allows + * - @/lib/auth/verify-session — controllable session + */ + +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/rate-limit", () => ({ + createRateLimiter: () => async () => false, +})); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { DELETE } from "../route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const VALID_CHAPTER_ID = "550e8400-e29b-41d4-a716-446655440000"; + +const authedSession = { + ok: true as const, + userId: "user-123", + wallet: null, + privyId: "did:privy:abc", + username: "testuser", + email: "test@example.com", +}; + +const unauthSession = { + ok: false as const, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }), +}; + +function makeDeleteRequest(id: string): import("next/server").NextRequest { + return new Request(`http://localhost/api/routes-f/vod/chapters/${id}`, { + method: "DELETE", + }) as unknown as import("next/server").NextRequest; +} + +function makeParams(id: string): { params: Promise<{ id: string }> } { + return { params: Promise.resolve({ id }) }; +} + +let consoleErrorSpy: jest.SpyInstance; + +// ── DELETE tests ─────────────────────────────────────────────────────────────── + +describe("DELETE /api/routes-f/vod/chapters/[id]", () => { + beforeEach(() => { + jest.clearAllMocks(); + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + verifySessionMock.mockResolvedValue(authedSession); + }); + afterEach(() => consoleErrorSpy?.mockRestore()); + + it("returns 401 when not authenticated", async () => { + verifySessionMock.mockResolvedValue(unauthSession); + const res = await DELETE( + makeDeleteRequest(VALID_CHAPTER_ID), + makeParams(VALID_CHAPTER_ID) + ); + expect(res.status).toBe(401); + }); + + it("returns 400 for a non-UUID chapter id", async () => { + const res = await DELETE(makeDeleteRequest("bad-id"), makeParams("bad-id")); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/invalid/i); + }); + + it("returns 404 when chapter does not exist", async () => { + sqlMock.mockResolvedValueOnce({ rows: [] }); + + const res = await DELETE( + makeDeleteRequest(VALID_CHAPTER_ID), + makeParams(VALID_CHAPTER_ID) + ); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toMatch(/not found/i); + }); + + it("returns 403 when caller does not own the recording", async () => { + sqlMock.mockResolvedValueOnce({ + rows: [{ id: VALID_CHAPTER_ID, user_id: "other-user" }], + }); + + const res = await DELETE( + makeDeleteRequest(VALID_CHAPTER_ID), + makeParams(VALID_CHAPTER_ID) + ); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toMatch(/forbidden/i); + }); + + it("returns 204 on successful deletion", async () => { + sqlMock.mockResolvedValueOnce({ + rows: [{ id: VALID_CHAPTER_ID, user_id: "user-123" }], + }); + sqlMock.mockResolvedValueOnce({ rows: [], rowCount: 1 }); // DELETE + + const res = await DELETE( + makeDeleteRequest(VALID_CHAPTER_ID), + makeParams(VALID_CHAPTER_ID) + ); + expect(res.status).toBe(204); + // 204 No Content — body is null or undefined depending on runtime + expect(res.body ?? null).toBeNull(); + }); + + it("performs the DELETE query with the correct chapter id", async () => { + sqlMock.mockResolvedValueOnce({ + rows: [{ id: VALID_CHAPTER_ID, user_id: "user-123" }], + }); + sqlMock.mockResolvedValueOnce({ rows: [] }); + + await DELETE( + makeDeleteRequest(VALID_CHAPTER_ID), + makeParams(VALID_CHAPTER_ID) + ); + + // SQL calls: 0=SELECT (JOIN), 1=DELETE + // The DELETE call should include the chapter id as an interpolated value + const deleteCall = sqlMock.mock.calls[1]; + expect(deleteCall.slice(1)).toContain(VALID_CHAPTER_ID); + }); + + it("returns 500 on unexpected DB error", async () => { + sqlMock.mockRejectedValueOnce(new Error("DB crash")); + + const res = await DELETE( + makeDeleteRequest(VALID_CHAPTER_ID), + makeParams(VALID_CHAPTER_ID) + ); + expect(res.status).toBe(500); + }); +}); diff --git a/app/api/routes-f/vod/chapters/[id]/route.ts b/app/api/routes-f/vod/chapters/[id]/route.ts new file mode 100644 index 00000000..5253be90 --- /dev/null +++ b/app/api/routes-f/vod/chapters/[id]/route.ts @@ -0,0 +1,75 @@ +/** + * DELETE /api/routes-f/vod/chapters/[id] — remove a chapter marker + * + * Auth required. Caller must own the recording the chapter belongs to. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { createRateLimiter } from "@/lib/rate-limit"; +import { verifySession } from "@/lib/auth/verify-session"; + +const isIpRateLimited = createRateLimiter(60_000, 5); + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const ip = + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + "unknown"; + + if (await isIpRateLimited(ip)) { + return NextResponse.json( + { error: "Too many requests" }, + { status: 429, headers: { "Retry-After": "60" } } + ); + } + + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { id } = await params; + + if (!UUID_RE.test(id)) { + return NextResponse.json( + { error: "Invalid chapter id format" }, + { status: 400 } + ); + } + + try { + // Join to stream_recordings to verify the caller owns the recording + const { rows } = await sql` + SELECT c.id, r.user_id + FROM vod_chapters c + JOIN stream_recordings r ON r.id = c.recording_id + WHERE c.id = ${id} + LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ error: "Chapter not found" }, { status: 404 }); + } + + if (rows[0].user_id !== session.userId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + await sql`DELETE FROM vod_chapters WHERE id = ${id}`; + + return new Response(null, { status: 204 }); + } catch (err) { + console.error("[vod/chapters] DELETE error:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/vod/chapters/__tests__/route.test.ts b/app/api/routes-f/vod/chapters/__tests__/route.test.ts new file mode 100644 index 00000000..eda0a71c --- /dev/null +++ b/app/api/routes-f/vod/chapters/__tests__/route.test.ts @@ -0,0 +1,498 @@ +/** + * Tests for GET /api/routes-f/vod/chapters and POST /api/routes-f/vod/chapters + * + * Mocks: + * - @vercel/postgres — no real DB + * - next/server — minimal polyfill + * - @/lib/rate-limit — always allows + * - @/lib/auth/verify-session — controllable session + */ + +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/rate-limit", () => ({ + createRateLimiter: () => async () => false, +})); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { GET, POST } from "../route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const VALID_RECORDING_ID = "550e8400-e29b-41d4-a716-446655440000"; +const VALID_CHAPTER_ID = "660e8400-e29b-41d4-a716-446655440001"; + +const authedSession = { + ok: true as const, + userId: "user-123", + wallet: null, + privyId: "did:privy:abc", + username: "testuser", + email: "test@example.com", +}; + +const unauthSession = { + ok: false as const, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }), +}; + +function makeGetRequest(search?: string): import("next/server").NextRequest { + return new Request( + `http://localhost/api/routes-f/vod/chapters${search ?? ""}`, + { method: "GET" } + ) as unknown as import("next/server").NextRequest; +} + +function makePostRequest(body?: object): import("next/server").NextRequest { + return new Request("http://localhost/api/routes-f/vod/chapters", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; +} + +function mockEnsureTable() { + sqlMock.mockResolvedValueOnce({ rows: [], rowCount: 0 }); +} + +let consoleErrorSpy: jest.SpyInstance; + +// ── GET tests ────────────────────────────────────────────────────────────────── + +describe("GET /api/routes-f/vod/chapters", () => { + beforeEach(() => { + jest.clearAllMocks(); + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + verifySessionMock.mockResolvedValue(authedSession); + }); + afterEach(() => consoleErrorSpy?.mockRestore()); + + it("returns 400 when recording_id is missing", async () => { + const res = await GET(makeGetRequest()); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/recording_id/i); + }); + + it("returns 400 for a non-UUID recording_id", async () => { + const res = await GET(makeGetRequest("?recording_id=not-a-uuid")); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/invalid/i); + }); + + it("returns 404 when recording does not exist", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); // stream_recordings lookup + + const res = await GET( + makeGetRequest(`?recording_id=${VALID_RECORDING_ID}`) + ); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toMatch(/not found/i); + }); + + it("returns 200 with chapters ordered by timestamp", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [{ id: VALID_RECORDING_ID }] }); // recording exists + sqlMock.mockResolvedValueOnce({ + rows: [ + { + id: VALID_CHAPTER_ID, + recording_id: VALID_RECORDING_ID, + title: "Intro", + timestamp_seconds: 0, + created_at: "2026-03-28T00:00:00Z", + }, + { + id: "770e8400-e29b-41d4-a716-446655440002", + recording_id: VALID_RECORDING_ID, + title: "Part 2", + timestamp_seconds: 120, + created_at: "2026-03-28T00:00:01Z", + }, + ], + }); + + const res = await GET( + makeGetRequest(`?recording_id=${VALID_RECORDING_ID}`) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.chapters).toHaveLength(2); + expect(body.chapters[0].title).toBe("Intro"); + expect(body.chapters[1].title).toBe("Part 2"); + }); + + it("returns 200 with empty chapters array when none exist", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [{ id: VALID_RECORDING_ID }] }); + sqlMock.mockResolvedValueOnce({ rows: [] }); + + const res = await GET( + makeGetRequest(`?recording_id=${VALID_RECORDING_ID}`) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.chapters).toEqual([]); + }); + + it("returns 500 on unexpected DB error", async () => { + mockEnsureTable(); + sqlMock.mockRejectedValueOnce(new Error("DB crash")); + + const res = await GET( + makeGetRequest(`?recording_id=${VALID_RECORDING_ID}`) + ); + expect(res.status).toBe(500); + }); +}); + +// ── POST tests ───────────────────────────────────────────────────────────────── + +describe("POST /api/routes-f/vod/chapters", () => { + beforeEach(() => { + jest.clearAllMocks(); + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + verifySessionMock.mockResolvedValue(authedSession); + }); + afterEach(() => consoleErrorSpy?.mockRestore()); + + it("returns 401 when not authenticated", async () => { + verifySessionMock.mockResolvedValue(unauthSession); + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + title: "Intro", + timestamp_seconds: 0, + }) + ); + expect(res.status).toBe(401); + }); + + it("returns 400 for invalid JSON body", async () => { + const req = new Request("http://localhost/api/routes-f/vod/chapters", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{ not json }", + }) as unknown as import("next/server").NextRequest; + const res = await POST(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/invalid json/i); + }); + + it("returns 400 when body is an array", async () => { + const res = await POST(makePostRequest([1, 2] as unknown as object)); + expect(res.status).toBe(400); + }); + + it("returns 400 when recording_id is missing", async () => { + const res = await POST( + makePostRequest({ title: "Intro", timestamp_seconds: 10 }) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/recording_id/i); + }); + + it("returns 400 for invalid recording_id format", async () => { + const res = await POST( + makePostRequest({ + recording_id: "bad-id", + title: "Intro", + timestamp_seconds: 10, + }) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/invalid recording_id/i); + }); + + it("returns 400 when title is missing", async () => { + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + timestamp_seconds: 10, + }) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/title/i); + }); + + it("returns 400 when title is an empty string", async () => { + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + title: " ", + timestamp_seconds: 10, + }) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/title/i); + }); + + it("returns 400 when timestamp_seconds is negative", async () => { + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + title: "Intro", + timestamp_seconds: -1, + }) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/timestamp_seconds/i); + }); + + it("returns 400 when timestamp_seconds is a float", async () => { + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + title: "Intro", + timestamp_seconds: 10.5, + }) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/timestamp_seconds/i); + }); + + it("returns 400 when timestamp_seconds is a string", async () => { + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + title: "Intro", + timestamp_seconds: "10", + }) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/timestamp_seconds/i); + }); + + it("returns 404 when recording does not exist", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); // stream_recordings lookup + + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + title: "Intro", + timestamp_seconds: 0, + }) + ); + expect(res.status).toBe(404); + }); + + it("returns 403 when caller does not own the recording", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ + rows: [{ id: VALID_RECORDING_ID, duration: 600, user_id: "other-user" }], + }); + + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + title: "Intro", + timestamp_seconds: 10, + }) + ); + expect(res.status).toBe(403); + }); + + it("returns 422 when timestamp_seconds exceeds recording duration", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ + rows: [{ id: VALID_RECORDING_ID, duration: 300, user_id: "user-123" }], + }); + + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + title: "End", + timestamp_seconds: 400, + }) + ); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.error).toMatch(/exceeds/i); + }); + + it("returns 422 when recording is already at the 100-chapter cap", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ + rows: [{ id: VALID_RECORDING_ID, duration: 3600, user_id: "user-123" }], + }); + sqlMock.mockResolvedValueOnce({ rows: [{ count: "100" }] }); // chapter count + + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + title: "Chapter 101", + timestamp_seconds: 60, + }) + ); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.error).toMatch(/maximum/i); + }); + + it("returns 201 with the created chapter on success", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ + rows: [{ id: VALID_RECORDING_ID, duration: 3600, user_id: "user-123" }], + }); + sqlMock.mockResolvedValueOnce({ rows: [{ count: "0" }] }); // chapter count + sqlMock.mockResolvedValueOnce({ + rows: [ + { + id: VALID_CHAPTER_ID, + recording_id: VALID_RECORDING_ID, + title: "Intro", + timestamp_seconds: 0, + created_at: "2026-03-28T00:00:00Z", + }, + ], + }); + + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + title: "Intro", + timestamp_seconds: 0, + }) + ); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.chapter.id).toBe(VALID_CHAPTER_ID); + expect(body.chapter.title).toBe("Intro"); + expect(body.chapter.timestamp_seconds).toBe(0); + }); + + it("trims whitespace from title before inserting", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ + rows: [{ id: VALID_RECORDING_ID, duration: 3600, user_id: "user-123" }], + }); + sqlMock.mockResolvedValueOnce({ rows: [{ count: "0" }] }); + sqlMock.mockResolvedValueOnce({ + rows: [ + { + id: VALID_CHAPTER_ID, + recording_id: VALID_RECORDING_ID, + title: "Trimmed Title", + timestamp_seconds: 5, + created_at: "2026-03-28T00:00:00Z", + }, + ], + }); + + await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + title: " Trimmed Title ", + timestamp_seconds: 5, + }) + ); + + // The INSERT call receives the trimmed title as an interpolated value + // SQL calls: 0=ensureTable, 1=SELECT recording, 2=COUNT chapters, 3=INSERT + const insertCall = sqlMock.mock.calls[3]; + expect(insertCall.slice(1)).toContain("Trimmed Title"); + expect(insertCall.slice(1)).not.toContain(" Trimmed Title "); + }); + + it("allows timestamp_seconds equal to recording duration", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ + rows: [{ id: VALID_RECORDING_ID, duration: 300, user_id: "user-123" }], + }); + sqlMock.mockResolvedValueOnce({ rows: [{ count: "0" }] }); + sqlMock.mockResolvedValueOnce({ + rows: [ + { + id: VALID_CHAPTER_ID, + recording_id: VALID_RECORDING_ID, + title: "End", + timestamp_seconds: 300, + created_at: "2026-03-28T00:00:00Z", + }, + ], + }); + + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + title: "End", + timestamp_seconds: 300, + }) + ); + expect(res.status).toBe(201); + }); + + it("skips duration check when recording has no duration", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ + rows: [{ id: VALID_RECORDING_ID, duration: null, user_id: "user-123" }], + }); + sqlMock.mockResolvedValueOnce({ rows: [{ count: "0" }] }); + sqlMock.mockResolvedValueOnce({ + rows: [ + { + id: VALID_CHAPTER_ID, + recording_id: VALID_RECORDING_ID, + title: "Chapter", + timestamp_seconds: 9999, + created_at: "2026-03-28T00:00:00Z", + }, + ], + }); + + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + title: "Chapter", + timestamp_seconds: 9999, + }) + ); + expect(res.status).toBe(201); + }); + + it("returns 500 on unexpected DB error", async () => { + mockEnsureTable(); + sqlMock.mockRejectedValueOnce(new Error("DB crash")); + + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + title: "Intro", + timestamp_seconds: 10, + }) + ); + expect(res.status).toBe(500); + }); +}); diff --git a/app/api/routes-f/vod/chapters/import/__tests__/route.test.ts b/app/api/routes-f/vod/chapters/import/__tests__/route.test.ts new file mode 100644 index 00000000..f1c0b953 --- /dev/null +++ b/app/api/routes-f/vod/chapters/import/__tests__/route.test.ts @@ -0,0 +1,322 @@ +/** + * Tests for POST /api/routes-f/vod/chapters/import + * + * Mocks: + * - @vercel/postgres — no real DB + * - next/server — minimal polyfill + * - @/lib/rate-limit — always allows + * - @/lib/auth/verify-session — controllable session + */ + +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/rate-limit", () => ({ + createRateLimiter: () => async () => false, +})); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { POST } from "../route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const VALID_RECORDING_ID = "550e8400-e29b-41d4-a716-446655440000"; +const VALID_STREAM_ID = "660e8400-e29b-41d4-a716-446655440001"; + +const authedSession = { + ok: true as const, + userId: "user-123", + wallet: null, + privyId: "did:privy:abc", + username: "testuser", + email: "test@example.com", +}; + +const unauthSession = { + ok: false as const, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }), +}; + +function makePostRequest(body?: object): import("next/server").NextRequest { + return new Request("http://localhost/api/routes-f/vod/chapters/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; +} + +function mockEnsureTables() { + sqlMock.mockResolvedValueOnce({ rows: [] }); // CREATE vod_chapters + sqlMock.mockResolvedValueOnce({ rows: [] }); // CREATE stream_chapters +} + +let consoleErrorSpy: jest.SpyInstance; + +// ── POST tests ───────────────────────────────────────────────────────────────── + +describe("POST /api/routes-f/vod/chapters/import", () => { + beforeEach(() => { + jest.clearAllMocks(); + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + verifySessionMock.mockResolvedValue(authedSession); + }); + afterEach(() => consoleErrorSpy?.mockRestore()); + + it("returns 401 when not authenticated", async () => { + verifySessionMock.mockResolvedValue(unauthSession); + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + stream_id: VALID_STREAM_ID, + }) + ); + expect(res.status).toBe(401); + }); + + it("returns 400 for invalid JSON body", async () => { + const req = new Request( + "http://localhost/api/routes-f/vod/chapters/import", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{ invalid }", + } + ) as unknown as import("next/server").NextRequest; + const res = await POST(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/invalid json/i); + }); + + it("returns 400 when body is an array", async () => { + const res = await POST(makePostRequest([1, 2] as unknown as object)); + expect(res.status).toBe(400); + }); + + it("returns 400 when recording_id is missing", async () => { + const res = await POST(makePostRequest({ stream_id: VALID_STREAM_ID })); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/recording_id/i); + }); + + it("returns 400 for invalid recording_id format", async () => { + const res = await POST( + makePostRequest({ recording_id: "bad", stream_id: VALID_STREAM_ID }) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/invalid recording_id/i); + }); + + it("returns 400 when stream_id is missing", async () => { + const res = await POST( + makePostRequest({ recording_id: VALID_RECORDING_ID }) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/stream_id/i); + }); + + it("returns 400 for invalid stream_id format", async () => { + const res = await POST( + makePostRequest({ recording_id: VALID_RECORDING_ID, stream_id: "bad" }) + ); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/invalid stream_id/i); + }); + + it("returns 404 when recording does not exist", async () => { + mockEnsureTables(); + sqlMock.mockResolvedValueOnce({ rows: [] }); // stream_recordings lookup + + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + stream_id: VALID_STREAM_ID, + }) + ); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toMatch(/not found/i); + }); + + it("returns 403 when caller does not own the recording", async () => { + mockEnsureTables(); + sqlMock.mockResolvedValueOnce({ + rows: [{ id: VALID_RECORDING_ID, user_id: "other-user" }], + }); + + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + stream_id: VALID_STREAM_ID, + }) + ); + expect(res.status).toBe(403); + }); + + it("returns 200 with imported=0 when stream has no chapters", async () => { + mockEnsureTables(); + sqlMock.mockResolvedValueOnce({ + rows: [{ id: VALID_RECORDING_ID, user_id: "user-123" }], + }); + sqlMock.mockResolvedValueOnce({ rows: [] }); // stream_chapters — empty + + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + stream_id: VALID_STREAM_ID, + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.imported).toBe(0); + expect(body.truncated).toBe(false); + }); + + it("returns 422 when recording is already at the 100-chapter cap", async () => { + mockEnsureTables(); + sqlMock.mockResolvedValueOnce({ + rows: [{ id: VALID_RECORDING_ID, user_id: "user-123" }], + }); + sqlMock.mockResolvedValueOnce({ + rows: [ + { title: "Ch1", timestamp_seconds: 0 }, + { title: "Ch2", timestamp_seconds: 60 }, + ], + }); + sqlMock.mockResolvedValueOnce({ rows: [{ count: "100" }] }); // existing chapters count + + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + stream_id: VALID_STREAM_ID, + }) + ); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.error).toMatch(/maximum/i); + }); + + it("imports all source chapters when under the cap", async () => { + mockEnsureTables(); + sqlMock.mockResolvedValueOnce({ + rows: [{ id: VALID_RECORDING_ID, user_id: "user-123" }], + }); + sqlMock.mockResolvedValueOnce({ + rows: [ + { title: "Intro", timestamp_seconds: 0 }, + { title: "Part 2", timestamp_seconds: 300 }, + ], + }); + sqlMock.mockResolvedValueOnce({ rows: [{ count: "0" }] }); // existing count + sqlMock.mockResolvedValueOnce({ rows: [] }); // INSERT ch 1 + sqlMock.mockResolvedValueOnce({ rows: [] }); // INSERT ch 2 + + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + stream_id: VALID_STREAM_ID, + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.imported).toBe(2); + expect(body.truncated).toBe(false); + expect(body.message).toMatch(/2 chapters/i); + }); + + it("truncates to available slots and sets truncated=true", async () => { + mockEnsureTables(); + sqlMock.mockResolvedValueOnce({ + rows: [{ id: VALID_RECORDING_ID, user_id: "user-123" }], + }); + // 3 source chapters + sqlMock.mockResolvedValueOnce({ + rows: [ + { title: "A", timestamp_seconds: 0 }, + { title: "B", timestamp_seconds: 60 }, + { title: "C", timestamp_seconds: 120 }, + ], + }); + sqlMock.mockResolvedValueOnce({ rows: [{ count: "99" }] }); // only 1 slot left + sqlMock.mockResolvedValueOnce({ rows: [] }); // INSERT ch A only + + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + stream_id: VALID_STREAM_ID, + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.imported).toBe(1); + expect(body.truncated).toBe(true); + + // Only 1 INSERT should have been made + const insertCalls = sqlMock.mock.calls.filter( + (call: unknown[]) => + typeof call[0] === "object" && + (call[0] as TemplateStringsArray)[0]?.includes( + "INSERT INTO vod_chapters" + ) + ); + expect(insertCalls).toHaveLength(1); + }); + + it("uses singular 'chapter' in message when importing exactly 1", async () => { + mockEnsureTables(); + sqlMock.mockResolvedValueOnce({ + rows: [{ id: VALID_RECORDING_ID, user_id: "user-123" }], + }); + sqlMock.mockResolvedValueOnce({ + rows: [{ title: "Solo", timestamp_seconds: 0 }], + }); + sqlMock.mockResolvedValueOnce({ rows: [{ count: "0" }] }); + sqlMock.mockResolvedValueOnce({ rows: [] }); // INSERT + + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + stream_id: VALID_STREAM_ID, + }) + ); + const body = await res.json(); + expect(body.message).toMatch(/1 chapter\b/); + expect(body.message).not.toMatch(/chapters/); + }); + + it("returns 500 on unexpected DB error", async () => { + mockEnsureTables(); + sqlMock.mockRejectedValueOnce(new Error("DB crash")); + + const res = await POST( + makePostRequest({ + recording_id: VALID_RECORDING_ID, + stream_id: VALID_STREAM_ID, + }) + ); + expect(res.status).toBe(500); + }); +}); diff --git a/app/api/routes-f/vod/chapters/import/route.ts b/app/api/routes-f/vod/chapters/import/route.ts new file mode 100644 index 00000000..b89006d9 --- /dev/null +++ b/app/api/routes-f/vod/chapters/import/route.ts @@ -0,0 +1,177 @@ +/** + * POST /api/routes-f/vod/chapters/import + * Bulk-import chapter markers from a live stream into a VOD recording. + * + * Body: { recording_id: string, stream_id: string } + * + * Copies all chapters from stream_chapters where stream_id matches, + * up to the per-recording cap of 100. Auth required; caller must own + * the recording. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { createRateLimiter } from "@/lib/rate-limit"; +import { verifySession } from "@/lib/auth/verify-session"; + +const isIpRateLimited = createRateLimiter(60_000, 5); + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const MAX_CHAPTERS_PER_RECORDING = 100; + +async function ensureTables(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS vod_chapters ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + recording_id VARCHAR NOT NULL, + title VARCHAR(255) NOT NULL, + timestamp_seconds INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; + await sql` + CREATE TABLE IF NOT EXISTS stream_chapters ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stream_id VARCHAR NOT NULL, + title VARCHAR(255) NOT NULL, + timestamp_seconds INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; +} + +export async function POST(req: NextRequest) { + const ip = + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + "unknown"; + + if (await isIpRateLimited(ip)) { + return NextResponse.json( + { error: "Too many requests" }, + { status: 429, headers: { "Retry-After": "60" } } + ); + } + + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + if (!body || typeof body !== "object" || Array.isArray(body)) { + return NextResponse.json( + { error: "Request body must be a JSON object" }, + { status: 400 } + ); + } + + const { recording_id, stream_id } = body as Record; + + if (typeof recording_id !== "string" || !recording_id) { + return NextResponse.json( + { error: "recording_id is required" }, + { status: 400 } + ); + } + if (!UUID_RE.test(recording_id)) { + return NextResponse.json( + { error: "Invalid recording_id format" }, + { status: 400 } + ); + } + if (typeof stream_id !== "string" || !stream_id) { + return NextResponse.json( + { error: "stream_id is required" }, + { status: 400 } + ); + } + if (!UUID_RE.test(stream_id)) { + return NextResponse.json( + { error: "Invalid stream_id format" }, + { status: 400 } + ); + } + + try { + await ensureTables(); + + // Verify recording exists and caller owns it + const { rows: recordings } = await sql` + SELECT id, user_id FROM stream_recordings WHERE id = ${recording_id} LIMIT 1 + `; + + if (recordings.length === 0) { + return NextResponse.json( + { error: "Recording not found" }, + { status: 404 } + ); + } + + if (recordings[0].user_id !== session.userId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + // Fetch source chapters ordered by timestamp + const { rows: sourceChapters } = await sql` + SELECT title, timestamp_seconds + FROM stream_chapters + WHERE stream_id = ${stream_id} + ORDER BY timestamp_seconds ASC + `; + + if (sourceChapters.length === 0) { + return NextResponse.json({ + imported: 0, + truncated: false, + message: "No chapters found for the specified stream", + }); + } + + // Determine how many slots remain under the cap + const { rows: countRows } = await sql` + SELECT COUNT(*) AS count FROM vod_chapters WHERE recording_id = ${recording_id} + `; + const existingCount = parseInt(countRows[0].count, 10); + const available = MAX_CHAPTERS_PER_RECORDING - existingCount; + + if (available <= 0) { + return NextResponse.json( + { + error: `Maximum of ${MAX_CHAPTERS_PER_RECORDING} chapters per recording already reached`, + }, + { status: 422 } + ); + } + + const chaptersToImport = sourceChapters.slice(0, available); + const truncated = sourceChapters.length > available; + + for (const ch of chaptersToImport) { + await sql` + INSERT INTO vod_chapters (recording_id, title, timestamp_seconds) + VALUES (${recording_id}, ${ch.title}, ${ch.timestamp_seconds}) + `; + } + + return NextResponse.json({ + imported: chaptersToImport.length, + truncated, + message: `Successfully imported ${chaptersToImport.length} chapter${chaptersToImport.length !== 1 ? "s" : ""}`, + }); + } catch (err) { + console.error("[vod/chapters/import] POST error:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/vod/chapters/route.ts b/app/api/routes-f/vod/chapters/route.ts new file mode 100644 index 00000000..d0b084cd --- /dev/null +++ b/app/api/routes-f/vod/chapters/route.ts @@ -0,0 +1,231 @@ +/** + * GET /api/routes-f/vod/chapters?recording_id= — list chapters for a VOD + * POST /api/routes-f/vod/chapters — add a chapter marker + * + * Constraints: + * - POST requires session auth; caller must own the recording + * - timestamp_seconds must be >= 0 and within the recording's duration + * - Max 100 chapters per recording + */ + +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { createRateLimiter } from "@/lib/rate-limit"; +import { verifySession } from "@/lib/auth/verify-session"; + +const isIpRateLimited = createRateLimiter(60_000, 5); + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const MAX_CHAPTERS_PER_RECORDING = 100; + +function getIp(req: NextRequest): string { + return ( + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + "unknown" + ); +} + +async function ensureChaptersTable(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS vod_chapters ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + recording_id VARCHAR NOT NULL, + title VARCHAR(255) NOT NULL, + timestamp_seconds INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; +} + +// ── GET /api/routes-f/vod/chapters?recording_id= ──────────────────────────── + +export async function GET(req: NextRequest) { + const ip = getIp(req); + if (await isIpRateLimited(ip)) { + return NextResponse.json( + { error: "Too many requests" }, + { status: 429, headers: { "Retry-After": "60" } } + ); + } + + const { searchParams } = new URL(req.url); + const recordingId = searchParams.get("recording_id"); + + if (!recordingId) { + return NextResponse.json( + { error: "recording_id is required" }, + { status: 400 } + ); + } + + if (!UUID_RE.test(recordingId)) { + return NextResponse.json( + { error: "Invalid recording_id format" }, + { status: 400 } + ); + } + + try { + await ensureChaptersTable(); + + const { rows: recordings } = await sql` + SELECT id FROM stream_recordings WHERE id = ${recordingId} LIMIT 1 + `; + + if (recordings.length === 0) { + return NextResponse.json( + { error: "Recording not found" }, + { status: 404 } + ); + } + + const { rows } = await sql` + SELECT id, recording_id, title, timestamp_seconds, created_at + FROM vod_chapters + WHERE recording_id = ${recordingId} + ORDER BY timestamp_seconds ASC + `; + + return NextResponse.json({ chapters: rows }); + } catch (err) { + console.error("[vod/chapters] GET error:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} + +// ── POST /api/routes-f/vod/chapters ───────────────────────────────────────── + +export async function POST(req: NextRequest) { + const ip = getIp(req); + if (await isIpRateLimited(ip)) { + return NextResponse.json( + { error: "Too many requests" }, + { status: 429, headers: { "Retry-After": "60" } } + ); + } + + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + if (!body || typeof body !== "object" || Array.isArray(body)) { + return NextResponse.json( + { error: "Request body must be a JSON object" }, + { status: 400 } + ); + } + + const { recording_id, title, timestamp_seconds } = body as Record< + string, + unknown + >; + + if (typeof recording_id !== "string" || !recording_id) { + return NextResponse.json( + { error: "recording_id is required" }, + { status: 400 } + ); + } + if (!UUID_RE.test(recording_id)) { + return NextResponse.json( + { error: "Invalid recording_id format" }, + { status: 400 } + ); + } + if (typeof title !== "string" || !title.trim()) { + return NextResponse.json( + { error: "title is required and must be a non-empty string" }, + { status: 400 } + ); + } + if ( + typeof timestamp_seconds !== "number" || + !Number.isInteger(timestamp_seconds) || + timestamp_seconds < 0 + ) { + return NextResponse.json( + { error: "timestamp_seconds must be a non-negative integer" }, + { status: 400 } + ); + } + + try { + await ensureChaptersTable(); + + // Verify recording exists and caller owns it; fetch duration for validation + const { rows: recordings } = await sql` + SELECT id, duration, user_id FROM stream_recordings + WHERE id = ${recording_id} + LIMIT 1 + `; + + if (recordings.length === 0) { + return NextResponse.json( + { error: "Recording not found" }, + { status: 404 } + ); + } + + const recording = recordings[0]; + + if (recording.user_id !== session.userId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + // Validate timestamp is within recording duration + const duration = + typeof recording.duration === "number" + ? recording.duration + : parseFloat(recording.duration); + if (!isNaN(duration) && timestamp_seconds > Math.floor(duration)) { + return NextResponse.json( + { + error: `timestamp_seconds (${timestamp_seconds}) exceeds recording duration (${Math.floor(duration)}s)`, + }, + { status: 422 } + ); + } + + // Enforce chapter cap + const { rows: countRows } = await sql` + SELECT COUNT(*) AS count FROM vod_chapters WHERE recording_id = ${recording_id} + `; + const currentCount = parseInt(countRows[0].count, 10); + if (currentCount >= MAX_CHAPTERS_PER_RECORDING) { + return NextResponse.json( + { + error: `Maximum of ${MAX_CHAPTERS_PER_RECORDING} chapters per recording reached`, + }, + { status: 422 } + ); + } + + const { rows } = await sql` + INSERT INTO vod_chapters (recording_id, title, timestamp_seconds) + VALUES (${recording_id}, ${title.trim()}, ${timestamp_seconds}) + RETURNING id, recording_id, title, timestamp_seconds, created_at + `; + + return NextResponse.json({ chapter: rows[0] }, { status: 201 }); + } catch (err) { + console.error("[vod/chapters] POST error:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/streams/[wallet]/route.ts b/app/api/streams/[wallet]/route.ts index c208ab8d..734ca038 100644 --- a/app/api/streams/[wallet]/route.ts +++ b/app/api/streams/[wallet]/route.ts @@ -32,6 +32,8 @@ export async function GET( u.creator, u.socialLinks, u.created_at, + u.stream_access_type, + u.stream_access_config, (SELECT COUNT(*)::int FROM user_follows WHERE followee_id = u.id) AS follower_count, -- Get latest session data ss.id as session_id, @@ -86,6 +88,8 @@ export async function GET( category: streamData.creator?.category || "", tags: streamData.creator?.tags || [], thumbnail: streamData.creator?.thumbnail || "", + stream_access_type: streamData.stream_access_type, + stream_access_config: streamData.stream_access_config, currentViewers: streamData.current_viewers || 0, totalViews: streamData.total_views || 0, diff --git a/app/api/streams/access/check/route.ts b/app/api/streams/access/check/route.ts new file mode 100644 index 00000000..80ae0534 --- /dev/null +++ b/app/api/streams/access/check/route.ts @@ -0,0 +1,190 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { checkTokenGatedAccess } from "@/lib/stream/access"; +import type { TokenGateConfig } from "@/types/stream-access"; + +type AccessType = "public" | "paid" | "password" | "invite" | "token_gated"; + +function jsonError(message: string, status: number) { + return NextResponse.json({ error: message }, { status }); +} + +function parseTokenGateConfig(raw: unknown): TokenGateConfig | null { + if (!raw || typeof raw !== "object") { + return null; + } + const o = raw as Record; + const asset_code = + typeof o.asset_code === "string" + ? o.asset_code + : typeof o.assetCode === "string" + ? o.assetCode + : null; + const min_balance = + typeof o.min_balance === "string" + ? o.min_balance + : typeof o.minBalance === "string" + ? o.minBalance + : null; + if (!asset_code || !min_balance) { + return null; + } + const issuer = + typeof o.issuer === "string" + ? o.issuer + : typeof o.asset_issuer === "string" + ? o.asset_issuer + : undefined; + return { asset_code, min_balance, issuer }; +} + +export async function POST(req: NextRequest) { + try { + let body: { + streamer_username?: string; + streamerUsername?: string; + viewer_public_key?: string | null; + }; + try { + body = await req.json(); + } catch { + return jsonError("Invalid JSON", 400); + } + + const streamer_username = + body.streamer_username ?? body.streamerUsername ?? undefined; + const viewer_public_key = body.viewer_public_key ?? null; + + if (!streamer_username || typeof streamer_username !== "string") { + return jsonError("streamer_username is required", 400); + } + + const session = await verifySession(req); + const viewerWallet = + session.ok && session.wallet + ? session.wallet + : typeof viewer_public_key === "string" + ? viewer_public_key + : null; + + const streamerResult = await sql` + SELECT id, wallet + FROM users + WHERE LOWER(username) = LOWER(${streamer_username}) + LIMIT 1 + `; + if (streamerResult.rows.length === 0) { + return jsonError("Streamer not found", 404); + } + const streamerId = streamerResult.rows[0].id as string; + const streamerWallet = (streamerResult.rows[0].wallet ?? null) as + | string + | null; + + const configResult = await sql` + SELECT access_type, config + FROM stream_access_config + WHERE streamer_id = ${streamerId} + LIMIT 1 + `; + + if (configResult.rows.length === 0) { + return NextResponse.json({ + allowed: true, + access_type: "public" as const, + }); + } + + const accessType = configResult.rows[0].access_type as AccessType; + const config = (configResult.rows[0].config ?? {}) as Record< + string, + unknown + >; + + if (accessType === "public") { + return NextResponse.json({ + allowed: true, + access_type: "public" as const, + }); + } + + if (accessType === "token_gated") { + const tg = parseTokenGateConfig(config); + if (!tg) { + console.warn( + `[access/check] token_gated misconfigured for ${streamer_username} — allowing` + ); + return NextResponse.json({ allowed: true, access_type: "token_gated" }); + } + + const result = await checkTokenGatedAccess(tg, viewerWallet); + if (result.allowed) { + return NextResponse.json({ + allowed: true, + access_type: "token_gated" as const, + }); + } + return NextResponse.json({ + allowed: false, + access_type: "token_gated" as const, + reason: result.reason, + asset_code: result.asset_code, + min_balance: result.min_balance, + }); + } + + if (accessType === "paid") { + const price_usdc = + typeof config.price_usdc === "string" ? config.price_usdc : null; + + if (!viewerWallet) { + return NextResponse.json({ + allowed: false, + access_type: "paid" as const, + reason: "wallet_required" as const, + price_usdc, + streamer_id: streamerId, + streamer_public_key: streamerWallet, + }); + } + + const grantResult = await sql` + SELECT g.id + FROM stream_access_grants g + JOIN users viewer ON viewer.id = g.viewer_id + WHERE g.streamer_id = ${streamerId} + AND g.access_type = 'paid' + AND viewer.wallet = ${viewerWallet} + LIMIT 1 + `; + + if (grantResult.rows.length > 0) { + return NextResponse.json({ + allowed: true, + access_type: "paid" as const, + }); + } + + return NextResponse.json({ + allowed: false, + access_type: "paid" as const, + reason: "paid" as const, + price_usdc, + streamer_id: streamerId, + streamer_public_key: streamerWallet, + }); + } + + return NextResponse.json({ + allowed: false, + access_type: accessType, + reason: accessType, + }); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : "Failed to check access" }, + { status: 500 } + ); + } +} diff --git a/app/api/streams/access/config/route.ts b/app/api/streams/access/config/route.ts new file mode 100644 index 00000000..f816fe38 --- /dev/null +++ b/app/api/streams/access/config/route.ts @@ -0,0 +1,119 @@ +import { NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; + +type AccessType = "public" | "paid" | "password" | "invite"; + +function isValidPrice(price: unknown): price is string { + return ( + typeof price === "string" && + /^\d+(\.\d{1,2})?$/.test(price) && + Number(price) >= 1 && + Number(price) <= 999 + ); +} + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const wallet = searchParams.get("wallet"); + if (!wallet) { + return NextResponse.json({ error: "wallet is required" }, { status: 400 }); + } + + const streamer = await sql` + SELECT id FROM users WHERE wallet = ${wallet} LIMIT 1 + `; + if (streamer.rows.length === 0) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + const streamerId = streamer.rows[0].id as string; + + const configRes = await sql` + SELECT access_type, config + FROM stream_access_config + WHERE streamer_id = ${streamerId} + LIMIT 1 + `; + + const access_type = (configRes.rows[0]?.access_type ?? "public") as AccessType; + const config = (configRes.rows[0]?.config ?? {}) as Record; + + const price_usdc = typeof config.price_usdc === "string" ? config.price_usdc : null; + + const paidStats = await sql` + SELECT + COUNT(*)::int AS paid_viewers, + COALESCE(SUM(NULLIF(amount_usdc, '')::numeric), 0)::text AS earned_usdc + FROM stream_access_grants + WHERE streamer_id = ${streamerId} + AND access_type = 'paid' + `; + + return NextResponse.json({ + access_type, + config: { price_usdc }, + stats: paidStats.rows[0] ?? { paid_viewers: 0, earned_usdc: "0" }, + }); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : "Failed to fetch access config" }, + { status: 500 } + ); + } +} + +export async function PATCH(req: Request) { + try { + const { wallet, access_type, price_usdc } = (await req.json()) as { + wallet?: string; + access_type?: AccessType; + price_usdc?: string; + }; + + if (!wallet) { + return NextResponse.json({ error: "wallet is required" }, { status: 400 }); + } + + if (!access_type || !["public", "paid"].includes(access_type)) { + return NextResponse.json( + { error: "access_type must be 'public' or 'paid'" }, + { status: 400 } + ); + } + + const streamer = await sql` + SELECT id FROM users WHERE wallet = ${wallet} LIMIT 1 + `; + if (streamer.rows.length === 0) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + const streamerId = streamer.rows[0].id as string; + + const config = + access_type === "paid" + ? (() => { + if (!isValidPrice(price_usdc)) { + throw new Error("price_usdc must be between 1 and 999 (2 decimals max)"); + } + return { price_usdc }; + })() + : {}; + + await sql` + INSERT INTO stream_access_config (streamer_id, access_type, config, updated_at) + VALUES (${streamerId}, ${access_type}, ${JSON.stringify(config)}, CURRENT_TIMESTAMP) + ON CONFLICT (streamer_id) DO UPDATE SET + access_type = EXCLUDED.access_type, + config = EXCLUDED.config, + updated_at = CURRENT_TIMESTAMP + `; + + return NextResponse.json({ ok: true }); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : "Failed to update access config" }, + { status: 500 } + ); + } +} + diff --git a/app/api/streams/access/password/route.ts b/app/api/streams/access/password/route.ts new file mode 100644 index 00000000..9d673df2 --- /dev/null +++ b/app/api/streams/access/password/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; + +export async function POST() { + return NextResponse.json( + { error: "Password-protected stream access is not configured." }, + { status: 501 } + ); +} diff --git a/app/api/streams/access/verify-asset/route.ts b/app/api/streams/access/verify-asset/route.ts new file mode 100644 index 00000000..552cd407 --- /dev/null +++ b/app/api/streams/access/verify-asset/route.ts @@ -0,0 +1,46 @@ +/** + * GET /api/streams/access/verify-asset?code=STREAM&issuer=GABC... + * + * Utility endpoint used by the dashboard "Verify asset" button. + * Checks Stellar Horizon to confirm the asset has been issued + * (has at least one trustline). + * + * No authentication required — read-only public Horizon data. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { verifyAssetExists, isValidAssetCode, isValidStellarIssuer } from "@/lib/stream/access"; + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const code = searchParams.get("code")?.trim() ?? ""; + const issuer = searchParams.get("issuer")?.trim() ?? ""; + + if (!code || !issuer) { + return NextResponse.json( + { error: "code and issuer query params are required" }, + { status: 400 } + ); + } + + if (!isValidAssetCode(code)) { + return NextResponse.json( + { error: "Invalid asset code. Must be 1–12 alphanumeric characters." }, + { status: 400 } + ); + } + + if (!isValidStellarIssuer(issuer)) { + return NextResponse.json( + { error: "Invalid issuer address. Must be a valid Stellar public key." }, + { status: 400 } + ); + } + + const exists = await verifyAssetExists(code, issuer); + + return NextResponse.json( + { exists }, + { headers: { "Cache-Control": "private, max-age=30" } } + ); +} diff --git a/app/api/streams/access/verify-payment/__tests__/route.test.ts b/app/api/streams/access/verify-payment/__tests__/route.test.ts new file mode 100644 index 00000000..dcab42b0 --- /dev/null +++ b/app/api/streams/access/verify-payment/__tests__/route.test.ts @@ -0,0 +1,158 @@ +/** + * Tests for POST /api/streams/access/verify-payment + * Mocks @vercel/postgres and Stellar Horizon calls. + */ + +// Polyfill NextResponse.json for jsdom test environment +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ + sql: jest.fn(), +})); + +const mockServerInstance = { + transactions: jest.fn(), + operations: jest.fn(), +}; + +jest.mock("@stellar/stellar-sdk", () => { + const actual = jest.requireActual("@stellar/stellar-sdk"); + return { + ...actual, + Horizon: { + Server: jest.fn(() => mockServerInstance), + }, + }; +}); + +import { sql } from "@vercel/postgres"; +import { Keypair } from "@stellar/stellar-sdk"; +import { POST } from "../route"; + +const sqlMock = sql as unknown as jest.Mock; + +const makeRequest = (body: object) => + new Request("http://localhost/api/streams/access/verify-payment", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) as unknown as import("next/server").NextRequest; + +const makeStellarKey = () => Keypair.random().publicKey(); + +describe("POST /api/streams/access/verify-payment", () => { + beforeEach(() => { + jest.clearAllMocks(); + process.env.NEXT_PUBLIC_STELLAR_NETWORK = "testnet"; + process.env.NEXT_PUBLIC_STELLAR_USDC_ISSUER_TESTNET = makeStellarKey(); + }); + + function mockHorizonTx(params: { + source: string; + memo: string; + }) { + mockServerInstance.transactions.mockReturnValue({ + transaction: () => ({ + call: async () => ({ + source_account: params.source, + memo_type: "text", + memo: params.memo, + }), + }), + }); + } + + function mockHorizonPaymentOp(params: { + to: string; + amount: string; + issuer: string; + }) { + mockServerInstance.operations.mockReturnValue({ + forTransaction: () => ({ + limit: () => ({ + call: async () => ({ + records: [ + { + type: "payment", + to: params.to, + amount: params.amount, + asset_type: "credit_alphanum4", + asset_code: "USDC", + asset_issuer: params.issuer, + }, + ], + }), + }), + }), + }); + } + + it("returns 400 when required fields missing", async () => { + const res = await POST(makeRequest({})); + expect(res.status).toBe(400); + }); + + it("rejects duplicate tx_hash (replay protection)", async () => { + const viewer = makeStellarKey(); + const streamerWallet = makeStellarKey(); + const streamerId = "streamer-uuid"; + + sqlMock + .mockResolvedValueOnce({ rows: [{ id: streamerId, wallet: streamerWallet }] }) // streamer + .mockResolvedValueOnce({ + rows: [{ access_type: "paid", config: { price_usdc: "25.00" } }], + }) // config + .mockResolvedValueOnce({ rows: [{ id: "grant-id" }] }); // dupe tx_hash + + const res = await POST( + makeRequest({ + streamer_username: "alice", + viewer_public_key: viewer, + tx_hash: "abc123", + }) + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + }); + + it("verifies payment and inserts grant on success", async () => { + const viewer = makeStellarKey(); + const streamerWallet = makeStellarKey(); + const streamerId = "streamer-uuid"; + const usdcIssuer = process.env.NEXT_PUBLIC_STELLAR_USDC_ISSUER_TESTNET!; + + sqlMock + .mockResolvedValueOnce({ rows: [{ id: streamerId, wallet: streamerWallet }] }) // streamer + .mockResolvedValueOnce({ + rows: [{ access_type: "paid", config: { price_usdc: "25.00" } }], + }) // config + .mockResolvedValueOnce({ rows: [] }) // dupe tx_hash + .mockResolvedValueOnce({ rows: [{ id: "viewer-uuid" }] }) // viewer + .mockResolvedValueOnce({ rows: [] }); // insert + + mockHorizonTx({ source: viewer, memo: `streamfi-access:${streamerId}` }); + mockHorizonPaymentOp({ to: streamerWallet, amount: "25.0000000", issuer: usdcIssuer }); + + const res = await POST( + makeRequest({ + streamer_username: "alice", + viewer_public_key: viewer, + tx_hash: "txhash123", + }) + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + }); +}); + diff --git a/app/api/streams/access/verify-payment/route.ts b/app/api/streams/access/verify-payment/route.ts new file mode 100644 index 00000000..4183e6a9 --- /dev/null +++ b/app/api/streams/access/verify-payment/route.ts @@ -0,0 +1,154 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import * as StellarSdk from "@stellar/stellar-sdk"; +import { getHorizonUrl, getStellarNetwork } from "@/lib/stellar/config"; +import { getUsdcAssetConfig } from "@/lib/stellar/usdc"; +import { parseStellarAmountToInt } from "@/lib/stellar/amount"; + +function isValidStellarPublicKey(key: unknown): key is string { + return typeof key === "string" && /^G[A-Z0-9]{55}$/.test(key); +} + +export async function POST(req: NextRequest) { + try { + const { streamer_username, viewer_public_key, tx_hash } = (await req.json()) as { + streamer_username?: string; + viewer_public_key?: string; + tx_hash?: string; + }; + + if (!streamer_username || !viewer_public_key || !tx_hash) { + return NextResponse.json( + { error: "streamer_username, viewer_public_key, and tx_hash are required" }, + { status: 400 } + ); + } + + if (!isValidStellarPublicKey(viewer_public_key)) { + return NextResponse.json({ error: "Invalid viewer_public_key" }, { status: 400 }); + } + + // Resolve streamer + access config + const streamerResult = await sql` + SELECT id, wallet + FROM users + WHERE LOWER(username) = LOWER(${streamer_username}) + LIMIT 1 + `; + if (streamerResult.rows.length === 0) { + return NextResponse.json({ error: "Streamer not found" }, { status: 404 }); + } + const streamerId = streamerResult.rows[0].id as string; + const streamerWallet = streamerResult.rows[0].wallet as string | null; + if (!isValidStellarPublicKey(streamerWallet)) { + return NextResponse.json( + { error: "Streamer has no valid Stellar wallet configured" }, + { status: 409 } + ); + } + + const configResult = await sql` + SELECT access_type, config + FROM stream_access_config + WHERE streamer_id = ${streamerId} + LIMIT 1 + `; + const accessType = (configResult.rows[0]?.access_type ?? "public") as string; + const config = (configResult.rows[0]?.config ?? {}) as Record; + + if (accessType !== "paid") { + return NextResponse.json({ error: "Stream is not configured as paid" }, { status: 409 }); + } + + const priceUsdc = typeof config.price_usdc === "string" ? config.price_usdc : null; + if (!priceUsdc) { + return NextResponse.json({ error: "Missing stream price" }, { status: 500 }); + } + + // Replay protection / idempotency: tx_hash cannot be reused + const dupe = await sql` + SELECT id FROM stream_access_grants WHERE tx_hash = ${tx_hash} LIMIT 1 + `; + if (dupe.rows.length > 0) { + // Idempotent success: if it's already been granted, treat as OK for retries. + return NextResponse.json({ ok: true }); + } + + // Resolve viewer user id (must exist to grant) + const viewerResult = await sql` + SELECT id FROM users WHERE wallet = ${viewer_public_key} LIMIT 1 + `; + if (viewerResult.rows.length === 0) { + return NextResponse.json({ error: "Viewer not found" }, { status: 404 }); + } + const viewerId = viewerResult.rows[0].id as string; + + // Verify on Stellar Horizon + const network = getStellarNetwork(); + const server = new StellarSdk.Horizon.Server(getHorizonUrl(network)); + + const tx = await server.transactions().transaction(tx_hash).call(); + + if (tx.source_account !== viewer_public_key) { + return NextResponse.json({ error: "Transaction source_account mismatch" }, { status: 400 }); + } + + const expectedMemo = `streamfi-access:${streamerId}`; + if (tx.memo_type !== "text" || tx.memo !== expectedMemo) { + return NextResponse.json({ error: "Transaction memo mismatch" }, { status: 400 }); + } + + const ops = await server.operations().forTransaction(tx_hash).limit(200).call(); + const payments = ops.records.filter((op: any) => op.type === "payment"); + if (payments.length === 0) { + return NextResponse.json({ error: "No payment operation found" }, { status: 400 }); + } + + const usdc = getUsdcAssetConfig(network); + const required = parseStellarAmountToInt(priceUsdc); + + const matching = payments.find((op: any) => { + if (op.to !== streamerWallet) { + return false; + } + if (op.asset_type !== "credit_alphanum4") { + return false; + } + if (op.asset_code !== usdc.code || op.asset_issuer !== usdc.issuer) { + return false; + } + try { + return parseStellarAmountToInt(String(op.amount)) >= required; + } catch { + return false; + } + }); + + if (!matching) { + return NextResponse.json( + { error: "Payment does not match destination/asset/amount requirements" }, + { status: 400 } + ); + } + + // Insert grant + try { + await sql` + INSERT INTO stream_access_grants (streamer_id, viewer_id, access_type, tx_hash, amount_usdc) + VALUES (${streamerId}, ${viewerId}, 'paid', ${tx_hash}, ${priceUsdc}) + `; + } catch { + // If we raced another request, the UNIQUE(tx_hash) or UNIQUE(streamer_id,viewer_id,access_type) + // will reject. Treat as idempotent success. + return NextResponse.json({ ok: true }); + } + + return NextResponse.json({ ok: true }); + } catch (e) { + return NextResponse.json( + { error: e instanceof Error ? e.message : "Failed to verify payment" }, + { status: 500 } + ); + } +} + diff --git a/app/api/streams/chat/__tests__/route.test.ts b/app/api/streams/chat/__tests__/route.test.ts index c05aaef7..04b17719 100644 --- a/app/api/streams/chat/__tests__/route.test.ts +++ b/app/api/streams/chat/__tests__/route.test.ts @@ -22,6 +22,7 @@ jest.mock("@vercel/postgres", () => ({ import { sql } from "@vercel/postgres"; import { POST, GET, DELETE } from "../route"; +import { Keypair } from "@stellar/stellar-sdk"; // Helper to build a minimal Request cast to NextRequest. // The route handlers only use standard Request APIs (json(), url) so this cast is safe. @@ -35,6 +36,7 @@ const makeRequest = (method: string, body?: object, search?: string) => const sqlMock = sql as unknown as jest.Mock; let consoleErrorSpy: jest.SpyInstance; +const makeStellarKey = () => Keypair.random().publicKey(); describe("POST /api/streams/chat", () => { beforeEach(() => { @@ -54,21 +56,39 @@ describe("POST /api/streams/chat", () => { }); it("returns 400 when playbackId is missing", async () => { - const req = makeRequest("POST", { wallet: "0xABC", content: "hello" }); + const req = makeRequest("POST", { + wallet: makeStellarKey(), + content: "hello", + }); const res = await POST(req); expect(res.status).toBe(400); }); it("returns 400 when content is missing", async () => { - const req = makeRequest("POST", { wallet: "0xABC", playbackId: "pb1" }); + const req = makeRequest("POST", { + wallet: makeStellarKey(), + playbackId: "pb1", + }); const res = await POST(req); expect(res.status).toBe(400); }); - it("returns 400 when message exceeds 500 characters", async () => { + it("returns 400 when wallet is not a Stellar public key", async () => { const req = makeRequest("POST", { wallet: "0xABC", playbackId: "pb1", + content: "hello", + }); + const res = await POST(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/stellar/i); + }); + + it("returns 400 when message exceeds 500 characters", async () => { + const req = makeRequest("POST", { + wallet: makeStellarKey(), + playbackId: "pb1", content: "a".repeat(501), }); const res = await POST(req); @@ -79,7 +99,7 @@ describe("POST /api/streams/chat", () => { it("returns 400 for invalid messageType", async () => { const req = makeRequest("POST", { - wallet: "0xABC", + wallet: makeStellarKey(), playbackId: "pb1", content: "hello", messageType: "shout", @@ -93,7 +113,7 @@ describe("POST /api/streams/chat", () => { it("returns 404 when user or stream not found", async () => { sqlMock.mockResolvedValueOnce({ rows: [] }); // combined query returns nothing const req = makeRequest("POST", { - wallet: "0xABC", + wallet: makeStellarKey(), playbackId: "pb1", content: "hello", }); @@ -113,7 +133,7 @@ describe("POST /api/streams/chat", () => { ], }); const req = makeRequest("POST", { - wallet: "0xABC", + wallet: makeStellarKey(), playbackId: "pb1", content: "hello", }); @@ -133,7 +153,7 @@ describe("POST /api/streams/chat", () => { ], }); const req = makeRequest("POST", { - wallet: "0xABC", + wallet: makeStellarKey(), playbackId: "pb1", content: "hello", }); @@ -142,18 +162,26 @@ describe("POST /api/streams/chat", () => { }); it("returns 201 and chatMessage on success", async () => { + const wallet = makeStellarKey(); sqlMock .mockResolvedValueOnce({ - // combined lookup + // combined lookup with moderation settings rows: [ { sender_id: 1, sender_username: "Alice", + streamer_id: 2, + streamer_username: "Bob", is_live: true, session_id: 10, + slow_mode_seconds: 0, + follower_only_chat: false, + link_blocking: false, }, ], }) + .mockResolvedValueOnce({ rows: [] }) // permanent ban check + .mockResolvedValueOnce({ rows: [] }) // timeout check .mockResolvedValueOnce({ // INSERT rows: [{ id: 99, created_at: "2025-01-01T00:00:00Z" }], @@ -161,7 +189,7 @@ describe("POST /api/streams/chat", () => { .mockResolvedValueOnce({ rows: [] }); // UPDATE total_messages const req = makeRequest("POST", { - wallet: "0xABC", + wallet, playbackId: "pb1", content: "hello", }); @@ -172,13 +200,31 @@ describe("POST /api/streams/chat", () => { expect(body.chatMessage.id).toBe(99); expect(body.chatMessage.content).toBe("hello"); expect(body.chatMessage.user.username).toBe("Alice"); - expect(body.chatMessage.user.wallet).toBe("0xABC"); + expect(body.chatMessage.user.wallet).toBe(wallet); }); it("returns 500 on unexpected database error", async () => { - sqlMock.mockRejectedValueOnce(new Error("DB down")); + sqlMock + .mockResolvedValueOnce({ + // combined lookup succeeds + rows: [ + { + sender_id: 1, + sender_username: "Alice", + streamer_id: 2, + streamer_username: "Bob", + is_live: true, + session_id: 10, + slow_mode_seconds: 0, + follower_only_chat: false, + link_blocking: false, + }, + ], + }) + .mockRejectedValueOnce(new Error("DB down")); // ban check fails + const req = makeRequest("POST", { - wallet: "0xABC", + wallet: makeStellarKey(), playbackId: "pb1", content: "hello", }); @@ -212,6 +258,7 @@ describe("GET /api/streams/chat", () => { }); it("returns messages for an active session", async () => { + const wallet = makeStellarKey(); sqlMock .mockResolvedValueOnce({ rows: [{ session_id: 10 }] }) // session lookup .mockResolvedValueOnce({ @@ -223,7 +270,7 @@ describe("GET /api/streams/chat", () => { message_type: "message", created_at: "2025-01-01T00:00:00Z", username: "Alice", - wallet: "0xABC", + wallet, avatar: null, }, ], @@ -274,7 +321,7 @@ describe("DELETE /api/streams/chat", () => { }); it("returns 400 when messageId is missing", async () => { - const req = makeRequest("DELETE", { moderatorWallet: "0xABC" }); + const req = makeRequest("DELETE", { moderatorWallet: makeStellarKey() }); const res = await DELETE(req); expect(res.status).toBe(400); }); @@ -289,7 +336,7 @@ describe("DELETE /api/streams/chat", () => { sqlMock.mockResolvedValueOnce({ rows: [] }); // moderator lookup const req = makeRequest("DELETE", { messageId: 42, - moderatorWallet: "0xABC", + moderatorWallet: makeStellarKey(), }); const res = await DELETE(req); expect(res.status).toBe(404); @@ -302,7 +349,7 @@ describe("DELETE /api/streams/chat", () => { const req = makeRequest("DELETE", { messageId: 42, - moderatorWallet: "0xABC", + moderatorWallet: makeStellarKey(), }); const res = await DELETE(req); expect(res.status).toBe(404); @@ -317,7 +364,7 @@ describe("DELETE /api/streams/chat", () => { const req = makeRequest("DELETE", { messageId: 42, - moderatorWallet: "0xABC", + moderatorWallet: makeStellarKey(), }); const res = await DELETE(req); expect(res.status).toBe(403); @@ -333,7 +380,7 @@ describe("DELETE /api/streams/chat", () => { const req = makeRequest("DELETE", { messageId: 42, - moderatorWallet: "0xSTREAMOWNER", + moderatorWallet: makeStellarKey(), }); const res = await DELETE(req); expect(res.status).toBe(200); @@ -349,7 +396,7 @@ describe("DELETE /api/streams/chat", () => { const req = makeRequest("DELETE", { messageId: 42, - moderatorWallet: "0xAUTHOR", + moderatorWallet: makeStellarKey(), }); const res = await DELETE(req); expect(res.status).toBe(200); @@ -359,7 +406,7 @@ describe("DELETE /api/streams/chat", () => { sqlMock.mockRejectedValueOnce(new Error("DB error")); const req = makeRequest("DELETE", { messageId: 42, - moderatorWallet: "0xABC", + moderatorWallet: makeStellarKey(), }); const res = await DELETE(req); expect(res.status).toBe(500); diff --git a/app/api/streams/chat/ban/[username]/route.ts b/app/api/streams/chat/ban/[username]/route.ts new file mode 100644 index 00000000..cb4cc629 --- /dev/null +++ b/app/api/streams/chat/ban/[username]/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ username: string }> } +) { + try { + const { username } = await params; + const { searchParams } = new URL(req.url); + const streamOwnerWallet = searchParams.get("streamOwnerWallet"); + + if (!streamOwnerWallet) { + return NextResponse.json( + { error: "Stream owner wallet is required" }, + { status: 400 } + ); + } + + // Get stream owner username + const ownerResult = await sql` + SELECT username FROM users WHERE wallet = ${streamOwnerWallet} + `; + + if (ownerResult.rows.length === 0) { + return NextResponse.json( + { error: "Stream owner not found" }, + { status: 404 } + ); + } + + const streamOwner = ownerResult.rows[0].username; + + // Delete the ban + const result = await sql` + DELETE FROM chat_bans + WHERE stream_owner = ${streamOwner} + AND banned_user = ${username} + `; + + if (result.rowCount === 0) { + return NextResponse.json({ error: "Ban not found" }, { status: 404 }); + } + + return NextResponse.json( + { message: "User unbanned successfully" }, + { status: 200 } + ); + } catch (error) { + console.error("Unban user error:", error); + return NextResponse.json( + { error: "Failed to unban user" }, + { status: 500 } + ); + } +} diff --git a/app/api/streams/chat/ban/route.ts b/app/api/streams/chat/ban/route.ts new file mode 100644 index 00000000..419f85e8 --- /dev/null +++ b/app/api/streams/chat/ban/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; + +export async function POST(req: NextRequest) { + try { + const { streamOwnerWallet, bannedUser, durationMinutes, reason } = + await req.json(); + + if (!streamOwnerWallet || !bannedUser) { + return NextResponse.json( + { error: "Stream owner wallet and banned user are required" }, + { status: 400 } + ); + } + + // Verify requester is the stream owner + const ownerResult = await sql` + SELECT username FROM users WHERE wallet = ${streamOwnerWallet} + `; + + if (ownerResult.rows.length === 0) { + return NextResponse.json( + { error: "Stream owner not found" }, + { status: 404 } + ); + } + + const streamOwner = ownerResult.rows[0].username; + + // Calculate expires_at for timeouts, null for permanent bans + let expiresAt: string | null = null; + if (durationMinutes && durationMinutes > 0) { + const now = new Date(); + const expiresDate = new Date(now.getTime() + durationMinutes * 60 * 1000); + expiresAt = expiresDate.toISOString(); + } + + // Insert or update ban record + await sql` + INSERT INTO chat_bans (stream_owner, banned_user, expires_at, reason) + VALUES (${streamOwner}, ${bannedUser}, ${expiresAt}, ${reason || null}) + ON CONFLICT (stream_owner, banned_user) + DO UPDATE SET + banned_at = now(), + expires_at = ${expiresAt}, + reason = ${reason || null} + `; + + return NextResponse.json( + { + message: expiresAt + ? `User timed out for ${durationMinutes} minute(s)` + : "User banned permanently", + bannedUser, + expiresAt, + }, + { status: 201 } + ); + } catch (error) { + console.error("Ban user error:", error); + return NextResponse.json({ error: "Failed to ban user" }, { status: 500 }); + } +} + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const streamOwnerWallet = searchParams.get("streamOwnerWallet"); + + if (!streamOwnerWallet) { + return NextResponse.json( + { error: "Stream owner wallet is required" }, + { status: 400 } + ); + } + + // Get stream owner username + const ownerResult = await sql` + SELECT username FROM users WHERE wallet = ${streamOwnerWallet} + `; + + if (ownerResult.rows.length === 0) { + return NextResponse.json( + { error: "Stream owner not found" }, + { status: 404 } + ); + } + + const streamOwner = ownerResult.rows[0].username; + + // Get active bans (permanent or not expired) + const bansResult = await sql` + SELECT + id, + banned_user, + banned_at, + expires_at, + reason + FROM chat_bans + WHERE stream_owner = ${streamOwner} + AND (expires_at IS NULL OR expires_at > now()) + ORDER BY banned_at DESC + `; + + return NextResponse.json({ bans: bansResult.rows }, { status: 200 }); + } catch (error) { + console.error("Get bans error:", error); + return NextResponse.json({ error: "Failed to get bans" }, { status: 500 }); + } +} diff --git a/app/api/streams/chat/route.ts b/app/api/streams/chat/route.ts index 788c7870..8312ca09 100644 --- a/app/api/streams/chat/route.ts +++ b/app/api/streams/chat/route.ts @@ -5,6 +5,10 @@ import { createRateLimiter } from "@/lib/rate-limit"; // 30 messages per minute per IP prevents chat spam const isRateLimited = createRateLimiter(60_000, 30); +function isValidStellarPublicKey(key: unknown): key is string { + return typeof key === "string" && /^G[A-Z0-9]{55}$/.test(key); +} + export async function POST(req: NextRequest) { const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? @@ -32,6 +36,13 @@ export async function POST(req: NextRequest) { ); } + if (!isValidStellarPublicKey(wallet)) { + return NextResponse.json( + { error: "Invalid Stellar public key" }, + { status: 400 } + ); + } + if (content.length > 500) { return NextResponse.json( { error: "Message must be 500 characters or less" }, @@ -46,13 +57,17 @@ export async function POST(req: NextRequest) { ); } - // Combined query: look up sender + stream + active session in one round-trip + // Combined query: look up sender + stream + active session + moderation settings in one round-trip const result = await sql` SELECT sender.id AS sender_id, sender.username AS sender_username, streamer.id AS streamer_id, + streamer.username AS streamer_username, streamer.is_live, + streamer.slow_mode_seconds, + streamer.follower_only_chat, + streamer.link_blocking, ( SELECT ss.id FROM stream_sessions ss WHERE ss.user_id = streamer.id AND ss.ended_at IS NULL @@ -71,7 +86,16 @@ export async function POST(req: NextRequest) { ); } - const { sender_id, sender_username, is_live, session_id } = result.rows[0]; + const { + sender_id, + sender_username, + streamer_username, + is_live, + session_id, + slow_mode_seconds, + follower_only_chat, + link_blocking, + } = result.rows[0]; if (!is_live) { return NextResponse.json( @@ -87,6 +111,107 @@ export async function POST(req: NextRequest) { ); } + // 1. Check for permanent ban + const permanentBanResult = await sql` + SELECT id FROM chat_bans + WHERE stream_owner = ${streamer_username} + AND banned_user = ${sender_username} + AND expires_at IS NULL + `; + + if (permanentBanResult.rows.length > 0) { + return NextResponse.json( + { error: "You are banned from this chat" }, + { status: 403 } + ); + } + + // 2. Check for active timeout + const timeoutResult = await sql` + SELECT expires_at FROM chat_bans + WHERE stream_owner = ${streamer_username} + AND banned_user = ${sender_username} + AND expires_at IS NOT NULL + AND expires_at > now() + `; + + if (timeoutResult.rows.length > 0) { + const expiresAt = new Date(timeoutResult.rows[0].expires_at); + const now = new Date(); + const secondsRemaining = Math.ceil( + (expiresAt.getTime() - now.getTime()) / 1000 + ); + + return NextResponse.json( + { + error: `You are timed out for ${Math.ceil(secondsRemaining / 60)} minute(s)`, + }, + { + status: 429, + headers: { "Retry-After": secondsRemaining.toString() }, + } + ); + } + + // 3. Check slow mode + if (slow_mode_seconds > 0) { + const lastMessageResult = await sql` + SELECT created_at FROM chat_messages + WHERE stream_session_id = ${session_id} + AND user_id = ${sender_id} + ORDER BY created_at DESC + LIMIT 1 + `; + + if (lastMessageResult.rows.length > 0) { + const lastMessageTime = new Date(lastMessageResult.rows[0].created_at); + const now = new Date(); + const secondsSinceLastMessage = + (now.getTime() - lastMessageTime.getTime()) / 1000; + + if (secondsSinceLastMessage < slow_mode_seconds) { + const waitSeconds = Math.ceil( + slow_mode_seconds - secondsSinceLastMessage + ); + return NextResponse.json( + { error: `Slow mode is enabled. Wait ${waitSeconds} second(s)` }, + { + status: 429, + headers: { "Retry-After": waitSeconds.toString() }, + } + ); + } + } + } + + // 4. Check follower-only mode + if (follower_only_chat) { + const followerResult = await sql` + SELECT id FROM users + WHERE username = ${sender_username} + AND ${streamer_username} = ANY(following) + `; + + if (followerResult.rows.length === 0) { + return NextResponse.json( + { error: "This chat is in follower-only mode" }, + { status: 403 } + ); + } + } + + // 5. Check link blocking + if (link_blocking) { + const urlRegex = + /(https?:\/\/[^\s]+)|(www\.[^\s]+)|([a-zA-Z0-9-]+\.(com|net|org|io|dev|gg|tv|me|co)[^\s]*)/gi; + if (urlRegex.test(content)) { + return NextResponse.json( + { error: "Links are not allowed in this chat" }, + { status: 400 } + ); + } + } + const messageResult = await sql` INSERT INTO chat_messages ( user_id, @@ -215,6 +340,13 @@ export async function DELETE(req: Request) { ); } + if (!isValidStellarPublicKey(moderatorWallet)) { + return NextResponse.json( + { error: "Invalid Stellar public key" }, + { status: 400 } + ); + } + const moderatorResult = await sql` SELECT id FROM users WHERE wallet = ${moderatorWallet} `; diff --git a/app/api/streams/delete/route.ts b/app/api/streams/delete/route.ts index 1bbe4052..d37d1da8 100644 --- a/app/api/streams/delete/route.ts +++ b/app/api/streams/delete/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { sql } from "@vercel/postgres"; import { deleteMuxStream } from "@/lib/mux/server"; import { verifySession } from "@/lib/auth/verify-session"; +import { aggregateAndFlushStreamReactions } from "@/app/api/routes-f/reactions/_lib/reactions"; export async function DELETE(req: NextRequest) { // Verify caller is authenticated — identity comes from the server-side session @@ -49,6 +50,18 @@ export async function DELETE(req: NextRequest) { } try { + const { rows: sessionRows } = await sql<{ id: string }>` + SELECT id + FROM stream_sessions + WHERE user_id = ${user.id} AND ended_at IS NULL + ORDER BY started_at DESC + LIMIT 1 + `; + + if (sessionRows.length > 0) { + await aggregateAndFlushStreamReactions(sessionRows[0].id); + } + await sql` UPDATE stream_sessions SET ended_at = CURRENT_TIMESTAMP diff --git a/app/api/streams/settings/route.ts b/app/api/streams/settings/route.ts new file mode 100644 index 00000000..ba8e4bc0 --- /dev/null +++ b/app/api/streams/settings/route.ts @@ -0,0 +1,131 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; + +export async function PATCH(req: NextRequest) { + try { + const { wallet, slowModeSeconds, followerOnlyChat, linkBlocking } = + await req.json(); + + if (!wallet) { + return NextResponse.json( + { error: "Wallet is required" }, + { status: 400 } + ); + } + + // Validate slow mode value + if ( + slowModeSeconds !== undefined && + ![0, 3, 5, 10, 30].includes(slowModeSeconds) + ) { + return NextResponse.json( + { error: "Invalid slow mode value. Must be 0, 3, 5, 10, or 30" }, + { status: 400 } + ); + } + + // Build update query dynamically based on provided fields + const updates: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (slowModeSeconds !== undefined) { + updates.push(`slow_mode_seconds = $${paramIndex}`); + values.push(slowModeSeconds); + paramIndex++; + } + + if (followerOnlyChat !== undefined) { + updates.push(`follower_only_chat = $${paramIndex}`); + values.push(followerOnlyChat); + paramIndex++; + } + + if (linkBlocking !== undefined) { + updates.push(`link_blocking = $${paramIndex}`); + values.push(linkBlocking); + paramIndex++; + } + + if (updates.length === 0) { + return NextResponse.json( + { error: "No settings provided to update" }, + { status: 400 } + ); + } + + values.push(wallet); + + const query = ` + UPDATE users + SET ${updates.join(", ")}, updated_at = CURRENT_TIMESTAMP + WHERE wallet = $${paramIndex} + RETURNING slow_mode_seconds, follower_only_chat, link_blocking + `; + + const result = await sql.query(query, values); + + if (result.rows.length === 0) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + return NextResponse.json( + { + message: "Settings updated successfully", + settings: { + slowModeSeconds: result.rows[0].slow_mode_seconds, + followerOnlyChat: result.rows[0].follower_only_chat, + linkBlocking: result.rows[0].link_blocking, + }, + }, + { status: 200 } + ); + } catch (error) { + console.error("Update settings error:", error); + return NextResponse.json( + { error: "Failed to update settings" }, + { status: 500 } + ); + } +} + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const wallet = searchParams.get("wallet"); + + if (!wallet) { + return NextResponse.json( + { error: "Wallet is required" }, + { status: 400 } + ); + } + + const result = await sql` + SELECT slow_mode_seconds, follower_only_chat, link_blocking + FROM users + WHERE wallet = ${wallet} + `; + + if (result.rows.length === 0) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + return NextResponse.json( + { + settings: { + slowModeSeconds: result.rows[0].slow_mode_seconds, + followerOnlyChat: result.rows[0].follower_only_chat, + linkBlocking: result.rows[0].link_blocking, + }, + }, + { status: 200 } + ); + } catch (error) { + console.error("Get settings error:", error); + return NextResponse.json( + { error: "Failed to get settings" }, + { status: 500 } + ); + } +} diff --git a/app/api/streams/start/route.ts b/app/api/streams/start/route.ts index 01d2546b..71c73dae 100644 --- a/app/api/streams/start/route.ts +++ b/app/api/streams/start/route.ts @@ -2,7 +2,10 @@ import { NextRequest, NextResponse } from "next/server"; import { sql } from "@vercel/postgres"; import { getMuxStreamHealth } from "@/lib/mux/server"; import { verifySession } from "@/lib/auth/verify-session"; -import { writeNotification } from "@/lib/notifications"; +import { + createLiveNotificationsForFollowers, + withNotificationTransaction, +} from "@/lib/notifications"; export async function POST(req: NextRequest) { // Verify the caller is logged in @@ -46,41 +49,64 @@ export async function POST(req: NextRequest) { console.error("Stream health check failed:", healthError); } - const result = await sql` - UPDATE users SET - is_live = true, - stream_started_at = CURRENT_TIMESTAMP, - current_viewers = 0, - updated_at = CURRENT_TIMESTAMP - WHERE id = ${user.id} - RETURNING id, username, mux_stream_id, mux_playback_id - `; + const updatedUser = await withNotificationTransaction(async client => { + const result = await client.sql<{ + id: string; + username: string; + mux_stream_id: string; + mux_playback_id: string | null; + stream_started_at: Date; + }>` + UPDATE users SET + is_live = true, + stream_started_at = CURRENT_TIMESTAMP, + current_viewers = 0, + updated_at = CURRENT_TIMESTAMP + WHERE id = ${user.id} AND is_live = false + RETURNING id, username, mux_stream_id, mux_playback_id, stream_started_at + `; - const updatedUser = result.rows[0]; + const updated = result.rows[0]; + if (!updated) { + throw new Error("Stream is already live"); + } + await client.sql` + INSERT INTO stream_sessions (user_id, mux_session_id, playback_id, started_at) + SELECT ${updated.id}, ${updated.mux_stream_id}, ${updated.mux_playback_id}, ${updated.stream_started_at.toISOString()} + WHERE NOT EXISTS ( + SELECT 1 FROM stream_sessions WHERE user_id = ${updated.id} AND ended_at IS NULL + ) + `; + + await createLiveNotificationsForFollowers({ + creatorId: updated.id, + creatorUsername: updated.username, + playbackId: updated.mux_playback_id, + dedupeKey: `stream-live:${updated.id}:${updated.stream_started_at.toISOString()}`, + client, + }); + + return updated; + }); + + // Activity event — non-blocking try { await sql` - INSERT INTO stream_sessions (user_id, mux_session_id, playback_id, started_at) - VALUES (${updatedUser.id}, ${updatedUser.mux_stream_id}, ${updatedUser.mux_playback_id}, CURRENT_TIMESTAMP) + INSERT INTO route_f_activity_events (user_id, type, metadata) + VALUES ( + ${updatedUser.id}, + 'stream_started', + ${JSON.stringify({ + stream_id: updatedUser.mux_stream_id, + playback_id: updatedUser.mux_playback_id, + })}::jsonb + ) `; - } catch (sessionError) { - console.error("Failed to create stream session record:", sessionError); + } catch (activityErr) { + console.error("[stream start] activity insert error:", activityErr); } - // Fire-and-forget live notifications to all followers via join table - sql`SELECT follower_id FROM user_follows WHERE followee_id = ${updatedUser.id}` - .then(({ rows }) => { - for (const { follower_id } of rows) { - writeNotification( - follower_id, - "live", - `${updatedUser.username} is live!`, - `${updatedUser.username} just started streaming` - ).catch(() => {}); - } - }) - .catch(() => {}); - return NextResponse.json( { message: "Stream started successfully", @@ -89,13 +115,19 @@ export async function POST(req: NextRequest) { streamId: updatedUser.mux_stream_id, playbackId: updatedUser.mux_playback_id, username: updatedUser.username, - startedAt: new Date().toISOString(), + startedAt: updatedUser.stream_started_at.toISOString(), }, }, { status: 200 } ); } catch (error) { console.error("Stream start error:", error); + if (error instanceof Error && error.message === "Stream is already live") { + return NextResponse.json( + { error: "Stream is already live" }, + { status: 409 } + ); + } return NextResponse.json( { error: "Failed to start stream" }, { status: 500 } @@ -148,6 +180,32 @@ export async function DELETE(req: NextRequest) { console.error("Failed to end stream session:", sessionError); } + // Activity event — non-blocking + try { + // Retrieve the just-ended session for duration and peak viewers + const { rows: sessionRows } = await sql` + SELECT duration_seconds, peak_viewers + FROM stream_sessions + WHERE user_id = ${user.id} + ORDER BY ended_at DESC NULLS LAST + LIMIT 1 + `; + const sess = sessionRows[0]; + await sql` + INSERT INTO route_f_activity_events (user_id, type, metadata) + VALUES ( + ${user.id}, + 'stream_ended', + ${JSON.stringify({ + duration_s: sess?.duration_seconds ?? 0, + peak_viewers: sess?.peak_viewers ?? 0, + })}::jsonb + ) + `; + } catch (activityErr) { + console.error("[stream stop] activity insert error:", activityErr); + } + return NextResponse.json( { message: "Stream stopped successfully" }, { status: 200 } diff --git a/app/api/tips/refresh-total/route.ts b/app/api/tips/refresh-total/route.ts index 001f4cfb..4ee27e03 100644 --- a/app/api/tips/refresh-total/route.ts +++ b/app/api/tips/refresh-total/route.ts @@ -1,9 +1,20 @@ // app/api/tips/refresh-total/route.ts +import { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { + createTipReceivedNotification, + withNotificationTransaction, +} from "@/lib/notifications"; import { fetchPaymentsReceived } from "@/lib/stellar/horizon"; -export async function POST(request: Request) { +export async function POST(request: NextRequest) { + const session = await verifySession(request); + if (!session.ok) { + return session.response; + } + try { const { username } = await request.json(); @@ -14,14 +25,11 @@ export async function POST(request: Request) { ); } - // TODO: Add authentication check here - // Verify that the requesting user is the owner or admin - // 1. Fetch user from database const userResult = await sql` - SELECT id, username, stellar_public_key + SELECT id, username, wallet AS stellar_public_key, last_tip_at FROM users - WHERE username = ${username} + WHERE LOWER(username) = LOWER(${username}) `; if (userResult.rows.length === 0) { @@ -29,6 +37,10 @@ export async function POST(request: Request) { } const user = userResult.rows[0]; + if (user.id !== session.userId) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + if (!user.stellar_public_key) { return NextResponse.json( { error: "User has not configured Stellar wallet" }, @@ -66,17 +78,38 @@ export async function POST(request: Request) { const totalCount = allTips.length; const lastTipAt = allTips.length > 0 ? allTips[0].timestamp : null; + const shouldCreateTipNotifications = Boolean(user.last_tip_at); + const newTips = shouldCreateTipNotifications + ? allTips.filter( + tip => + Date.parse(tip.timestamp) >= Date.parse(String(user.last_tip_at)) + ) + : []; // 4. Update database - await sql` - UPDATE users - SET - total_tips_received = ${totalReceived}, - total_tips_count = ${totalCount}, - last_tip_at = ${lastTipAt}, - updated_at = CURRENT_TIMESTAMP - WHERE id = ${user.id} - `; + await withNotificationTransaction(async client => { + await client.sql` + UPDATE users + SET + total_tips_received = ${totalReceived}, + total_tips_count = ${totalCount}, + last_tip_at = ${lastTipAt}, + updated_at = CURRENT_TIMESTAMP + WHERE id = ${user.id} + `; + + for (const tip of [...newTips].reverse()) { + await createTipReceivedNotification({ + userId: user.id, + amount: tip.amount, + senderLabel: tip.sender, + senderWallet: tip.sender, + txHash: tip.txHash, + paymentId: tip.id, + client, + }); + } + }); // 5. Return updated statistics return NextResponse.json({ diff --git a/app/api/users/[username]/route.ts b/app/api/users/[username]/route.ts index c055e5e9..98df5aa8 100644 --- a/app/api/users/[username]/route.ts +++ b/app/api/users/[username]/route.ts @@ -20,6 +20,7 @@ export async function GET( u.stream_started_at, u.total_views, u.total_tips_received, u.total_tips_count, u.last_tip_at, u.created_at, u.updated_at, + u.stream_access_type, u.stream_access_config, (SELECT COUNT(*)::int FROM user_follows WHERE followee_id = u.id) AS follower_count, (SELECT COUNT(*)::int FROM user_follows WHERE follower_id = u.id) AS following_count, EXISTS( @@ -42,6 +43,13 @@ export async function GET( // eslint-disable-next-line @typescript-eslint/no-unused-vars const { privy_id, email, ...publicUser } = user; + // Filter sensitive fields from stream_access_config + if (publicUser.stream_access_config?.password_hash) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { password_hash, ...safeConfig } = publicUser.stream_access_config; + publicUser.stream_access_config = safeConfig; + } + return NextResponse.json( { user: publicUser }, { diff --git a/app/api/users/follow/route.ts b/app/api/users/follow/route.ts index a15a494b..b6129c2b 100644 --- a/app/api/users/follow/route.ts +++ b/app/api/users/follow/route.ts @@ -2,7 +2,10 @@ import { NextRequest, NextResponse } from "next/server"; import { sql } from "@vercel/postgres"; import { verifySession } from "@/lib/auth/verify-session"; import { createRateLimiter } from "@/lib/rate-limit"; -import { writeNotification } from "@/lib/notifications"; +import { + createNotification, + withNotificationTransaction, +} from "@/lib/notifications"; // 30 follow/unfollow actions per IP per minute const isRateLimited = createRateLimiter(60_000, 30); @@ -60,22 +63,45 @@ export async function POST(req: NextRequest) { } if (action === "follow") { - await sql` - INSERT INTO user_follows (follower_id, followee_id) - VALUES (${callerId}, ${receiverId}) - ON CONFLICT DO NOTHING - `; + await withNotificationTransaction(async client => { + const followResult = await client.sql` + INSERT INTO user_follows (follower_id, followee_id) + VALUES (${callerId}, ${receiverId}) + ON CONFLICT DO NOTHING + RETURNING follower_id + `; + + if ((followResult.rowCount ?? 0) === 0) { + return; + } + + await createNotification({ + userId: receiverId, + type: "new_follower", + title: "New follower", + body: `${callerUsername} started following you`, + metadata: { + followerId: callerId, + followerUsername: callerUsername, + url: `/${callerUsername}`, + }, + client, + }); + }); - // Write notification — awaited so it completes before response is sent + // Activity event — non-blocking, must not break the follow operation try { - await writeNotification( - receiverId, - "follow", - "New follower", - `${callerUsername} started following you` - ); - } catch (notifErr) { - console.error("[follow] notification write failed:", notifErr); + await sql` + INSERT INTO route_f_activity_events (user_id, type, actor_id, metadata) + VALUES ( + ${receiverId}, + 'new_follower', + ${callerId}, + ${JSON.stringify({ username: callerUsername })}::jsonb + ) + `; + } catch (activityErr) { + console.error("[follow] activity insert error:", activityErr); } return NextResponse.json({ message: "Followed successfully" }); diff --git a/app/api/users/notifications/[id]/route.ts b/app/api/users/notifications/[id]/route.ts new file mode 100644 index 00000000..bd2642cb --- /dev/null +++ b/app/api/users/notifications/[id]/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { + deleteNotification, + markNotificationAsRead, +} from "@/lib/notifications"; + +const paramsSchema = z.object({ + id: z.string().uuid(), +}); + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const parsedParams = paramsSchema.safeParse(await params); + if (!parsedParams.success) { + return NextResponse.json( + { error: "Invalid notification id" }, + { status: 400 } + ); + } + + try { + const notification = await markNotificationAsRead( + session.userId, + parsedParams.data.id + ); + + if (!notification) { + return NextResponse.json( + { error: "Notification not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ notification }); + } catch (error) { + console.error("PATCH notification error:", error); + return NextResponse.json( + { error: "Failed to update notification" }, + { status: 500 } + ); + } +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const parsedParams = paramsSchema.safeParse(await params); + if (!parsedParams.success) { + return NextResponse.json( + { error: "Invalid notification id" }, + { status: 400 } + ); + } + + try { + const deleted = await deleteNotification( + session.userId, + parsedParams.data.id + ); + + if (!deleted) { + return NextResponse.json( + { error: "Notification not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ deleted: true }); + } catch (error) { + console.error("DELETE notification error:", error); + return NextResponse.json( + { error: "Failed to delete notification" }, + { status: 500 } + ); + } +} diff --git a/app/api/users/notifications/__tests__/routes.test.ts b/app/api/users/notifications/__tests__/routes.test.ts new file mode 100644 index 00000000..f3598d7b --- /dev/null +++ b/app/api/users/notifications/__tests__/routes.test.ts @@ -0,0 +1,188 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +jest.mock("@/lib/notifications", () => ({ + listNotifications: jest.fn(), + markNotificationAsRead: jest.fn(), + markAllNotificationsAsRead: jest.fn(), + deleteNotification: jest.fn(), + getNotificationPreferences: jest.fn(), + updateNotificationPreferences: jest.fn(), +})); + +import { GET as getNotifications } from "../route"; +import { + PATCH as patchNotification, + DELETE as deleteNotificationRoute, +} from "../[id]/route"; +import { PATCH as patchReadAll } from "../read-all/route"; +import { + GET as getPreferences, + PUT as putPreferences, +} from "../preferences/route"; +import { verifySession } from "@/lib/auth/verify-session"; +import { + deleteNotification, + getNotificationPreferences, + listNotifications, + markAllNotificationsAsRead, + markNotificationAsRead, + updateNotificationPreferences, +} from "@/lib/notifications"; + +const verifySessionMock = verifySession as jest.Mock; +const listNotificationsMock = listNotifications as jest.Mock; +const markNotificationAsReadMock = markNotificationAsRead as jest.Mock; +const markAllNotificationsAsReadMock = markAllNotificationsAsRead as jest.Mock; +const deleteNotificationMock = deleteNotification as jest.Mock; +const getNotificationPreferencesMock = getNotificationPreferences as jest.Mock; +const updateNotificationPreferencesMock = + updateNotificationPreferences as jest.Mock; + +const makeRequest = (method: string, path: string, body?: unknown) => + new Request(`http://localhost${path}`, { + method, + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; + +describe("notifications routes", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("lists notifications for the authenticated user", async () => { + verifySessionMock.mockResolvedValue({ ok: true, userId: "user-1" }); + listNotificationsMock.mockResolvedValue({ + notifications: [{ id: "n-1", title: "Test notification" }], + unreadCount: 2, + }); + + const response = await getNotifications( + makeRequest("GET", "/api/users/notifications?limit=5") + ); + + expect(response.status).toBe(200); + expect(listNotificationsMock).toHaveBeenCalledWith("user-1", 5); + await expect(response.json()).resolves.toEqual({ + notifications: [{ id: "n-1", title: "Test notification" }], + unreadCount: 2, + }); + }); + + it("rejects invalid notification list query params", async () => { + verifySessionMock.mockResolvedValue({ ok: true, userId: "user-1" }); + + const response = await getNotifications( + makeRequest("GET", "/api/users/notifications?limit=500") + ); + + expect(response.status).toBe(400); + }); + + it("marks a single notification as read", async () => { + verifySessionMock.mockResolvedValue({ ok: true, userId: "user-1" }); + markNotificationAsReadMock.mockResolvedValue({ id: "n-1", read: true }); + + const response = await patchNotification( + makeRequest( + "PATCH", + "/api/users/notifications/11111111-1111-1111-1111-111111111111" + ), + { + params: Promise.resolve({ + id: "11111111-1111-1111-1111-111111111111", + }), + } + ); + + expect(response.status).toBe(200); + expect(markNotificationAsReadMock).toHaveBeenCalledWith( + "user-1", + "11111111-1111-1111-1111-111111111111" + ); + }); + + it("marks all notifications as read", async () => { + verifySessionMock.mockResolvedValue({ ok: true, userId: "user-1" }); + markAllNotificationsAsReadMock.mockResolvedValue(4); + + const response = await patchReadAll( + makeRequest("PATCH", "/api/users/notifications/read-all") + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ updatedCount: 4 }); + }); + + it("deletes a notification", async () => { + verifySessionMock.mockResolvedValue({ ok: true, userId: "user-1" }); + deleteNotificationMock.mockResolvedValue(true); + + const response = await deleteNotificationRoute( + makeRequest( + "DELETE", + "/api/users/notifications/11111111-1111-1111-1111-111111111111" + ), + { + params: Promise.resolve({ + id: "11111111-1111-1111-1111-111111111111", + }), + } + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ deleted: true }); + }); + + it("returns notification preferences for the authenticated user", async () => { + verifySessionMock.mockResolvedValue({ ok: true, userId: "user-1" }); + getNotificationPreferencesMock.mockResolvedValue({ + newFollower: true, + tipReceived: true, + streamLive: false, + recordingReady: true, + emailNotifications: false, + }); + + const response = await getPreferences( + makeRequest("GET", "/api/users/notifications/preferences") + ); + + expect(response.status).toBe(200); + expect(getNotificationPreferencesMock).toHaveBeenCalledWith("user-1"); + }); + + it("updates notification preferences", async () => { + verifySessionMock.mockResolvedValue({ ok: true, userId: "user-1" }); + updateNotificationPreferencesMock.mockResolvedValue({ + newFollower: false, + tipReceived: true, + streamLive: true, + recordingReady: true, + emailNotifications: true, + }); + + const response = await putPreferences( + makeRequest("PUT", "/api/users/notifications/preferences", { + newFollower: false, + }) + ); + + expect(response.status).toBe(200); + expect(updateNotificationPreferencesMock).toHaveBeenCalledWith("user-1", { + newFollower: false, + }); + }); +}); diff --git a/app/api/users/notifications/preferences/route.ts b/app/api/users/notifications/preferences/route.ts new file mode 100644 index 00000000..abe3fc06 --- /dev/null +++ b/app/api/users/notifications/preferences/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { + getNotificationPreferences, + updateNotificationPreferences, +} from "@/lib/notifications"; + +const preferencesSchema = z.object({ + newFollower: z.boolean().optional(), + tipReceived: z.boolean().optional(), + streamLive: z.boolean().optional(), + recordingReady: z.boolean().optional(), + emailNotifications: z.boolean().optional(), +}); + +export async function GET(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + const preferences = await getNotificationPreferences(session.userId); + return NextResponse.json({ preferences }); + } catch (error) { + console.error("GET notification preferences error:", error); + return NextResponse.json( + { error: "Failed to fetch notification preferences" }, + { status: 500 } + ); + } +} + +export async function PUT(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + let payload: unknown; + + try { + payload = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = preferencesSchema.safeParse(payload); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid notification preferences" }, + { status: 400 } + ); + } + + try { + const preferences = await updateNotificationPreferences( + session.userId, + parsed.data + ); + return NextResponse.json({ preferences }); + } catch (error) { + console.error("PUT notification preferences error:", error); + return NextResponse.json( + { error: "Failed to update notification preferences" }, + { status: 500 } + ); + } +} diff --git a/app/api/users/notifications/read-all/route.ts b/app/api/users/notifications/read-all/route.ts new file mode 100644 index 00000000..9c8a81f3 --- /dev/null +++ b/app/api/users/notifications/read-all/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifySession } from "@/lib/auth/verify-session"; +import { markAllNotificationsAsRead } from "@/lib/notifications"; + +export async function PATCH(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + const updatedCount = await markAllNotificationsAsRead(session.userId); + return NextResponse.json({ updatedCount }); + } catch (error) { + console.error("PATCH read-all error:", error); + return NextResponse.json( + { error: "Failed to mark notifications as read" }, + { status: 500 } + ); + } +} diff --git a/app/api/users/notifications/route.ts b/app/api/users/notifications/route.ts index c3f84180..5336526a 100644 --- a/app/api/users/notifications/route.ts +++ b/app/api/users/notifications/route.ts @@ -1,96 +1,40 @@ import { NextRequest, NextResponse } from "next/server"; -import { sql } from "@vercel/postgres"; +import { z } from "zod"; import { verifySession } from "@/lib/auth/verify-session"; -import { writeNotification } from "@/lib/notifications"; +import { listNotifications } from "@/lib/notifications"; + +const querySchema = z.object({ + limit: z.coerce.number().int().min(1).max(50).optional(), +}); -// ─── GET — fetch caller's notifications ────────────────────────────────────── export async function GET(req: NextRequest) { const session = await verifySession(req); if (!session.ok) { return session.response; } - try { - const { rows } = await sql` - SELECT COALESCE(notifications, ARRAY[]::jsonb[]) AS notifications - FROM users - WHERE id = ${session.userId} - `; - - if (rows.length === 0) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); - } - - // notifications is JSONB[] — already parsed by @vercel/postgres into an array - const raw: Record[] = rows[0].notifications ?? []; - - // Newest-first, cap at 50 - const notifications = [...raw].reverse().slice(0, 50); - const unreadCount = notifications.filter(n => n.read === false).length; + const parsedQuery = querySchema.safeParse( + Object.fromEntries(new URL(req.url).searchParams.entries()) + ); - return NextResponse.json({ notifications, unreadCount }); - } catch (error) { - console.error("GET notifications error:", error); + if (!parsedQuery.success) { return NextResponse.json( - { error: "Failed to fetch notifications" }, - { status: 500 } - ); - } -} - -// ─── POST — internal server-to-server write only ───────────────────────────── -export async function POST(req: NextRequest) { - const internalSecret = process.env.INTERNAL_API_SECRET; - if ( - !internalSecret || - req.headers.get("x-internal-secret") !== internalSecret - ) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - const { recipientId, type, title, text } = await req.json(); - - if (!recipientId || !type || !title || !text) { - return NextResponse.json( - { error: "Missing required fields: recipientId, type, title, text" }, + { error: "Invalid query parameters" }, { status: 400 } ); } try { - await writeNotification(recipientId, type, title, text); - return NextResponse.json({ message: "Notification added" }); - } catch (error) { - console.error("POST notification error:", error); - return NextResponse.json( - { error: "Failed to add notification" }, - { status: 500 } + const { notifications, unreadCount } = await listNotifications( + session.userId, + parsedQuery.data.limit ?? 50 ); - } -} - -// ─── PATCH — mark all as read for caller ───────────────────────────────────── -export async function PATCH(req: NextRequest) { - const session = await verifySession(req); - if (!session.ok) { - return session.response; - } - - try { - await sql` - UPDATE users - SET notifications = ARRAY( - SELECT jsonb_set(n::jsonb, '{read}', 'true'::jsonb) - FROM unnest(COALESCE(notifications, ARRAY[]::jsonb[])) AS t(n) - ) - WHERE id = ${session.userId} - `; - return NextResponse.json({ message: "All notifications marked as read" }); + return NextResponse.json({ notifications, unreadCount }); } catch (error) { - console.error("PATCH notifications error:", error); + console.error("GET notifications error:", error); return NextResponse.json( - { error: "Failed to mark notifications as read" }, + { error: "Failed to fetch notifications" }, { status: 500 } ); } diff --git a/app/api/users/notifications/stream/route.ts b/app/api/users/notifications/stream/route.ts new file mode 100644 index 00000000..9c11ccba --- /dev/null +++ b/app/api/users/notifications/stream/route.ts @@ -0,0 +1,90 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifySession } from "@/lib/auth/verify-session"; +import { listUnreadNotificationsSince } from "@/lib/notifications"; +import type { NotificationCursor } from "@/types/notifications"; + +const encoder = new TextEncoder(); +const initialCursorId = "00000000-0000-0000-0000-000000000000"; + +function encodeSseChunk(payload: string) { + return encoder.encode(payload); +} + +export async function GET(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const stream = new ReadableStream({ + start(controller) { + let closed = false; + let cursor: NotificationCursor = { + createdAt: new Date().toISOString(), + id: initialCursorId, + }; + + const close = () => { + if (closed) { + return; + } + + closed = true; + clearInterval(intervalId); + req.signal.removeEventListener("abort", close); + controller.close(); + }; + + const poll = async () => { + try { + const notifications = await listUnreadNotificationsSince( + session.userId, + cursor, + 50 + ); + + if (notifications.length === 0) { + controller.enqueue(encodeSseChunk(": keep-alive\n\n")); + return; + } + + for (const notification of notifications) { + controller.enqueue( + encodeSseChunk(`data: ${JSON.stringify(notification)}\n\n`) + ); + } + + const lastNotification = notifications[notifications.length - 1]; + cursor = { + createdAt: lastNotification.createdAt, + id: lastNotification.id, + }; + } catch (error) { + console.error("Notification SSE polling error:", error); + controller.enqueue( + encodeSseChunk( + `event: error\ndata: ${JSON.stringify({ message: "Polling failed" })}\n\n` + ) + ); + } + }; + + controller.enqueue(encodeSseChunk(": connected\n\n")); + void poll(); + + const intervalId = setInterval(() => { + void poll(); + }, 5_000); + + req.signal.addEventListener("abort", close); + }, + }); + + return new NextResponse(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} diff --git a/app/api/users/register/route.ts b/app/api/users/register/route.ts index 03e6daac..b7280098 100644 --- a/app/api/users/register/route.ts +++ b/app/api/users/register/route.ts @@ -135,6 +135,7 @@ async function handler(req: NextRequest) { wallet, socialLinks, emailNotifications, + notification_preferences, creator, mux_stream_id, mux_playback_id, @@ -147,6 +148,13 @@ async function handler(req: NextRequest) { ${wallet}, ${JSON.stringify(socialLinks)}, ${emailNotifications}, + ${JSON.stringify({ + newFollower: true, + tipReceived: true, + streamLive: true, + recordingReady: true, + emailNotifications, + })}::jsonb, ${JSON.stringify(creator)}, ${muxStream?.id ?? null}, ${muxStream?.playbackId ?? null}, diff --git a/app/api/users/update-creator/route.ts b/app/api/users/update-creator/route.ts index af728c8a..07feeebd 100644 --- a/app/api/users/update-creator/route.ts +++ b/app/api/users/update-creator/route.ts @@ -1,10 +1,15 @@ import { NextResponse } from "next/server"; import { sql } from "@vercel/postgres"; +import { + isValidAssetCode, + isValidStellarIssuer, +} from "@/lib/stream/access"; +import type { StreamAccessType, TokenGateConfig } from "@/types/stream-access"; export async function PATCH(req: Request) { try { const body = await req.json(); - const { email, creator } = body; + const { email, creator, stream_access_type, stream_access_config } = body; if (!email || !creator) { return NextResponse.json( @@ -21,18 +26,71 @@ export async function PATCH(req: Request) { thumbnail = "", } = creator; - const updatedCreator = { - streamTitle, - tags, - category, - payout, - thumbnail, - }; + const updatedCreator = { streamTitle, tags, category, payout, thumbnail }; + + // ── Access control validation ─────────────────────────────────────────── + const accessType: StreamAccessType = stream_access_type ?? "public"; + if (accessType !== "public" && accessType !== "token_gated") { + return NextResponse.json( + { error: "Invalid stream_access_type. Must be 'public' or 'token_gated'." }, + { status: 400 } + ); + } + + let accessConfig: TokenGateConfig | null = null; + + if (accessType === "token_gated") { + const cfg = stream_access_config as Partial | undefined; + + if (!cfg?.asset_code || !cfg?.asset_issuer) { + return NextResponse.json( + { + error: + "stream_access_config.asset_code and stream_access_config.asset_issuer are required for token_gated streams.", + }, + { status: 400 } + ); + } + + if (!isValidAssetCode(cfg.asset_code)) { + return NextResponse.json( + { error: "Invalid asset_code. Must be 1–12 alphanumeric characters." }, + { status: 400 } + ); + } + + if (!isValidStellarIssuer(cfg.asset_issuer)) { + return NextResponse.json( + { + error: + "Invalid asset_issuer. Must be a valid Stellar public key (starts with G, 56 chars).", + }, + { status: 400 } + ); + } + + const minBalance = cfg.min_balance ?? "1"; + if (isNaN(parseFloat(minBalance)) || parseFloat(minBalance) <= 0) { + return NextResponse.json( + { error: "min_balance must be a positive number." }, + { status: 400 } + ); + } + + accessConfig = { + asset_code: cfg.asset_code, + asset_issuer: cfg.asset_issuer, + min_balance: minBalance, + }; + } const result = await sql` UPDATE users - SET creator = ${JSON.stringify(updatedCreator)}, - updated_at = CURRENT_TIMESTAMP + SET + creator = ${JSON.stringify(updatedCreator)}, + stream_access_type = ${accessType}, + stream_access_config = ${accessConfig ? JSON.stringify(accessConfig) : null}, + updated_at = CURRENT_TIMESTAMP WHERE email = ${email} `; diff --git a/app/api/users/updates/[wallet]/route.ts b/app/api/users/updates/[wallet]/route.ts index be28a996..a8c51cc7 100644 --- a/app/api/users/updates/[wallet]/route.ts +++ b/app/api/users/updates/[wallet]/route.ts @@ -167,6 +167,15 @@ export async function PUT( sociallinks = ${processedSocialLinks}, emailverified = ${emailVerified}, emailnotifications = ${emailNotifications}, + notification_preferences = jsonb_set( + COALESCE( + notification_preferences, + '{"newFollower":true,"tipReceived":true,"streamLive":true,"recordingReady":true,"emailNotifications":true}'::jsonb + ), + '{emailNotifications}', + to_jsonb(${emailNotifications}), + true + ), creator = ${creator ? JSON.stringify(creator) : user.creator}, enable_recording = ${enableRecording}, updated_at = CURRENT_TIMESTAMP diff --git a/app/api/wallet/onramp/order/route.ts b/app/api/wallet/onramp/order/route.ts new file mode 100644 index 00000000..34c4351a --- /dev/null +++ b/app/api/wallet/onramp/order/route.ts @@ -0,0 +1,88 @@ +/** + * POST /api/wallet/onramp/order + * + * Called by the client (useTransak hook) after a successful Transak order event. + * Upserts the order record in transak_orders tied to the authenticated user. + * + * This is a convenience path for client-initiated upserts. The authoritative + * record update comes via the server-side webhook at /api/webhooks/transak. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; + +export async function POST(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + let body: { + id?: string; + status?: string; + cryptoAmount?: number | null; + cryptoCurrency?: string; + fiatAmount?: number; + fiatCurrency?: string; + walletAddress?: string; + txHash?: string | null; + }; + + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const { + id, + status, + cryptoAmount, + cryptoCurrency, + fiatAmount, + fiatCurrency, + walletAddress, + txHash, + } = body; + + if (!id || typeof id !== "string" || id.trim() === "") { + return NextResponse.json({ error: "Missing order id" }, { status: 400 }); + } + if (!status || typeof status !== "string") { + return NextResponse.json({ error: "Missing order status" }, { status: 400 }); + } + + try { + await sql` + INSERT INTO transak_orders ( + id, user_id, status, crypto_amount, crypto_currency, + fiat_amount, fiat_currency, wallet_address, tx_hash, + created_at, updated_at + ) + VALUES ( + ${id}, + ${session.userId}, + ${status}, + ${cryptoAmount ?? null}, + ${cryptoCurrency ?? null}, + ${fiatAmount ?? null}, + ${fiatCurrency ?? null}, + ${walletAddress ?? null}, + ${txHash ?? null}, + now(), + now() + ) + ON CONFLICT (id) DO UPDATE SET + status = EXCLUDED.status, + crypto_amount = COALESCE(EXCLUDED.crypto_amount, transak_orders.crypto_amount), + tx_hash = COALESCE(EXCLUDED.tx_hash, transak_orders.tx_hash), + updated_at = now() + `; + + return NextResponse.json({ ok: true }); + } catch (err) { + console.error("[onramp/order] DB error:", err); + return NextResponse.json({ error: "Failed to save order" }, { status: 500 }); + } +} diff --git a/app/api/webhooks/mux/route.ts b/app/api/webhooks/mux/route.ts index 45a07f8f..5e3192f6 100644 --- a/app/api/webhooks/mux/route.ts +++ b/app/api/webhooks/mux/route.ts @@ -1,6 +1,12 @@ import { createHmac, timingSafeEqual } from "crypto"; import { NextResponse } from "next/server"; import { sql } from "@vercel/postgres"; +import { + createLiveNotificationsForFollowers, + createRecordingReadyNotification, + withNotificationTransaction, +} from "@/lib/notifications"; +import { aggregateAndFlushStreamReactions } from "@/app/api/routes-f/reactions/_lib/reactions"; /** * Mux Webhook Handler @@ -104,54 +110,68 @@ export async function POST(req: Request) { case "video.live_stream.active": { console.log(`🔴 Stream ACTIVE (broadcasting): ${streamId}`); - await sql` - UPDATE users SET - is_live = true, - stream_started_at = CURRENT_TIMESTAMP, - current_viewers = 0, - updated_at = CURRENT_TIMESTAMP - WHERE mux_stream_id = ${streamId} - `; + await withNotificationTransaction(async client => { + const userResult = await client.sql<{ + id: string; + is_live: boolean; + mux_playback_id: string | null; + creator: { title?: string; streamTitle?: string } | null; + }>` + SELECT id, is_live, mux_playback_id, creator + FROM users + WHERE mux_stream_id = ${streamId} + LIMIT 1 + `; - // Create stream session record — only one per broadcast - try { - const userResult = await sql` - SELECT id, mux_playback_id, creator FROM users WHERE mux_stream_id = ${streamId} + const user = userResult.rows[0]; + if (!user) { + console.warn("⚠️ No user found for Mux stream", streamId); + return; + } + + const updateResult = await client.sql<{ + id: string; + username: string; + mux_playback_id: string | null; + stream_started_at: Date; + }>` + UPDATE users SET + is_live = true, + stream_started_at = COALESCE(stream_started_at, CURRENT_TIMESTAMP), + current_viewers = 0, + updated_at = CURRENT_TIMESTAMP + WHERE id = ${user.id} + RETURNING id, username, mux_playback_id, stream_started_at `; - if (userResult.rows.length > 0) { - const user = userResult.rows[0]; + const updatedUser = updateResult.rows[0]; + const existingSession = await client.sql` + SELECT id FROM stream_sessions WHERE user_id = ${user.id} AND ended_at IS NULL LIMIT 1 + `; - // Dedup: skip if an active session already exists - const existingSession = await sql` - SELECT id FROM stream_sessions WHERE user_id = ${user.id} AND ended_at IS NULL LIMIT 1 - `; + if (existingSession.rows.length === 0) { + const streamTitle = + user.creator?.title || user.creator?.streamTitle || "Live Stream"; - if (existingSession.rows.length === 0) { - const streamTitle = - user.creator?.title || - user.creator?.streamTitle || - "Live Stream"; + await client.sql` + INSERT INTO stream_sessions (user_id, title, playback_id, started_at, mux_session_id) + VALUES (${user.id}, ${streamTitle}, ${user.mux_playback_id}, ${updatedUser.stream_started_at.toISOString()}, ${streamId}) + `; + console.log("✅ New stream session created"); + } else { + console.log("⏭️ Active session already exists, skipping creation"); + } - await sql` - INSERT INTO stream_sessions (user_id, title, playback_id, started_at, mux_session_id) - VALUES (${user.id}, ${streamTitle}, ${user.mux_playback_id}, CURRENT_TIMESTAMP, ${streamId}) - `; - console.log("✅ New stream session created"); - } else { - console.log( - "⏭️ Active session already exists, skipping creation" - ); - } + if (!user.is_live) { + await createLiveNotificationsForFollowers({ + creatorId: updatedUser.id, + creatorUsername: updatedUser.username, + playbackId: updatedUser.mux_playback_id, + dedupeKey: `stream-live:${updatedUser.id}:${updatedUser.stream_started_at.toISOString()}`, + client, + }); } - } catch (sessionError) { - console.error( - "⚠️ Failed to create stream session (non-critical):", - sessionError instanceof Error - ? sessionError.message - : String(sessionError) - ); - } + }); console.log(`✅ Stream marked as LIVE`); break; @@ -188,6 +208,18 @@ export async function POST(req: Request) { if (userResult.rows.length > 0) { const user = userResult.rows[0]; + const sessionResult = await sql<{ id: string }>` + SELECT id + FROM stream_sessions + WHERE user_id = ${user.id} AND ended_at IS NULL + ORDER BY started_at DESC + LIMIT 1 + `; + + if (sessionResult.rows.length > 0) { + await aggregateAndFlushStreamReactions(sessionResult.rows[0].id); + } + await sql` UPDATE stream_sessions SET ended_at = CURRENT_TIMESTAMP WHERE user_id = ${user.id} AND ended_at IS NULL @@ -234,60 +266,71 @@ export async function POST(req: Request) { } try { - let userId: string | null = null; - let streamSessionId: string | null = null; - let sessionTitle: string | null = "Stream Recording"; - - if (liveStreamId) { - const userResult = await sql` - SELECT id, mux_playback_id, creator FROM users WHERE mux_stream_id = ${liveStreamId} - `; - if (userResult.rows.length > 0) { - const u = userResult.rows[0]; - userId = u.id; - sessionTitle = - u.creator?.streamTitle ?? u.creator?.title ?? sessionTitle; - const sessionResult = await sql` - SELECT id FROM stream_sessions - WHERE user_id = ${u.id} AND ended_at IS NOT NULL - ORDER BY ended_at DESC LIMIT 1 + await withNotificationTransaction(async client => { + let userId: string | null = null; + let streamSessionId: string | null = null; + let sessionTitle: string | null = "Stream Recording"; + + if (liveStreamId) { + const userResult = await client.sql<{ + id: string; + creator: { streamTitle?: string; title?: string } | null; + }>` + SELECT id, creator FROM users WHERE mux_stream_id = ${liveStreamId} `; - if (sessionResult.rows.length > 0) { - streamSessionId = sessionResult.rows[0].id; + if (userResult.rows.length > 0) { + const u = userResult.rows[0]; + userId = u.id; + sessionTitle = + u.creator?.streamTitle ?? u.creator?.title ?? sessionTitle; + const sessionResult = await client.sql<{ id: string }>` + SELECT id FROM stream_sessions + WHERE user_id = ${u.id} AND ended_at IS NOT NULL + ORDER BY ended_at DESC LIMIT 1 + `; + if (sessionResult.rows.length > 0) { + streamSessionId = sessionResult.rows[0].id; + } } } - } - if (!userId) { - console.warn( - "⚠️ video.asset.ready: could not resolve user for asset", - assetId - ); - break; - } + if (!userId) { + console.warn( + "⚠️ video.asset.ready: could not resolve user for asset", + assetId + ); + return; + } + + await client.sql` + INSERT INTO stream_recordings ( + user_id, stream_session_id, mux_asset_id, playback_id, + title, duration, status, needs_review + ) + VALUES ( + ${userId}, + ${streamSessionId}, + ${assetId}, + ${playbackId}, + ${sessionTitle}, + ${duration ?? 0}, + 'ready', + true + ) + ON CONFLICT (mux_asset_id) DO UPDATE SET + status = 'ready', + duration = COALESCE(EXCLUDED.duration, stream_recordings.duration) + `; + + await createRecordingReadyNotification({ + userId, + title: "Recording ready", + recordingId: assetId, + playbackId, + client, + }); + }); - // Insert new recording with needs_review=true so the owner is prompted. - // ON CONFLICT: update status/duration only — preserve needs_review in case - // the user already dismissed or deleted the prompt. - await sql` - INSERT INTO stream_recordings ( - user_id, stream_session_id, mux_asset_id, playback_id, - title, duration, status, needs_review - ) - VALUES ( - ${userId}, - ${streamSessionId}, - ${assetId}, - ${playbackId}, - ${sessionTitle}, - ${duration ?? 0}, - 'ready', - true - ) - ON CONFLICT (mux_asset_id) DO UPDATE SET - status = 'ready', - duration = COALESCE(EXCLUDED.duration, stream_recordings.duration) - `; console.log(`✅ Stream recording saved: ${assetId}`); } catch (recErr) { console.error("❌ Failed to save stream recording:", recErr); diff --git a/app/api/webhooks/transak/route.ts b/app/api/webhooks/transak/route.ts new file mode 100644 index 00000000..51109ab6 --- /dev/null +++ b/app/api/webhooks/transak/route.ts @@ -0,0 +1,125 @@ +/** + * POST /api/webhooks/transak + * + * Receives server-side order status updates from Transak. + * Verifies the X-Transak-Signature HMAC-SHA256 header, then upserts + * the order into transak_orders. + * + * Setup: + * 1. In the Transak dashboard, set the webhook URL to: + * https:///api/webhooks/transak + * 2. Copy the webhook secret into TRANSAK_WEBHOOK_SECRET env var. + * + * Signature format (Transak): + * X-Transak-Signature: + */ + +import { createHmac, timingSafeEqual } from "crypto"; +import { NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import type { TransakWebhookPayload } from "@/types/transak"; + +function verifyTransakSignature( + signature: string, + rawBody: string, + secret: string +): boolean { + const expected = createHmac("sha256", secret) + .update(rawBody) + .digest("hex"); + + try { + return timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); + } catch { + return false; + } +} + +export async function POST(req: Request) { + const rawBody = await req.text(); + const signature = req.headers.get("x-transak-signature"); + const webhookSecret = process.env.TRANSAK_WEBHOOK_SECRET; + + if (webhookSecret) { + if (!signature) { + console.error("❌ [transak webhook] Missing X-Transak-Signature header"); + return NextResponse.json({ error: "Missing signature" }, { status: 401 }); + } + if (!verifyTransakSignature(signature, rawBody, webhookSecret)) { + console.error("❌ [transak webhook] Invalid signature"); + return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); + } + } else { + console.warn( + "⚠️ TRANSAK_WEBHOOK_SECRET not set — skipping signature verification (set it in production)" + ); + } + + let payload: TransakWebhookPayload; + try { + payload = JSON.parse(rawBody); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const order = payload?.webhookData; + if (!order?.id || !order?.status) { + console.error("❌ [transak webhook] Missing order data in payload", payload); + return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); + } + + console.log(`🔔 Transak webhook: order ${order.id} → ${order.status}`); + + try { + // Resolve the StreamFi user by wallet address so we can associate the order. + // wallet_address is the Stellar public key the user provided to Transak. + const userResult = await sql` + SELECT id FROM users WHERE wallet = ${order.walletAddress} LIMIT 1 + `; + const userId: string | null = + userResult.rows.length > 0 ? userResult.rows[0].id : null; + + await sql` + INSERT INTO transak_orders ( + id, user_id, status, crypto_amount, crypto_currency, + fiat_amount, fiat_currency, wallet_address, tx_hash, + created_at, updated_at + ) + VALUES ( + ${order.id}, + ${userId}, + ${order.status}, + ${order.cryptoAmount ?? null}, + ${order.cryptoCurrency ?? null}, + ${order.fiatAmount ?? null}, + ${order.fiatCurrency ?? null}, + ${order.walletAddress ?? null}, + ${order.transactionHash ?? null}, + now(), + now() + ) + ON CONFLICT (id) DO UPDATE SET + status = EXCLUDED.status, + crypto_amount = COALESCE(EXCLUDED.crypto_amount, transak_orders.crypto_amount), + tx_hash = COALESCE(EXCLUDED.tx_hash, transak_orders.tx_hash), + updated_at = now() + `; + + console.log(`✅ [transak webhook] Upserted order ${order.id}`); + return NextResponse.json({ received: true }); + } catch (err) { + console.error("❌ [transak webhook] DB error:", err); + return NextResponse.json( + { error: "Failed to process webhook" }, + { status: 500 } + ); + } +} + +// Health check +export async function GET() { + return NextResponse.json({ + status: "ok", + message: "Transak webhook endpoint is active", + }); +} diff --git a/app/browse/category/[title]/layout.tsx b/app/browse/category/[title]/layout.tsx new file mode 100644 index 00000000..4d50146c --- /dev/null +++ b/app/browse/category/[title]/layout.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from "next"; +import React from "react"; + +const BASE = process.env.NEXT_PUBLIC_APP_URL || "https://streamfi.com"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ title: string }>; +}): Promise { + const { title } = await params; + const decodedTitle = decodeURIComponent(title); + const canonical = `${BASE}/browse/category/${title}`; + + return { + title: `${decodedTitle} streams`, + description: `Watch the best ${decodedTitle} streams live on StreamFi`, + alternates: { + canonical, + }, + openGraph: { + title: `${decodedTitle} streams — StreamFi`, + description: `Watch the best ${decodedTitle} streams live on StreamFi`, + url: canonical, + type: "website", + }, + }; +} + +export default function CategoryTitleLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx index e18ca422..d918515e 100644 --- a/app/dashboard/layout.tsx +++ b/app/dashboard/layout.tsx @@ -8,11 +8,19 @@ import { ReactNode } from "react"; import ProtectedRoute from "@/components/auth/ProtectedRoute"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { HomeIcon, BarChart2, Video, Coins, Settings } from "lucide-react"; +import { + HomeIcon, + BarChart2, + Video, + Coins, + Settings, + Bell, +} from "lucide-react"; const mobileNavItems = [ { name: "Home", icon: HomeIcon, path: "/dashboard/home" }, { name: "Stream", icon: BarChart2, path: "/dashboard/stream-manager" }, + { name: "Alerts", icon: Bell, path: "/dashboard/notifications" }, { name: "Recordings", icon: Video, path: "/dashboard/recordings" }, { name: "Wallet", icon: Coins, path: "/dashboard/payout" }, { name: "Settings", icon: Settings, path: "/dashboard/settings" }, diff --git a/app/dashboard/notifications/page.tsx b/app/dashboard/notifications/page.tsx new file mode 100644 index 00000000..cf4935eb --- /dev/null +++ b/app/dashboard/notifications/page.tsx @@ -0,0 +1,5 @@ +import NotificationsPage from "@/components/notifications/NotificationsPage"; + +export default function DashboardNotificationsPage() { + return ; +} diff --git a/app/dashboard/payout/page.tsx b/app/dashboard/payout/page.tsx index 7ae35093..94b59fce 100644 --- a/app/dashboard/payout/page.tsx +++ b/app/dashboard/payout/page.tsx @@ -6,6 +6,8 @@ import useSWR from "swr"; import Link from "next/link"; import { motion, AnimatePresence } from "framer-motion"; import { TipCounter } from "@/components/tipping"; +import { AddFundsButton } from "@/components/wallet/AddFundsButton"; +import { TRANSAK_ORDER_COMPLETE_EVENT } from "@/hooks/useTransak"; import { Wallet, Copy, @@ -85,6 +87,15 @@ export default function PayoutPage() { return () => clearInterval(interval); }, [fetchPrice]); + // Refresh balance automatically after a completed Transak order + useEffect(() => { + const handler = () => { + void refreshBalance(); + }; + window.addEventListener(TRANSAK_ORDER_COMPLETE_EVENT, handler); + return () => window.removeEventListener(TRANSAK_ORDER_COMPLETE_EVENT, handler); + }, [refreshBalance]); + const handleCopy = () => { if (!walletAddress) { return; @@ -146,18 +157,27 @@ export default function PayoutPage() { Stellar Balance
- +
+ {walletAddress && ( + + )} + +
{/* Balance amount */} diff --git a/app/dashboard/stream-manager/page.tsx b/app/dashboard/stream-manager/page.tsx index e0dc166b..7deef372 100644 --- a/app/dashboard/stream-manager/page.tsx +++ b/app/dashboard/stream-manager/page.tsx @@ -9,6 +9,8 @@ import ActivityFeed from "@/components/dashboard/stream-manager/ActivityFeed"; import Chat from "@/components/dashboard/stream-manager/Chat"; import StreamInfo from "@/components/dashboard/stream-manager/StreamInfo"; import StreamSettings from "@/components/dashboard/stream-manager/StreamSettings"; +import StreamAccessSettings from "@/components/dashboard/stream-manager/StreamAccessSettings"; +import ChatModerationSettings from "@/components/dashboard/stream-manager/ChatModerationSettings"; import StreamInfoModal from "@/components/dashboard/common/StreamInfoModal"; import { motion } from "framer-motion"; import { Users, UserPlus, Coins, Timer } from "lucide-react"; @@ -26,6 +28,8 @@ export default function StreamManagerPage() { description: "", tags: [] as string[], thumbnail: null as string | null, + accessType: "public", + accessConfig: {} as any, }); const [isLoadingData, setIsLoadingData] = useState(true); const [isSaving, setIsSaving] = useState(false); @@ -79,6 +83,8 @@ export default function StreamManagerPage() { description: creator.description || "", tags: creator.tags || [], thumbnail: creator.thumbnail || null, + accessType: data.streamData?.stream?.stream_access_type || "public", + accessConfig: data.streamData?.stream?.stream_access_config || {}, }); } } catch (error) { @@ -136,13 +142,14 @@ export default function StreamManagerPage() { }); if (response.ok) { const result = await response.json(); - setStreamData({ + setStreamData(prev => ({ + ...prev, title: result.streamData.title || "", category: result.streamData.category || "", description: result.streamData.description || "", tags: result.streamData.tags || [], thumbnail: result.streamData.thumbnail || null, - }); + })); setIsStreamInfoModalOpen(false); showToast("Stream info updated!"); } else { @@ -156,6 +163,54 @@ export default function StreamManagerPage() { } }; + const handleAccessPolicyUpdate = async ( + accessType: string, + accessConfig: any + ) => { + if (!address) { + showToast("Wallet not connected"); + return; + } + const userEmail = + sessionStorage.getItem("userEmail") || privyWallet?.email || ""; + if (!userEmail) { + showToast("Session expired, please refresh"); + return; + } + + setIsSaving(true); + try { + const response = await fetch("/api/users/update-creator", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: userEmail, + creator: { + ...streamData, + stream_access_type: accessType, + stream_access_config: accessConfig, + }, + }), + }); + + if (response.ok) { + setStreamData({ + ...streamData, + accessType, + accessConfig, + }); + showToast("Access policy updated!"); + } else { + const err = await response.json(); + showToast(err.error || "Failed to update access policy"); + } + } catch { + showToast("Failed to update access policy"); + } finally { + setIsSaving(false); + } + }; + const showToast = (message: string) => { setToastMessage(message); setTimeout(() => setToastMessage(null), 3000); @@ -205,7 +260,7 @@ export default function StreamManagerPage() { - {/* Right column: Chat + Stream info + Tip wallet */} + {/* Right column: Chat + Stream info + Chat moderation + Tip wallet */}
@@ -217,6 +272,13 @@ export default function StreamManagerPage() { }} onEditClick={() => setIsStreamInfoModalOpen(true)} /> + +
diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 00000000..315a5b89 --- /dev/null +++ b/app/sitemap.ts @@ -0,0 +1,64 @@ +import { sql } from "@vercel/postgres"; +import type { MetadataRoute } from "next"; + +const BASE = process.env.NEXT_PUBLIC_APP_URL || "https://streamfi.com"; + +export default async function sitemap(): Promise { + const statics: MetadataRoute.Sitemap = [ + { + url: BASE, + lastModified: new Date(), + priority: 1.0, + changeFrequency: "daily", + }, + { + url: `${BASE}/browse`, + lastModified: new Date(), + priority: 0.8, + changeFrequency: "hourly", + }, + { + url: `${BASE}/explore`, + lastModified: new Date(), + priority: 0.8, + changeFrequency: "hourly", + }, + ]; + + let userRoutes: MetadataRoute.Sitemap = []; + let clipRoutes: MetadataRoute.Sitemap = []; + + try { + const { rows: userRows } = await sql` + SELECT username, updated_at + FROM users + ORDER BY created_at DESC + LIMIT 500 + `; + userRoutes = userRows.map(u => ({ + url: `${BASE}/${u.username}`, + lastModified: u.updated_at ? new Date(u.updated_at) : new Date(), + priority: 0.7, + changeFrequency: "daily" as const, + })); + + const { rows: clipRows } = await sql` + SELECT r.id, u.username, r.updated_at + FROM stream_recordings r + JOIN users u ON u.id = r.user_id + WHERE r.status = 'ready' + ORDER BY r.created_at DESC + LIMIT 1000 + `; + clipRoutes = clipRows.map(r => ({ + url: `${BASE}/${r.username}/clips/${r.id}`, + lastModified: r.updated_at ? new Date(r.updated_at) : new Date(), + priority: 0.5, + changeFrequency: "monthly" as const, + })); + } catch { + // Return statics on DB error to avoid breaking the sitemap + } + + return [...statics, ...userRoutes, ...clipRoutes]; +} diff --git a/components/dashboard/common/Sidebar.tsx b/components/dashboard/common/Sidebar.tsx index 07d616fc..0f5d9c2e 100644 --- a/components/dashboard/common/Sidebar.tsx +++ b/components/dashboard/common/Sidebar.tsx @@ -8,6 +8,7 @@ import { Settings, BarChartIcon as ChartColumnDecreasing, ArrowLeftToLine, + Bell, Video, Coins, } from "lucide-react"; @@ -37,6 +38,11 @@ export default function Sidebar({ icon: , path: "/dashboard/stream-manager", }, + { + name: "Notifications", + icon: , + path: "/dashboard/notifications", + }, { name: "Recordings", icon: