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/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/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/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/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/jobs/[id]/route.ts b/app/api/routes-f/jobs/[id]/route.ts new file mode 100644 index 00000000..697451d7 --- /dev/null +++ b/app/api/routes-f/jobs/[id]/route.ts @@ -0,0 +1,122 @@ +/** + * 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: { 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, + { params }: RouteParams +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const idError = validateId(params.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 = ${params.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, + { params }: RouteParams +): Promise { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const idError = validateId(params.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 = ${params.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 = ${params.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/rewards/__tests__/route.test.ts b/app/api/routes-f/rewards/__tests__/route.test.ts new file mode 100644 index 00000000..6ab24083 --- /dev/null +++ b/app/api/routes-f/rewards/__tests__/route.test.ts @@ -0,0 +1,90 @@ +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/db", () => ({ + ensureRewardsSchema: jest.fn(), + syncRewardEventsForUser: jest.fn(), + getRewardBalance: jest.fn(), +})); + +import { verifySession } from "@/lib/auth/verify-session"; +import { + ensureRewardsSchema, + getRewardBalance, + syncRewardEventsForUser, +} from "../_lib/db"; +import { GET } from "../route"; + +const verifySessionMock = verifySession as jest.Mock; +const ensureRewardsSchemaMock = ensureRewardsSchema as jest.Mock; +const syncRewardEventsForUserMock = syncRewardEventsForUser as jest.Mock; +const getRewardBalanceMock = getRewardBalance as jest.Mock; + +const authedSession = { + ok: true as const, + userId: "user-1", + wallet: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + privyId: null, + username: "viewer", + email: "viewer@example.com", +}; + +function makeRequest() { + return new Request("http://localhost/api/routes-f/rewards", { + method: "GET", + }) as unknown as import("next/server").NextRequest; +} + +describe("GET /api/routes-f/rewards", () => { + beforeEach(() => { + jest.clearAllMocks(); + verifySessionMock.mockResolvedValue(authedSession); + ensureRewardsSchemaMock.mockResolvedValue(undefined); + syncRewardEventsForUserMock.mockResolvedValue(undefined); + getRewardBalanceMock.mockResolvedValue({ + pointsBalance: 1250, + lifetimePoints: 1500, + tier: "Silver", + }); + }); + + it("returns 401 when unauthenticated", async () => { + verifySessionMock.mockResolvedValue({ + ok: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }), + }); + + const res = await GET(makeRequest()); + expect(res.status).toBe(401); + }); + + it("returns the synced balance and tier", async () => { + const res = await GET(makeRequest()); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(ensureRewardsSchemaMock).toHaveBeenCalled(); + expect(syncRewardEventsForUserMock).toHaveBeenCalledWith( + authedSession.userId, + authedSession.wallet + ); + expect(json).toEqual({ + points_balance: 1250, + lifetime_points: 1500, + tier: "Silver", + }); + }); +}); diff --git a/app/api/routes-f/rewards/_lib/db.ts b/app/api/routes-f/rewards/_lib/db.ts new file mode 100644 index 00000000..92565554 --- /dev/null +++ b/app/api/routes-f/rewards/_lib/db.ts @@ -0,0 +1,307 @@ +import { db, sql, type VercelPoolClient } from "@vercel/postgres"; + +export type RewardTier = "Bronze" | "Silver" | "Gold" | "Diamond"; + +export interface RewardDefinition { + id: string; + name: string; + cost: number; + description: string; +} + +export interface RewardEventRow { + id: string; + event_type: string; + points: number; + metadata: Record | null; + created_at: string | Date; +} + +export const REWARD_CATALOG: RewardDefinition[] = [ + { + id: "featured-chat-highlight", + name: "Featured Chat Highlight", + cost: 250, + description: "Pin one of your chat messages during a live stream.", + }, + { + id: "creator-shoutout", + name: "Creator Shoutout", + cost: 500, + description: "Redeem for a creator callout on a participating stream.", + }, + { + id: "vip-badge", + name: "VIP Badge", + cost: 1200, + description: "Unlock a loyalty badge for your account.", + }, +]; + +function getQuery(client?: VercelPoolClient) { + return client ? client.sql.bind(client) : sql; +} + +function sumInsertedPoints(rows: Array<{ points: number | string }>) { + return rows.reduce((total, row) => total + Number(row.points ?? 0), 0); +} + +async function tableExists(tableName: string, client?: VercelPoolClient) { + const query = getQuery(client); + const result = await query<{ exists: string | null }>` + SELECT to_regclass(${`public.${tableName}`})::text AS exists + `; + + return Boolean(result.rows[0]?.exists); +} + +export function getTier(points: number): RewardTier { + if (points >= 20_000) { + return "Diamond"; + } + if (points >= 5_000) { + return "Gold"; + } + if (points >= 1_000) { + return "Silver"; + } + return "Bronze"; +} + +export function getRewardDefinition(rewardId: string) { + return REWARD_CATALOG.find(reward => reward.id === rewardId) ?? null; +} + +export async function ensureRewardsSchema(client?: VercelPoolClient) { + const query = getQuery(client); + + await query` + CREATE EXTENSION IF NOT EXISTS pgcrypto + `; + + await query` + CREATE TABLE IF NOT EXISTS viewer_reward_balances ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + points_balance INTEGER NOT NULL DEFAULT 0, + lifetime_points INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + + await query` + CREATE TABLE IF NOT EXISTS viewer_reward_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + source_key TEXT NOT NULL UNIQUE, + event_type TEXT NOT NULL, + points INTEGER NOT NULL, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `; + + await query` + CREATE INDEX IF NOT EXISTS viewer_reward_events_user_created + ON viewer_reward_events (user_id, created_at DESC, id DESC) + `; +} + +export async function ensureRewardBalanceRow( + userId: string, + client?: VercelPoolClient +) { + const query = getQuery(client); + await query` + INSERT INTO viewer_reward_balances (user_id) + VALUES (${userId}) + ON CONFLICT (user_id) DO NOTHING + `; +} + +async function applyEarnedPoints( + userId: string, + rows: Array<{ points: number | string }>, + client?: VercelPoolClient +) { + const earnedPoints = sumInsertedPoints(rows); + if (earnedPoints <= 0) { + return 0; + } + + const query = getQuery(client); + await query` + UPDATE viewer_reward_balances + SET + points_balance = points_balance + ${earnedPoints}, + lifetime_points = lifetime_points + ${earnedPoints}, + updated_at = CURRENT_TIMESTAMP + WHERE user_id = ${userId} + `; + + return earnedPoints; +} + +export async function syncRewardEventsForUser( + userId: string, + wallet: string | null, + client?: VercelPoolClient +) { + const query = getQuery(client); + await ensureRewardBalanceRow(userId, client); + + if (await tableExists("stream_viewers", client)) { + const watchEvents = await query<{ points: number }>` + INSERT INTO viewer_reward_events ( + user_id, + source_key, + event_type, + points, + metadata, + created_at + ) + SELECT + ${userId}, + 'watch:' || sv.id::text, + 'watch', + FLOOR( + GREATEST( + EXTRACT(EPOCH FROM (COALESCE(sv.left_at, CURRENT_TIMESTAMP) - sv.joined_at)), + 0 + ) / 60 + )::int, + jsonb_build_object( + 'stream_session_id', sv.stream_session_id, + 'viewer_session_id', sv.session_id + ), + COALESCE(sv.left_at, sv.joined_at, sv.created_at) + FROM stream_viewers sv + WHERE sv.user_id = ${userId} + AND FLOOR( + GREATEST( + EXTRACT(EPOCH FROM (COALESCE(sv.left_at, CURRENT_TIMESTAMP) - sv.joined_at)), + 0 + ) / 60 + )::int > 0 + ON CONFLICT (source_key) DO NOTHING + RETURNING points + `; + await applyEarnedPoints(userId, watchEvents.rows, client); + } + + if (await tableExists("chat_messages", client)) { + const chatEvents = await query<{ points: number }>` + WITH ranked_messages AS ( + SELECT + cm.id, + cm.stream_session_id, + cm.created_at, + ROW_NUMBER() OVER ( + PARTITION BY cm.user_id, cm.stream_session_id + ORDER BY cm.created_at ASC, cm.id ASC + ) AS reward_rank + FROM chat_messages cm + WHERE cm.user_id = ${userId} + AND cm.is_deleted = FALSE + AND COALESCE(cm.message_type, 'message') IN ('message', 'emote') + ) + INSERT INTO viewer_reward_events ( + user_id, + source_key, + event_type, + points, + metadata, + created_at + ) + SELECT + ${userId}, + 'chat:' || rm.id::text, + 'chat', + 10, + jsonb_build_object( + 'chat_message_id', rm.id, + 'stream_session_id', rm.stream_session_id + ), + rm.created_at + FROM ranked_messages rm + WHERE rm.reward_rank <= 5 + ON CONFLICT (source_key) DO NOTHING + RETURNING points + `; + await applyEarnedPoints(userId, chatEvents.rows, client); + } + + if (wallet && (await tableExists("notifications", client))) { + const tipEvents = await query<{ points: number }>` + INSERT INTO viewer_reward_events ( + user_id, + source_key, + event_type, + points, + metadata, + created_at + ) + SELECT + ${userId}, + 'tip:' || n.id::text, + 'tip', + 100, + jsonb_build_object( + 'notification_id', n.id, + 'payment_id', n.metadata ->> 'paymentId', + 'tx_hash', n.metadata ->> 'txHash', + 'amount', n.metadata ->> 'amount', + 'recipient_user_id', n.user_id + ), + n.created_at + FROM notifications n + WHERE n.type = 'tip_received'::notification_type + AND n.metadata ->> 'senderWallet' = ${wallet} + ON CONFLICT (source_key) DO NOTHING + RETURNING points + `; + await applyEarnedPoints(userId, tipEvents.rows, client); + } +} + +export async function getRewardBalance( + userId: string, + client?: VercelPoolClient +) { + const query = getQuery(client); + const result = await query<{ + points_balance: number; + lifetime_points: number; + }>` + SELECT points_balance, lifetime_points + FROM viewer_reward_balances + WHERE user_id = ${userId} + LIMIT 1 + `; + + const balance = result.rows[0] ?? { points_balance: 0, lifetime_points: 0 }; + const pointsBalance = Number(balance.points_balance ?? 0); + + return { + pointsBalance, + lifetimePoints: Number(balance.lifetime_points ?? 0), + tier: getTier(pointsBalance), + }; +} + +export async function withRewardsTransaction( + callback: (client: VercelPoolClient) => Promise +) { + const client = await db.connect(); + + try { + await client.sql`BEGIN`; + const result = await callback(client); + await client.sql`COMMIT`; + return result; + } catch (error) { + await client.sql`ROLLBACK`; + throw error; + } finally { + client.release(); + } +} diff --git a/app/api/routes-f/rewards/history/__tests__/route.test.ts b/app/api/routes-f/rewards/history/__tests__/route.test.ts new file mode 100644 index 00000000..ac1e38dc --- /dev/null +++ b/app/api/routes-f/rewards/history/__tests__/route.test.ts @@ -0,0 +1,108 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +jest.mock("../../_lib/db", () => ({ + ensureRewardsSchema: jest.fn(), + syncRewardEventsForUser: jest.fn(), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { ensureRewardsSchema, syncRewardEventsForUser } from "../../_lib/db"; +import { GET } from "../route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; +const ensureRewardsSchemaMock = ensureRewardsSchema as jest.Mock; +const syncRewardEventsForUserMock = syncRewardEventsForUser as jest.Mock; + +const authedSession = { + ok: true as const, + userId: "user-1", + wallet: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + privyId: null, + username: "viewer", + email: "viewer@example.com", +}; + +function makeRequest(query = "") { + return new Request(`http://localhost/api/routes-f/rewards/history${query}`, { + method: "GET", + }) as unknown as import("next/server").NextRequest; +} + +describe("GET /api/routes-f/rewards/history", () => { + beforeEach(() => { + jest.clearAllMocks(); + verifySessionMock.mockResolvedValue(authedSession); + ensureRewardsSchemaMock.mockResolvedValue(undefined); + syncRewardEventsForUserMock.mockResolvedValue(undefined); + }); + + it("returns reward events in descending order", async () => { + sqlMock.mockResolvedValueOnce({ + rows: [ + { + id: "evt-2", + event_type: "redeem", + points: -250, + metadata: { reward_id: "featured-chat-highlight", quantity: 1 }, + created_at: "2026-03-28T10:00:00.000Z", + }, + { + id: "evt-1", + event_type: "watch", + points: 60, + metadata: { stream_session_id: "stream-1" }, + created_at: "2026-03-28T09:00:00.000Z", + }, + ], + }); + + const res = await GET(makeRequest("?limit=2")); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(syncRewardEventsForUserMock).toHaveBeenCalledWith( + authedSession.userId, + authedSession.wallet + ); + expect(json).toEqual({ + events: [ + { + id: "evt-2", + event_type: "redeem", + points: -250, + metadata: { reward_id: "featured-chat-highlight", quantity: 1 }, + created_at: "2026-03-28T10:00:00.000Z", + }, + { + id: "evt-1", + event_type: "watch", + points: 60, + metadata: { stream_session_id: "stream-1" }, + created_at: "2026-03-28T09:00:00.000Z", + }, + ], + next_cursor: "evt-1", + }); + }); + + it("returns 400 for invalid pagination params", async () => { + const res = await GET(makeRequest("?limit=0")); + expect(res.status).toBe(400); + }); +}); diff --git a/app/api/routes-f/rewards/history/route.ts b/app/api/routes-f/rewards/history/route.ts new file mode 100644 index 00000000..3331b2ba --- /dev/null +++ b/app/api/routes-f/rewards/history/route.ts @@ -0,0 +1,75 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { paginationSchema } from "@/app/api/routes-f/_lib/schemas"; +import { validateQuery } from "@/app/api/routes-f/_lib/validate"; +import { + ensureRewardsSchema, + syncRewardEventsForUser, + type RewardEventRow, +} from "../_lib/db"; + +export async function GET(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const { searchParams } = new URL(req.url); + const queryResult = validateQuery(searchParams, paginationSchema); + if (queryResult instanceof Response) { + return queryResult; + } + + const { limit, cursor } = queryResult.data; + + try { + await ensureRewardsSchema(); + await syncRewardEventsForUser(session.userId, session.wallet); + + const result = cursor + ? await sql` + SELECT id, event_type, points, metadata, created_at + FROM viewer_reward_events + WHERE user_id = ${session.userId} + AND created_at < ( + SELECT created_at + FROM viewer_reward_events + WHERE id = ${cursor} + LIMIT 1 + ) + ORDER BY created_at DESC, id DESC + LIMIT ${limit} + ` + : await sql` + SELECT id, event_type, points, metadata, created_at + FROM viewer_reward_events + WHERE user_id = ${session.userId} + ORDER BY created_at DESC, id DESC + LIMIT ${limit} + `; + + const nextCursor = + result.rows.length === limit ? result.rows[result.rows.length - 1].id : null; + + return NextResponse.json({ + events: result.rows.map(event => ({ + id: event.id, + event_type: event.event_type, + points: Number(event.points), + metadata: event.metadata ?? null, + created_at: + event.created_at instanceof Date + ? event.created_at.toISOString() + : new Date(event.created_at).toISOString(), + })), + next_cursor: nextCursor, + }); + } catch (error) { + console.error("[routes-f/rewards/history] GET error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/rewards/redeem/__tests__/route.test.ts b/app/api/routes-f/rewards/redeem/__tests__/route.test.ts new file mode 100644 index 00000000..0f28e839 --- /dev/null +++ b/app/api/routes-f/rewards/redeem/__tests__/route.test.ts @@ -0,0 +1,125 @@ +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/db", () => ({ + ensureRewardsSchema: jest.fn(), + ensureRewardBalanceRow: jest.fn(), + syncRewardEventsForUser: jest.fn(), + getRewardBalance: jest.fn(), + withRewardsTransaction: jest.fn(), + getRewardDefinition: jest.fn(), +})); + +import { verifySession } from "@/lib/auth/verify-session"; +import { + ensureRewardsSchema, + getRewardBalance, + getRewardDefinition, + withRewardsTransaction, +} from "../../_lib/db"; +import { POST } from "../route"; + +const verifySessionMock = verifySession as jest.Mock; +const ensureRewardsSchemaMock = ensureRewardsSchema as jest.Mock; +const getRewardBalanceMock = getRewardBalance as jest.Mock; +const withRewardsTransactionMock = withRewardsTransaction as jest.Mock; +const getRewardDefinitionMock = getRewardDefinition as jest.Mock; + +const authedSession = { + ok: true as const, + userId: "user-1", + wallet: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + privyId: null, + username: "viewer", + email: "viewer@example.com", +}; + +function makeRequest(body: object) { + return new Request("http://localhost/api/routes-f/rewards/redeem", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) as unknown as import("next/server").NextRequest; +} + +describe("POST /api/routes-f/rewards/redeem", () => { + beforeEach(() => { + jest.clearAllMocks(); + verifySessionMock.mockResolvedValue(authedSession); + ensureRewardsSchemaMock.mockResolvedValue(undefined); + getRewardDefinitionMock.mockReturnValue({ + id: "featured-chat-highlight", + name: "Featured Chat Highlight", + cost: 250, + }); + }); + + it("returns 404 when the reward does not exist", async () => { + getRewardDefinitionMock.mockReturnValue(null); + + const res = await POST( + makeRequest({ reward_id: "missing-reward", quantity: 1 }) + ); + + expect(res.status).toBe(404); + }); + + it("returns 409 when points are insufficient", async () => { + withRewardsTransactionMock.mockResolvedValue({ + ok: false, + balance: { + pointsBalance: 100, + tier: "Bronze", + }, + }); + + const res = await POST( + makeRequest({ reward_id: "featured-chat-highlight", quantity: 1 }) + ); + const json = await res.json(); + + expect(res.status).toBe(409); + expect(json).toEqual({ + error: "Insufficient points", + points_balance: 100, + tier: "Bronze", + }); + }); + + it("returns redemption details after a successful atomic spend", async () => { + withRewardsTransactionMock.mockResolvedValue({ + ok: true, + balance: { + pointsBalance: 750, + tier: "Bronze", + }, + }); + + const res = await POST( + makeRequest({ reward_id: "featured-chat-highlight", quantity: 1 }) + ); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(ensureRewardsSchemaMock).toHaveBeenCalled(); + expect(json).toEqual({ + reward_id: "featured-chat-highlight", + reward_name: "Featured Chat Highlight", + quantity: 1, + points_spent: 250, + points_balance: 750, + tier: "Bronze", + }); + }); +}); diff --git a/app/api/routes-f/rewards/redeem/route.ts b/app/api/routes-f/rewards/redeem/route.ts new file mode 100644 index 00000000..c8199a7a --- /dev/null +++ b/app/api/routes-f/rewards/redeem/route.ts @@ -0,0 +1,126 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { verifySession } from "@/lib/auth/verify-session"; +import { validateBody } from "@/app/api/routes-f/_lib/validate"; +import { + ensureRewardBalanceRow, + ensureRewardsSchema, + getRewardBalance, + getRewardDefinition, + syncRewardEventsForUser, + withRewardsTransaction, +} from "../_lib/db"; + +const redeemBodySchema = z.object({ + reward_id: z.string().min(1, "reward_id is required"), + quantity: z.coerce.number().int().min(1).max(100).default(1), +}); + +export async function POST(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const bodyResult = await validateBody(req, redeemBodySchema); + if (bodyResult instanceof Response) { + return bodyResult; + } + + const { reward_id, quantity } = bodyResult.data; + const reward = getRewardDefinition(reward_id); + if (!reward) { + return NextResponse.json({ error: "Reward not found" }, { status: 404 }); + } + + const totalCost = reward.cost * quantity; + + try { + await ensureRewardsSchema(); + + const redemption = await withRewardsTransaction(async client => { + await ensureRewardBalanceRow(session.userId, client); + + await client.sql` + SELECT user_id + FROM viewer_reward_balances + WHERE user_id = ${session.userId} + FOR UPDATE + `; + + await syncRewardEventsForUser(session.userId, session.wallet, client); + + const balance = await getRewardBalance(session.userId, client); + if (balance.pointsBalance < totalCost) { + return { + ok: false as const, + balance, + }; + } + + await client.sql` + UPDATE viewer_reward_balances + SET + points_balance = points_balance - ${totalCost}, + updated_at = CURRENT_TIMESTAMP + WHERE user_id = ${session.userId} + `; + + await client.sql` + INSERT INTO viewer_reward_events ( + user_id, + source_key, + event_type, + points, + metadata + ) + VALUES ( + ${session.userId}, + ${`redeem:${crypto.randomUUID()}`}, + 'redeem', + ${-totalCost}, + ${JSON.stringify({ + reward_id: reward.id, + reward_name: reward.name, + quantity, + unit_cost: reward.cost, + total_cost: totalCost, + })}::jsonb + ) + `; + + const updatedBalance = await getRewardBalance(session.userId, client); + + return { + ok: true as const, + balance: updatedBalance, + }; + }); + + if (!redemption.ok) { + return NextResponse.json( + { + error: "Insufficient points", + points_balance: redemption.balance.pointsBalance, + tier: redemption.balance.tier, + }, + { status: 409 } + ); + } + + return NextResponse.json({ + reward_id: reward.id, + reward_name: reward.name, + quantity, + points_spent: totalCost, + points_balance: redemption.balance.pointsBalance, + tier: redemption.balance.tier, + }); + } catch (error) { + console.error("[routes-f/rewards/redeem] POST error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/rewards/route.ts b/app/api/routes-f/rewards/route.ts new file mode 100644 index 00000000..aa9a034b --- /dev/null +++ b/app/api/routes-f/rewards/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifySession } from "@/lib/auth/verify-session"; +import { + ensureRewardsSchema, + getRewardBalance, + syncRewardEventsForUser, +} from "./_lib/db"; + +export async function GET(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + await ensureRewardsSchema(); + await syncRewardEventsForUser(session.userId, session.wallet); + + const balance = await getRewardBalance(session.userId); + + return NextResponse.json({ + points_balance: balance.pointsBalance, + lifetime_points: balance.lifetimePoints, + tier: balance.tier, + }); + } catch (error) { + console.error("[routes-f/rewards] GET 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/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..87043a1a --- /dev/null +++ b/app/api/streams/access/check/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; +import { checkStreamAccess } from "@/lib/stream/access"; + +/** + * Endpoint to check if a viewer has access to a stream. + * Called by the client before rendering the StreamPlayer. + * + * Request: + * { "streamer_username": "alice", "viewer_public_key": "GABC..." } + * + * Response (allowed): + * { "allowed": true } + * + * Response (blocked): + * { "allowed": false, "reason": "paid", "price_usdc": "10.00" } + */ +export async function POST(req: Request) { + try { + const body = await req.json(); + const { streamer_username, viewer_public_key } = body; + + if (!streamer_username) { + return NextResponse.json( + { error: "Streamer username is required" }, + { status: 400 } + ); + } + + const accessResult = await checkStreamAccess( + streamer_username, + viewer_public_key || null + ); + + if (accessResult.allowed) { + return NextResponse.json({ allowed: true }); + } + + // Build response for blocked access + const responseBody: any = { + allowed: false, + reason: accessResult.reason, + }; + + // Include config fields if available (e.g. price for paid streams) + if (accessResult.config) { + Object.assign(responseBody, accessResult.config); + } + + return NextResponse.json(responseBody); + } catch (error) { + console.error("API: Check stream access error:", error); + return NextResponse.json( + { error: "Failed to check stream access" }, + { 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/chat/__tests__/route.test.ts b/app/api/streams/chat/__tests__/route.test.ts index c05aaef7..b4e7909f 100644 --- a/app/api/streams/chat/__tests__/route.test.ts +++ b/app/api/streams/chat/__tests__/route.test.ts @@ -144,16 +144,23 @@ describe("POST /api/streams/chat", () => { it("returns 201 and chatMessage on success", async () => { 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" }], @@ -176,7 +183,25 @@ describe("POST /api/streams/chat", () => { }); 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", playbackId: "pb1", 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..f365ce07 100644 --- a/app/api/streams/chat/route.ts +++ b/app/api/streams/chat/route.ts @@ -46,13 +46,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 +75,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 +100,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, 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..5996d070 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,40 +49,46 @@ 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"); + } - try { - await sql` + await client.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) + 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 + ) `; - } catch (sessionError) { - console.error("Failed to create stream session record:", sessionError); - } - // 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(() => {}); + 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; + }); return NextResponse.json( { @@ -89,13 +98,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 } 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..15a0092f 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,23 +63,31 @@ 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; + } - // Write notification — awaited so it completes before response is sent - try { - await writeNotification( - receiverId, - "follow", - "New follower", - `${callerUsername} started following you` - ); - } catch (notifErr) { - console.error("[follow] notification write failed:", notifErr); - } + await createNotification({ + userId: receiverId, + type: "new_follower", + title: "New follower", + body: `${callerUsername} started following you`, + metadata: { + followerId: callerId, + followerUsername: callerUsername, + url: `/${callerUsername}`, + }, + client, + }); + }); return NextResponse.json({ message: "Followed successfully" }); } else { 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..39926840 100644 --- a/app/api/users/update-creator/route.ts +++ b/app/api/users/update-creator/route.ts @@ -19,6 +19,8 @@ export async function PATCH(req: Request) { category = "", payout = "", thumbnail = "", + stream_access_type, + stream_access_config, } = creator; const updatedCreator = { @@ -32,6 +34,8 @@ export async function PATCH(req: Request) { const result = await sql` UPDATE users SET creator = ${JSON.stringify(updatedCreator)}, + stream_access_type = COALESCE(${stream_access_type}, stream_access_type), + stream_access_config = COALESCE(${JSON.stringify(stream_access_config)}, stream_access_config), 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/webhooks/mux/route.ts b/app/api/webhooks/mux/route.ts index 45a07f8f..7636a046 100644 --- a/app/api/webhooks/mux/route.ts +++ b/app/api/webhooks/mux/route.ts @@ -1,6 +1,11 @@ import { createHmac, timingSafeEqual } from "crypto"; import { NextResponse } from "next/server"; import { sql } from "@vercel/postgres"; +import { + createLiveNotificationsForFollowers, + createRecordingReadyNotification, + withNotificationTransaction, +} from "@/lib/notifications"; /** * Mux Webhook Handler @@ -104,54 +109,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; @@ -234,60 +253,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/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/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/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: