Add community content reports, map bans, and appeals
This commit is contained in:
227
lib/api.ts
227
lib/api.ts
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user