Add community content reports, map bans, and appeals

This commit is contained in:
2026-10-06 13:00:10 +05:00
parent 5f43e97d32
commit dd67b5c0de
18 changed files with 1167 additions and 50 deletions

View File

@@ -15,6 +15,7 @@ import { mediaUrl, mediaUrls } from "./catalog";
import { ServiceError, checkServiceError } from "./service-error";
import { requestOrigin, verifyOrigin } from "./request-origin";
import { accountApi } from "./account-api";
import { contributionCutoff, reportSchema } from "../shared/moderation";
const hash = (value: string) =>
createHash("sha256").update(value).digest("hex");
const idSchema = z.string().uuid();
@@ -335,6 +336,9 @@ export async function handle(request: NextRequest, path: string[]) {
"*, upload_jobs!dataset_id(state,progress,error,updated_at,lease_until,kind)",
)
.eq("owner_id", owner)
.or(
`parent_dataset_id.is.null,created_at.gt.${contributionCutoff()}`,
)
.order("created_at", { ascending: false }),
);
return NextResponse.json({
@@ -348,6 +352,32 @@ export async function handle(request: NextRequest, path: string[]) {
});
}
const d = await ownedDataset(request, path[1]);
if (path[2] === "appeals" && path.length === 3 && !d.parent_dataset_id) {
if (method === "GET") {
const appeals = required(
await client
.from("map_appeals")
.select("*")
.eq("dataset_id", d.id)
.order("created_at", { ascending: false }),
);
return NextResponse.json({ appeals });
}
if (method === "POST") {
const body = z
.object({ reason: z.string().trim().min(10).max(2000) })
.parse(await jsonBody(request));
await limit("appeals:" + d.owner_id, 5, 3600);
const id = required(
await client.rpc("appeal_map_ban", {
p_dataset: d.id,
p_owner: d.owner_id,
p_reason: body.reason,
}),
);
return NextResponse.json({ id });
}
}
if (path[2] === "refresh-map") {
if (d.parent_dataset_id)
throw new Error("Only maps can refresh their guessing map");
@@ -571,6 +601,7 @@ export async function handle(request: NextRequest, path: string[]) {
.from("datasets")
.select("*")
.eq("parent_dataset_id", target)
.gt("created_at", contributionCutoff())
.order("created_at", { ascending: false }),
);
const profiles = rows.length
@@ -751,6 +782,60 @@ export async function handle(request: NextRequest, path: string[]) {
}
if (path[0] === "moderation") {
const reviewer = await moderator(request);
if (path[1] === "appeals" && path.length === 3 && method === "POST") {
const body = z
.object({
decision: z.enum(["approve", "reject"]),
reason: z.string().trim().min(10).max(2000),
})
.parse(await jsonBody(request));
checkServiceError(
(
await client.rpc("review_map_appeal", {
p_appeal: uuid(path[2]),
p_reviewer: reviewer,
p_decision: body.decision,
p_reason: body.reason,
})
).error,
);
return NextResponse.json({ ok: true });
}
if (
path[1] === "reports" &&
path[3] === "panoramas" &&
method === "GET" &&
[4, 5].includes(path.length)
) {
const report = required(
await client
.from("reports")
.select("*")
.eq("id", uuid(path[2]))
.single(),
);
const revision = required(
report.revision_id
? await client
.from("revisions")
.select("manifest,asset_root")
.eq("id", report.revision_id)
.single()
: await client
.from("revisions")
.select("manifest,asset_root")
.eq("dataset_id", report.dataset_id)
.order("created_at", { ascending: false })
.limit(1)
.single(),
);
const manifest = datasetSchema.parse(revision.manifest);
return panoramaResponse(
revision.asset_root,
manifest.viewpoints,
path[4],
);
}
if (method === "GET" && path[2] === "panoramas") {
const d = required(
await client
@@ -759,12 +844,13 @@ export async function handle(request: NextRequest, path: string[]) {
.eq("id", uuid(path[1]))
.single(),
);
if (!["review", "published"].includes(d.state))
if (!["review", "published", "unpublished"].includes(d.state))
throw new Error("Map is not awaiting review or published");
if (d.parent_dataset_id)
throw new Error("Contributions require owner approval first");
const input = required(
d.state === "published" && d.update_state !== "review"
(d.state === "published" && d.update_state !== "review") ||
d.state === "unpublished"
? await client
.from("revisions")
.select("manifest")
@@ -788,11 +874,49 @@ export async function handle(request: NextRequest, path: string[]) {
.from("datasets")
.select("*")
.is("parent_dataset_id", null)
.in("state", ["review", "published"])
.in("state", ["review", "published", "unpublished"])
.order("created_at"),
);
const reports = required(
await client.from("reports").select("*").eq("state", "open"),
await client
.from("reports")
.select("*")
.eq("state", "open")
.order("created_at"),
);
const appeals = required(
await client
.from("map_appeals")
.select("*")
.eq("state", "open")
.order("created_at"),
);
const reportViews = await Promise.all(
reports.map(async (report) => {
const revision = report.revision_id
? required(
await client
.from("revisions")
.select("manifest,asset_root")
.eq("id", report.revision_id)
.single(),
)
: null;
const manifest = revision
? datasetSchema.parse(revision.manifest)
: null;
return {
...report,
mapName:
maps.find((m) => m.id === report.dataset_id)?.name ??
"Reported map",
map: revision
? await mediaUrl(
manifest?.mapPath ?? `${revision.asset_root}/map.webp`,
)
: null,
};
}),
);
return NextResponse.json({
maps: await Promise.all(
@@ -820,7 +944,8 @@ export async function handle(request: NextRequest, path: string[]) {
};
}),
),
reports,
reports: reportViews,
appeals,
});
}
if (method === "POST") {
@@ -843,37 +968,79 @@ export async function handle(request: NextRequest, path: string[]) {
return NextResponse.json({ ok: true });
}
}
if (path[0] === "reports" && method === "POST") {
if (path[0] === "reports" && path.length === 1 && method === "POST") {
const owner = await contributor(request);
await limit("reports:" + owner, 10, 3600);
const b = z
.object({ datasetId: idSchema, reason: z.string().min(10).max(2000) })
.parse(await jsonBody(request));
required(
await client
.from("reports")
.insert({
dataset_id: b.datasetId,
owner_id: owner,
reason: b.reason,
})
.select("id")
.single(),
const b = reportSchema.parse(await jsonBody(request));
let datasetId = b.datasetId,
revisionId: string | undefined,
panoramaId: string | null = null;
if (b.sessionId) {
const game = await session(request, b.sessionId);
const revision = required(
await client
.from("revisions")
.select("dataset_id,id")
.eq("id", game.revision_id)
.single(),
);
datasetId = revision.dataset_id;
revisionId = revision.id;
if (b.target === "panorama") {
if (b.round! > game.current_round)
throw new Error("This round is not available yet");
const round = required(
await client
.from("game_rounds")
.select("viewpoint")
.eq("session_id", game.id)
.eq("round_number", b.round!)
.single(),
);
panoramaId = viewpointSchema.parse(round.viewpoint).id;
}
} else {
const map = required(
await client
.from("datasets")
.select("revision_id")
.eq("id", datasetId!)
.eq("state", "published")
.is("parent_dataset_id", null)
.single(),
);
revisionId = map.revision_id ?? undefined;
}
const id = required(
await client.rpc("submit_content_report", {
p_dataset: datasetId!,
p_owner: owner,
p_revision: revisionId!,
p_panorama: panoramaId,
p_category: b.category,
p_reason: b.reason,
p_evidence: b.evidenceUrl ?? null,
}),
);
return NextResponse.json({ ok: true });
return NextResponse.json({ id });
}
if (path[0] === "reports" && method === "PATCH") {
await moderator(request);
if (path[0] === "reports" && path.length === 2 && method === "PATCH") {
const reviewer = await moderator(request);
const b = z
.object({ state: z.literal("resolved") })
.object({
decision: z.enum(["confirm", "dismiss"]),
reason: z.string().trim().min(10).max(2000),
})
.parse(await jsonBody(request));
required(
await client
.from("reports")
.update({ state: b.state })
.eq("id", uuid(path[1]))
.select("id")
.single(),
checkServiceError(
(
await client.rpc("review_content_report", {
p_report: uuid(path[1]),
p_reviewer: reviewer,
p_decision: b.decision,
p_reason: b.reason,
})
).error,
);
return NextResponse.json({ ok: true });
}