import { NextRequest, NextResponse } from "next/server"; import type { SupabaseClient } from "@supabase/supabase-js"; import { z } from "zod"; import type { Database } from "../shared/database"; import { commentInput, communityReportInput, communityReviewInput, reactionInput, } from "../shared/community"; import { canReviewMaps } from "../shared/permissions"; import { browserAccount } from "./request-security"; import { ServiceError, checkServiceError } from "./service-error"; type Dependencies = { db: SupabaseClient; getUser: () => Promise<{ id: string } | null>; getProfile: (id: string) => Promise<{ id: string; role: string }>; jsonBody: (request: NextRequest) => Promise; limit: (key: string, count: number, seconds: number) => Promise; }; const id = (value: string) => z.string().uuid().parse(value); const cursorInput = z.tuple([ z.string().datetime({ offset: true }), z.string().uuid(), ]); const cursorFor = (row: { created_at: string; id: string }) => `${row.created_at}|${row.id}`; function cursorFilter(request: NextRequest, direction: "lt" | "gt") { const value = request.nextUrl.searchParams.get("cursor"); if (!value) return null; const [created, rowId] = cursorInput.parse(value.split("|")); return `created_at.${direction}.${created},and(created_at.eq.${created},id.${direction}.${rowId})`; } function rows(result: { data: T; error: { message: string; code?: string } | null; }): NonNullable { checkServiceError(result.error); if (result.data == null) throw new ServiceError("Content not found", 404); return result.data; } const respond = (body: unknown, status = 200) => NextResponse.json(body, { status, headers: { "Cache-Control": "no-store" } }); export async function communityApi( request: NextRequest, path: string[], { db, getUser, getProfile, jsonBody, limit }: Dependencies, ) { const method = request.method; async function account(staff = false) { const current = await browserAccount(request, getUser); const profile = await getProfile(current.id); if (staff && !canReviewMaps(profile.role)) throw new ServiceError("Moderator permission required", 403); return profile; } async function published(datasetId: string) { rows( await db .from("datasets") .select("id") .eq("id", datasetId) .eq("state", "published") .maybeSingle(), ); } if ( path[0] === "maps" && path[2] === "community" && path.length === 3 && method === "GET" ) { const datasetId = id(path[1]); await published(datasetId); const current = await getUser(); const profile = current ? await getProfile(current.id) : null; const filter = cursorFilter(request, "lt"); let commentQuery = db .from("map_comments") .select("id,body,created_at,author_id") .eq("dataset_id", datasetId) .is("removed_at", null) .order("created_at", { ascending: false }) .order("id", { ascending: false }) .limit(31); if (filter) commentQuery = commentQuery.or(filter); const [summary, comments, reaction] = await Promise.all([ db.rpc("map_reaction_summary", { p_dataset: datasetId }), commentQuery, current ? db .from("map_reactions") .select("value") .eq("dataset_id", datasetId) .eq("user_id", current.id) .maybeSingle() : Promise.resolve({ data: null, error: null }), ]); const items = rows(comments); const authors = [ ...new Set(items.flatMap((c) => (c.author_id ? [c.author_id] : []))), ]; const profiles = authors.length ? rows( await db .from("profiles") .select( "id,display_name,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon", ) .in("id", authors), ) : []; const byId = new Map(profiles.map((p) => [p.id, p])); checkServiceError(reaction.error); const totals = rows(summary)[0]; return respond({ likes: Number(totals?.likes ?? 0), dislikes: Number(totals?.dislikes ?? 0), reaction: reaction.data?.value ?? 0, viewer: profile ? { id: profile.id, canModerate: canReviewMaps(profile.role) } : null, comments: items.slice(0, 30).map((c) => ({ ...c, author: byId.get(c.author_id ?? "")?.display_name ?? "Deleted player", appearance: byId.get(c.author_id ?? ""), })), nextCursor: items.length > 30 ? cursorFor(items[29]) : null, }); } if ( path[0] === "maps" && path.length === 3 && ["reaction", "comments"].includes(path[2]) && method === "POST" ) { const current = await account(); const datasetId = id(path[1]); await published(datasetId); if (path[2] === "reaction") { const body = reactionInput.parse(await jsonBody(request)); await limit("community:reaction:" + current.id, 120, 60); const result = body.value === 0 ? await db .from("map_reactions") .delete() .eq("dataset_id", datasetId) .eq("user_id", current.id) : await db.from("map_reactions").upsert( { dataset_id: datasetId, user_id: current.id, value: body.value, }, { onConflict: "dataset_id,user_id" }, ); checkServiceError(result.error); return respond({ ok: true }); } const body = commentInput.parse(await jsonBody(request)); await limit("community:comment:" + current.id, 10, 600); rows( await db .from("map_comments") .insert({ dataset_id: datasetId, author_id: current.id, body: body.body, }) .select("id") .single(), ); return respond({ ok: true }, 201); } if ( path[0] === "community" && path[1] === "comments" && path.length === 3 && method === "DELETE" ) { const reviewer = await account(true); const result = await db .from("map_comments") .update({ removed_at: new Date().toISOString(), removed_by: reviewer.id }) .eq("id", id(path[2])) .is("removed_at", null) .select("id") .maybeSingle(); rows(result); return respond({ ok: true }); } if (path.join("/") === "community/reports" && method === "POST") { const current = await account(); const body = communityReportInput.parse(await jsonBody(request)); await limit("community:report:" + current.id, 10, 3600); if (body.target === "comment") { const comment = rows( await db .from("map_comments") .select("dataset_id") .eq("id", body.targetId) .is("removed_at", null) .maybeSingle(), ); await published(comment.dataset_id); } else { if (body.targetId === current.id) throw new ServiceError("You cannot report your own account", 400); rows( await db .from("profiles") .select("id") .eq("id", body.targetId) .maybeSingle(), ); } const result = await db.from("community_reports").insert({ reporter_id: current.id, target: body.target, comment_id: body.target === "comment" ? body.targetId : null, account_id: body.target === "account" ? body.targetId : null, category: body.category, reason: body.reason, }); if (result.error?.code === "23505") throw new ServiceError( "You already have an open report for this content", 409, ); checkServiceError(result.error); return respond({ ok: true }, 201); } if (path[0] === "moderation" && path[1] === "community-reports") { const reviewer = await account(true); if (method === "GET" && path.length === 2) { const filter = cursorFilter(request, "gt"); let query = db .from("community_reports") .select("*") .eq("state", "open") .order("created_at") .order("id") .limit(51); if (filter) query = query.or(filter); const reports = rows(await query); const commentIds = reports.flatMap((r) => r.comment_id ? [r.comment_id] : [], ); const accountIds = reports.flatMap((r) => r.account_id ? [r.account_id] : [], ); const [comments, accounts] = await Promise.all([ commentIds.length ? db.from("map_comments").select("*").in("id", commentIds) : Promise.resolve({ data: [], error: null }), accountIds.length ? db.from("profiles").select("id,display_name").in("id", accountIds) : Promise.resolve({ data: [], error: null }), ]); const byComment = new Map(rows(comments).map((c) => [c.id, c])); const byAccount = new Map( rows(accounts).map((a) => [a.id, a.display_name]), ); return respond({ reports: reports.slice(0, 50).map((r) => ({ ...r, comment: byComment.get(r.comment_id ?? "") ?? null, accountName: byAccount.get(r.account_id ?? "") ?? null, })), nextCursor: reports.length > 50 ? cursorFor(reports[49]) : null, }); } if (method === "PATCH" && path.length === 3) { const body = communityReviewInput.parse(await jsonBody(request)); const result = await db .from("community_reports") .update({ state: body.decision, reviewer_id: reviewer.id, reviewed_at: new Date().toISOString(), review_reason: body.reason, }) .eq("id", id(path[2])) .eq("state", "open") .select("id") .maybeSingle(); checkServiceError(result.error); if (!result.data) throw new ServiceError("Report already reviewed or not found", 409); return respond({ ok: true }); } } return respond({ error: "Not found" }, 404); }