Files
MineGeo/lib/community-api.ts
pavel444-byte e1a6ab5730
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m22s
Add map community reactions, comments, and moderation
2026-10-17 12:45:18 +05:00

298 lines
9.8 KiB
TypeScript

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<Database>;
getUser: () => Promise<{ id: string } | null>;
getProfile: (id: string) => Promise<{ id: string; role: string }>;
jsonBody: (request: NextRequest) => Promise<unknown>;
limit: (key: string, count: number, seconds: number) => Promise<void>;
};
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<T>(result: {
data: T;
error: { message: string; code?: string } | null;
}): NonNullable<T> {
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);
}