Add map community reactions, comments, and moderation
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m22s
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m22s
This commit is contained in:
18
lib/api.ts
18
lib/api.ts
@@ -22,6 +22,8 @@ import { requestOrigin, verifyOrigin } from "./request-origin";
|
||||
import { browserAccount } from "./request-security";
|
||||
import { jsonBody } from "./json-body";
|
||||
import { accountApi } from "./account-api";
|
||||
import { accountProfile } from "./profile";
|
||||
import { communityApi } from "./community-api";
|
||||
import { multiplayerApi } from "./multiplayer-api";
|
||||
import { bugReportApi } from "./bug-report-api";
|
||||
import { moderatorUsersApi } from "./moderator-users-api";
|
||||
@@ -137,6 +139,22 @@ export async function handle(request: NextRequest, path: string[]) {
|
||||
return await accountApi(request, path, { jsonBody, limit });
|
||||
// Check signed-in identities before any guest-cookie fallback (games/rooms).
|
||||
if (path[0] !== "health") await user();
|
||||
if (
|
||||
path[0] === "maps" ||
|
||||
path[0] === "community" ||
|
||||
(path[0] === "moderation" && path[1] === "community-reports")
|
||||
)
|
||||
return await communityApi(request, path, {
|
||||
db: admin(),
|
||||
getUser: user,
|
||||
jsonBody,
|
||||
limit,
|
||||
getProfile: async () => {
|
||||
const account = await user();
|
||||
if (!account) throw new ServiceError("Sign in to continue", 401);
|
||||
return accountProfile(account);
|
||||
},
|
||||
});
|
||||
if (path[0] === "rooms")
|
||||
return await multiplayerApi(request, path, { jsonBody, limit });
|
||||
if (path[0] === "bug-reports")
|
||||
|
||||
297
lib/community-api.ts
Normal file
297
lib/community-api.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user