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:
37
app/community-social.css
Normal file
37
app/community-social.css
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
.map-community {
|
||||||
|
margin-top: 36px;
|
||||||
|
}
|
||||||
|
.map-community .button,
|
||||||
|
.map-comment .button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.map-community .button[aria-pressed="true"] {
|
||||||
|
border-color: var(--green);
|
||||||
|
background: var(--panel2);
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
.map-comments {
|
||||||
|
margin-block: 24px;
|
||||||
|
}
|
||||||
|
.map-comment {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-block: 20px;
|
||||||
|
}
|
||||||
|
.map-comment-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 16px;
|
||||||
|
}
|
||||||
|
.map-comment-meta time {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.community-body {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.map-community textarea {
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import "./multiplayer.css";
|
|||||||
import "./support.css";
|
import "./support.css";
|
||||||
import "./appearance.css";
|
import "./appearance.css";
|
||||||
import "./community.css";
|
import "./community.css";
|
||||||
|
import "./community-social.css";
|
||||||
import { themeInitScript } from "@/shared/theme";
|
import { themeInitScript } from "@/shared/theme";
|
||||||
import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
|
import { SiteSettingsProvider } from "@/components/SiteSettingsProvider";
|
||||||
import { initialSiteSettings } from "@/lib/site-settings";
|
import { initialSiteSettings } from "@/lib/site-settings";
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ReportContent } from "@/components/ReportContent";
|
|||||||
import { user } from "@/lib/supabase";
|
import { user } from "@/lib/supabase";
|
||||||
import { accountProfile } from "@/lib/profile";
|
import { accountProfile } from "@/lib/profile";
|
||||||
import { ExportMap } from "@/components/MapTransfer";
|
import { ExportMap } from "@/components/MapTransfer";
|
||||||
|
import { MapCommunity } from "@/components/MapCommunity";
|
||||||
export default async function ServerPage({
|
export default async function ServerPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
@@ -74,6 +75,7 @@ export default async function ServerPage({
|
|||||||
serverAddress={map.contribution_server_address}
|
serverAddress={map.contribution_server_address}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<MapCommunity datasetId={id} ownerId={map.owner_id} />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
|
|||||||
193
components/CommunityModeration.tsx
Normal file
193
components/CommunityModeration.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { api } from "@/lib/client-api";
|
||||||
|
import {
|
||||||
|
communityCategories,
|
||||||
|
type CommunityReportView,
|
||||||
|
} from "@/shared/community";
|
||||||
|
import { useI18n } from "./I18nProvider";
|
||||||
|
|
||||||
|
type Queue = { reports: CommunityReportView[]; nextCursor: string | null };
|
||||||
|
export function CommunityModeration() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [queue, setQueue] = useState<Queue | null>(null);
|
||||||
|
const [reasons, setReasons] = useState<Record<string, string>>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
async function load() {
|
||||||
|
setQueue(await api<Queue>("moderation/community-reports"));
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
api<Queue>("moderation/community-reports")
|
||||||
|
.then((result) => {
|
||||||
|
if (active) setQueue(result);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (active) setError(e.message);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
async function act(action: () => Promise<unknown>) {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function more() {
|
||||||
|
if (queue?.nextCursor == null) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const next = await api<Queue>(
|
||||||
|
`moderation/community-reports?cursor=${encodeURIComponent(queue.nextCursor)}`,
|
||||||
|
);
|
||||||
|
setQueue((current) => ({
|
||||||
|
...next,
|
||||||
|
reports: [
|
||||||
|
...(current?.reports ?? []),
|
||||||
|
...next.reports.filter(
|
||||||
|
(r) => !current?.reports.some((old) => old.id === r.id),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="token-box">
|
||||||
|
<h2>{t("Comment and account reports")}</h2>
|
||||||
|
<p className="muted">
|
||||||
|
{t(
|
||||||
|
"Review the evidence before taking action. Resolving a report does not automatically ban an account or remove a comment.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="error-message">
|
||||||
|
{t(error)}{" "}
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => act(async () => {})}
|
||||||
|
>
|
||||||
|
{t("Try again")}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!queue && !error && <p role="status">{t("Loading review queue…")}</p>}
|
||||||
|
{queue?.reports.length === 0 && (
|
||||||
|
<p>{t("No open comment or account reports.")}</p>
|
||||||
|
)}
|
||||||
|
{queue?.reports.map((report) => (
|
||||||
|
<article className="tool-card" key={report.id}>
|
||||||
|
<h3>
|
||||||
|
{t(
|
||||||
|
report.target === "comment"
|
||||||
|
? "Reported comment"
|
||||||
|
: "Reported account",
|
||||||
|
)}{" "}
|
||||||
|
· {t(communityCategories[report.category])}
|
||||||
|
</h3>
|
||||||
|
{report.target === "account" && (
|
||||||
|
<p>
|
||||||
|
{report.accountName ?? t("Deleted player")} ·{" "}
|
||||||
|
<code>{report.account_id}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{report.comment && (
|
||||||
|
<>
|
||||||
|
<blockquote className="community-body">
|
||||||
|
{report.comment.body}
|
||||||
|
</blockquote>
|
||||||
|
<p>
|
||||||
|
<Link
|
||||||
|
className="button"
|
||||||
|
href={`/servers/${report.comment.dataset_id}`}
|
||||||
|
>
|
||||||
|
{t("Open map")}
|
||||||
|
</Link>{" "}
|
||||||
|
{report.comment.removed_at ? (
|
||||||
|
t("Comment removed.")
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
act(() =>
|
||||||
|
api(
|
||||||
|
`community/comments/${report.comment_id}`,
|
||||||
|
undefined,
|
||||||
|
"DELETE",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Remove comment")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="muted">
|
||||||
|
{t("Account ID:")}{" "}
|
||||||
|
<code>{report.comment.author_id ?? t("Deleted player")}</code>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p className="community-body">{report.reason}</p>
|
||||||
|
<label className="field">
|
||||||
|
{t("Decision reason")}
|
||||||
|
<textarea
|
||||||
|
minLength={10}
|
||||||
|
maxLength={2000}
|
||||||
|
value={reasons[report.id] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setReasons((current) => ({
|
||||||
|
...current,
|
||||||
|
[report.id]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="inline-actions">
|
||||||
|
{(["resolved", "dismissed"] as const).map((decision) => (
|
||||||
|
<button
|
||||||
|
key={decision}
|
||||||
|
className="button"
|
||||||
|
disabled={busy || (reasons[report.id]?.trim().length ?? 0) < 10}
|
||||||
|
onClick={() =>
|
||||||
|
act(() =>
|
||||||
|
api(
|
||||||
|
`moderation/community-reports/${report.id}`,
|
||||||
|
{ decision, reason: reasons[report.id] },
|
||||||
|
"PATCH",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
decision === "resolved" ? "Resolve report" : "Dismiss report",
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
{queue?.nextCursor != null && (
|
||||||
|
<button className="button" disabled={busy} onClick={more}>
|
||||||
|
{t("Load more reports")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
components/MapCommunity.tsx
Normal file
251
components/MapCommunity.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ThumbsUp, ThumbsDown, Trash2 } from "lucide-react";
|
||||||
|
import { api } from "@/lib/client-api";
|
||||||
|
import type { MapCommunityView } from "@/shared/community";
|
||||||
|
import { useI18n } from "./I18nProvider";
|
||||||
|
import { Username } from "./Username";
|
||||||
|
import { ReportCommunity } from "./ReportCommunity";
|
||||||
|
|
||||||
|
export function MapCommunity({
|
||||||
|
datasetId,
|
||||||
|
ownerId,
|
||||||
|
}: {
|
||||||
|
datasetId: string;
|
||||||
|
ownerId: string | null;
|
||||||
|
}) {
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const [data, setData] = useState<MapCommunityView | null>(null);
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
async function refresh() {
|
||||||
|
const result = await api<MapCommunityView>(`maps/${datasetId}/community`);
|
||||||
|
setData(result);
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
api<MapCommunityView>(`maps/${datasetId}/community`)
|
||||||
|
.then((result) => {
|
||||||
|
if (active) {
|
||||||
|
setData(result);
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (active) setError(e.message);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [datasetId]);
|
||||||
|
async function act(action: () => Promise<unknown>, success?: string) {
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
if (success) setMessage(success);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function more() {
|
||||||
|
if (data?.nextCursor == null) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const next = await api<MapCommunityView>(
|
||||||
|
`maps/${datasetId}/community?cursor=${encodeURIComponent(data.nextCursor)}`,
|
||||||
|
);
|
||||||
|
setData((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...next,
|
||||||
|
comments: [
|
||||||
|
...current.comments,
|
||||||
|
...next.comments.filter(
|
||||||
|
(c) =>
|
||||||
|
!current.comments.some((existing) => existing.id === c.id),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: next,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="map-community tool-card"
|
||||||
|
aria-label={t("Map community")}
|
||||||
|
>
|
||||||
|
<h2>{t("Map community")}</h2>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="error-message">
|
||||||
|
{t(error)}{" "}
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => act(async () => {})}
|
||||||
|
>
|
||||||
|
{t("Try again")}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!data && !error && <p role="status">{t("Loading comments…")}</p>}
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="inline-actions"
|
||||||
|
role="group"
|
||||||
|
aria-label={t("Map reactions")}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
aria-pressed={data.reaction === 1}
|
||||||
|
disabled={!data.viewer || busy}
|
||||||
|
onClick={() =>
|
||||||
|
act(() =>
|
||||||
|
api(`maps/${datasetId}/reaction`, {
|
||||||
|
value: data.reaction === 1 ? 0 : 1,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ThumbsUp size={18} aria-hidden="true" /> {t("Like")}{" "}
|
||||||
|
<span>{data.likes}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
aria-pressed={data.reaction === -1}
|
||||||
|
disabled={!data.viewer || busy}
|
||||||
|
onClick={() =>
|
||||||
|
act(() =>
|
||||||
|
api(`maps/${datasetId}/reaction`, {
|
||||||
|
value: data.reaction === -1 ? 0 : -1,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ThumbsDown size={18} aria-hidden="true" /> {t("Dislike")}{" "}
|
||||||
|
<span>{data.dislikes}</span>
|
||||||
|
</button>
|
||||||
|
{data.viewer && ownerId && ownerId !== data.viewer.id && (
|
||||||
|
<ReportCommunity target="account" targetId={ownerId} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!data.viewer && (
|
||||||
|
<p>
|
||||||
|
<Link className="button" href="/log-in">
|
||||||
|
{t("Sign in to react, comment, or report.")}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<h3>{t("Comments")}</h3>
|
||||||
|
{data.viewer && (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
act(async () => {
|
||||||
|
await api(`maps/${datasetId}/comments`, { body });
|
||||||
|
setBody("");
|
||||||
|
}, "Comment published.");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
{t("Write a comment")}
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
maxLength={2000}
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
placeholder={t("Share your thoughts about this map.")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
disabled={busy || !body.trim()}
|
||||||
|
>
|
||||||
|
{t(busy ? "Saving…" : "Publish comment")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{message && (
|
||||||
|
<p role="status" className="success-message">
|
||||||
|
{t(message)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!data.comments.length && (
|
||||||
|
<p className="muted">
|
||||||
|
{t("No comments yet. Be the first to share your thoughts.")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="map-comments">
|
||||||
|
{data.comments.map((comment) => (
|
||||||
|
<article className="map-comment" key={comment.id}>
|
||||||
|
<div className="map-comment-meta">
|
||||||
|
<Username
|
||||||
|
name={comment.author}
|
||||||
|
appearance={comment.appearance}
|
||||||
|
/>
|
||||||
|
<time className="muted" dateTime={comment.created_at}>
|
||||||
|
{new Date(comment.created_at).toLocaleString(
|
||||||
|
locale === "ru" ? "ru-RU" : "en-GB",
|
||||||
|
)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<p className="community-body">{comment.body}</p>
|
||||||
|
{data.viewer && (
|
||||||
|
<div className="inline-actions">
|
||||||
|
<ReportCommunity target="comment" targetId={comment.id} />
|
||||||
|
{comment.author_id &&
|
||||||
|
comment.author_id !== data.viewer.id && (
|
||||||
|
<ReportCommunity
|
||||||
|
target="account"
|
||||||
|
targetId={comment.author_id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{data.viewer.canModerate && (
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
act(
|
||||||
|
() =>
|
||||||
|
api(
|
||||||
|
`community/comments/${comment.id}`,
|
||||||
|
undefined,
|
||||||
|
"DELETE",
|
||||||
|
),
|
||||||
|
"Comment removed.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} aria-hidden="true" />{" "}
|
||||||
|
{t("Remove comment")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{data.nextCursor !== null && (
|
||||||
|
<button className="button" disabled={busy} onClick={more}>
|
||||||
|
{t("Load more comments")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { isAdministrator, type StaffRole } from "@/shared/permissions";
|
|||||||
import { DiscordIntegration } from "./DiscordIntegration";
|
import { DiscordIntegration } from "./DiscordIntegration";
|
||||||
import { AccountControls } from "./AccountControls";
|
import { AccountControls } from "./AccountControls";
|
||||||
import { PrometheusIntegration } from "./PrometheusIntegration";
|
import { PrometheusIntegration } from "./PrometheusIntegration";
|
||||||
|
import { CommunityModeration } from "./CommunityModeration";
|
||||||
type ReviewMap = {
|
type ReviewMap = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -127,6 +128,7 @@ export function Moderation({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{role && <CommunityModeration />}
|
||||||
{role && (
|
{role && (
|
||||||
<AccountControls
|
<AccountControls
|
||||||
administrator={administrator}
|
administrator={administrator}
|
||||||
|
|||||||
133
components/ReportCommunity.tsx
Normal file
133
components/ReportCommunity.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
|
import { Flag } from "lucide-react";
|
||||||
|
import { api } from "@/lib/client-api";
|
||||||
|
import { communityCategories } from "@/shared/community";
|
||||||
|
import { useI18n } from "./I18nProvider";
|
||||||
|
|
||||||
|
export function ReportCommunity({
|
||||||
|
target,
|
||||||
|
targetId,
|
||||||
|
}: {
|
||||||
|
target: "comment" | "account";
|
||||||
|
targetId: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const titleId = useId();
|
||||||
|
const dialog = useRef<HTMLDialogElement>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [category, setCategory] = useState("spam");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const label = target === "comment" ? "Report comment" : "Report account";
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const previous =
|
||||||
|
document.activeElement instanceof HTMLElement
|
||||||
|
? document.activeElement
|
||||||
|
: null;
|
||||||
|
const element = dialog.current;
|
||||||
|
element?.showModal();
|
||||||
|
return () => {
|
||||||
|
element?.close();
|
||||||
|
previous?.focus();
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
async function submit(event: React.FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await api("community/reports", { target, targetId, category, reason });
|
||||||
|
setMessage("Report sent for moderator review.");
|
||||||
|
setOpen(false);
|
||||||
|
setReason("");
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(true);
|
||||||
|
setError("");
|
||||||
|
setMessage("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flag size={15} aria-hidden="true" /> {t(label)}
|
||||||
|
</button>
|
||||||
|
{message && (
|
||||||
|
<span role="status" className="success-message">
|
||||||
|
{t(message)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{open && (
|
||||||
|
<dialog
|
||||||
|
ref={dialog}
|
||||||
|
className="report-dialog"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<h2 id={titleId}>{t(label)}</h2>
|
||||||
|
<p className="muted">
|
||||||
|
{t(
|
||||||
|
"A moderator will review your report. Reports do not automatically remove content or ban accounts.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<label className="field">
|
||||||
|
{t("Reason")}
|
||||||
|
<select
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
>
|
||||||
|
{Object.entries(communityCategories).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{t(label)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
{t("What happened?")}
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
minLength={10}
|
||||||
|
maxLength={2000}
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="error-message">
|
||||||
|
{t(error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="inline-actions">
|
||||||
|
<button
|
||||||
|
className="button primary"
|
||||||
|
disabled={busy || reason.trim().length < 10}
|
||||||
|
>
|
||||||
|
{t(busy ? "Sending…" : "Send report")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
{t("Cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
docs/map-community.md
Normal file
26
docs/map-community.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Map reactions, comments, and account reports
|
||||||
|
|
||||||
|
Published map pages include a community panel. Visitors can read comments and see like/dislike totals. Active signed-in accounts can post comments of 1–2,000 characters, like or dislike a map, and submit reports. Clicking the selected reaction clears it; choosing the other reaction replaces it. Each account has one reaction per map.
|
||||||
|
|
||||||
|
Users can report a comment or its author, or report the map creator from the reaction bar. Reasons include Spam, Advertising, Inappropriate content, Harassment, and Other. Reports require a 10–2,000-character explanation. Only one open report per reporter and target is allowed.
|
||||||
|
|
||||||
|
Moderators and admins can remove comments directly on map pages or from **Comment and account reports** in their staff tools. Removal hides the comment publicly while preserving its body and reviewer for staff investigation. Staff resolve or dismiss reports with a decision reason. Reports never automatically remove comments, add map strikes, or ban accounts. Existing account controls remain responsible for account restrictions.
|
||||||
|
|
||||||
|
## Setup and verification
|
||||||
|
|
||||||
|
Apply `supabase/migrations/20261017073500_map_community_features.sql` before deploying the application. This adds `map_reactions`, `map_comments`, `community_reports`, and `map_reaction_summary`. All tables enable RLS and restrict access to the server service role. Browser and device-token clients cannot directly read or write these tables. The server routes validate browser sessions, active account status, published-map visibility, and staff roles.
|
||||||
|
|
||||||
|
- `node --import tsx --test tests/community.test.ts` — validation, authentication, moderator authorization, scoped reactions, reporting and review conflicts.
|
||||||
|
- `node scripts/test-community-sql.mjs` — applies the full migration history in isolated PGlite and verifies constraints, counts, duplicates, deletion, and privileges. Uses the same optional `.data/sql-verification` runtime as the existing SQL suites.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `GET /api/v1/maps/:id/community`: totals, current viewer/reaction, newest visible comments (30 per page), and `nextCursor`. Pass the URL-encoded cursor as `?cursor=...` for the next page.
|
||||||
|
- `POST /api/v1/maps/:id/reaction`: `{ "value": -1 | 0 | 1 }`.
|
||||||
|
- `POST /api/v1/maps/:id/comments`: `{ "body": "..." }`.
|
||||||
|
- `DELETE /api/v1/community/comments/:id`: staff-only soft removal.
|
||||||
|
- `POST /api/v1/community/reports`: `{ "target": "comment" | "account", "targetId": "UUID", "category": "spam" | "advertising" | "inappropriate" | "harassment" | "other", "reason": "..." }`.
|
||||||
|
- `GET /api/v1/moderation/community-reports`: staff-only open queue (50 per page), including comment evidence/account identity and `nextCursor`. Pass the URL-encoded cursor as `?cursor=...` for the next page. Timestamp/ID cursors avoid skipping items when earlier comments are removed or reports resolved.
|
||||||
|
- `PATCH /api/v1/moderation/community-reports/:id`: staff-only `{ "decision": "resolved" | "dismissed", "reason": "..." }`. Reviews atomically require the report to remain open.
|
||||||
|
|
||||||
|
Responses are not cached. Posting is limited per account to 10 comments per 10 minutes, 10 reports per hour, and 120 reaction changes per minute. Existing origin checks apply to every mutation.
|
||||||
18
lib/api.ts
18
lib/api.ts
@@ -22,6 +22,8 @@ import { requestOrigin, verifyOrigin } from "./request-origin";
|
|||||||
import { browserAccount } from "./request-security";
|
import { browserAccount } from "./request-security";
|
||||||
import { jsonBody } from "./json-body";
|
import { jsonBody } from "./json-body";
|
||||||
import { accountApi } from "./account-api";
|
import { accountApi } from "./account-api";
|
||||||
|
import { accountProfile } from "./profile";
|
||||||
|
import { communityApi } from "./community-api";
|
||||||
import { multiplayerApi } from "./multiplayer-api";
|
import { multiplayerApi } from "./multiplayer-api";
|
||||||
import { bugReportApi } from "./bug-report-api";
|
import { bugReportApi } from "./bug-report-api";
|
||||||
import { moderatorUsersApi } from "./moderator-users-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 });
|
return await accountApi(request, path, { jsonBody, limit });
|
||||||
// Check signed-in identities before any guest-cookie fallback (games/rooms).
|
// Check signed-in identities before any guest-cookie fallback (games/rooms).
|
||||||
if (path[0] !== "health") await user();
|
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")
|
if (path[0] === "rooms")
|
||||||
return await multiplayerApi(request, path, { jsonBody, limit });
|
return await multiplayerApi(request, path, { jsonBody, limit });
|
||||||
if (path[0] === "bug-reports")
|
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);
|
||||||
|
}
|
||||||
24
scripts/test-community-sql.mjs
Normal file
24
scripts/test-community-sql.mjs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// Uses the same optional isolated Postgres runtime as test-moderation-sql.mjs.
|
||||||
|
import { readFile, readdir } from "node:fs/promises";
|
||||||
|
import { PGlite } from "../.data/sql-verification/node_modules/@electric-sql/pglite/dist/index.js";
|
||||||
|
|
||||||
|
const db = new PGlite();
|
||||||
|
try {
|
||||||
|
await db.exec(`create role anon; create role authenticated; create role service_role bypassrls;
|
||||||
|
create schema auth; create table auth.users(id uuid primary key);
|
||||||
|
create function auth.uid() returns uuid language sql as $$ select null::uuid $$;
|
||||||
|
create schema storage;
|
||||||
|
create table storage.buckets(id text primary key,name text,public boolean,file_size_limit bigint,allowed_mime_types text[]);
|
||||||
|
create table storage.objects(id uuid primary key default gen_random_uuid(),bucket_id text,name text);
|
||||||
|
create function storage.foldername(name text) returns text[] language sql as $$ select string_to_array(name, '/') $$;`);
|
||||||
|
for (const file of (await readdir("supabase/migrations")).filter((f) => f.endsWith(".sql")).sort()) {
|
||||||
|
await db.exec(await readFile(`supabase/migrations/${file}`, "utf8"));
|
||||||
|
}
|
||||||
|
await db.exec(await readFile("tests/sql/community.sql", "utf8"));
|
||||||
|
console.log("Community SQL passed: reactions, comments, reports, constraints, deletion, and service-only privileges.");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
89
shared/community.ts
Normal file
89
shared/community.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { ProfileAppearance } from "./profile-appearance";
|
||||||
|
|
||||||
|
export const communityCategories = {
|
||||||
|
spam: "Spam",
|
||||||
|
advertising: "Advertising",
|
||||||
|
inappropriate: "Inappropriate content",
|
||||||
|
harassment: "Harassment",
|
||||||
|
other: "Other",
|
||||||
|
} as const;
|
||||||
|
export const reactionInput = z
|
||||||
|
.object({ value: z.union([z.literal(-1), z.literal(0), z.literal(1)]) })
|
||||||
|
.strict();
|
||||||
|
export const commentInput = z
|
||||||
|
.object({ body: z.string().trim().min(1).max(2000) })
|
||||||
|
.strict();
|
||||||
|
export const communityReportInput = z.discriminatedUnion("target", [
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
target: z.literal("comment"),
|
||||||
|
targetId: z.string().uuid(),
|
||||||
|
category: z.enum(
|
||||||
|
Object.keys(communityCategories) as [
|
||||||
|
keyof typeof communityCategories,
|
||||||
|
...Array<keyof typeof communityCategories>,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
reason: z.string().trim().min(10).max(2000),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
target: z.literal("account"),
|
||||||
|
targetId: z.string().uuid(),
|
||||||
|
category: z.enum(
|
||||||
|
Object.keys(communityCategories) as [
|
||||||
|
keyof typeof communityCategories,
|
||||||
|
...Array<keyof typeof communityCategories>,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
reason: z.string().trim().min(10).max(2000),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
]);
|
||||||
|
export const communityReviewInput = z
|
||||||
|
.object({
|
||||||
|
decision: z.enum(["resolved", "dismissed"]),
|
||||||
|
reason: z.string().trim().min(10).max(2000),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export type MapCommentRecord = {
|
||||||
|
id: string;
|
||||||
|
dataset_id: string;
|
||||||
|
author_id: string | null;
|
||||||
|
body: string;
|
||||||
|
created_at: string;
|
||||||
|
removed_at: string | null;
|
||||||
|
removed_by: string | null;
|
||||||
|
};
|
||||||
|
export type CommunityReportRecord = {
|
||||||
|
id: string;
|
||||||
|
reporter_id: string | null;
|
||||||
|
target: "comment" | "account";
|
||||||
|
comment_id: string | null;
|
||||||
|
account_id: string | null;
|
||||||
|
category: keyof typeof communityCategories;
|
||||||
|
reason: string;
|
||||||
|
state: "open" | "resolved" | "dismissed";
|
||||||
|
reviewer_id: string | null;
|
||||||
|
reviewed_at: string | null;
|
||||||
|
review_reason: string | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
export type CommunityComment = Pick<
|
||||||
|
MapCommentRecord,
|
||||||
|
"id" | "body" | "created_at" | "author_id"
|
||||||
|
> & { author: string; appearance?: ProfileAppearance };
|
||||||
|
export type MapCommunityView = {
|
||||||
|
likes: number;
|
||||||
|
dislikes: number;
|
||||||
|
reaction: number;
|
||||||
|
viewer: { id: string; canModerate: boolean } | null;
|
||||||
|
comments: CommunityComment[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
};
|
||||||
|
export type CommunityReportView = CommunityReportRecord & {
|
||||||
|
comment: MapCommentRecord | null;
|
||||||
|
accountName: string | null;
|
||||||
|
};
|
||||||
@@ -62,6 +62,42 @@ export type Database = {
|
|||||||
};
|
};
|
||||||
public: {
|
public: {
|
||||||
Tables: {
|
Tables: {
|
||||||
|
map_reactions: {
|
||||||
|
Row: {
|
||||||
|
dataset_id: string;
|
||||||
|
user_id: string;
|
||||||
|
value: number;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
Insert: {
|
||||||
|
dataset_id: string;
|
||||||
|
user_id: string;
|
||||||
|
value: number;
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
Update: { value?: number };
|
||||||
|
Relationships: [];
|
||||||
|
};
|
||||||
|
map_comments: {
|
||||||
|
Row: import("./community").MapCommentRecord;
|
||||||
|
Insert: Partial<import("./community").MapCommentRecord> &
|
||||||
|
Pick<
|
||||||
|
import("./community").MapCommentRecord,
|
||||||
|
"dataset_id" | "author_id" | "body"
|
||||||
|
>;
|
||||||
|
Update: Partial<import("./community").MapCommentRecord>;
|
||||||
|
Relationships: [];
|
||||||
|
};
|
||||||
|
community_reports: {
|
||||||
|
Row: import("./community").CommunityReportRecord;
|
||||||
|
Insert: Partial<import("./community").CommunityReportRecord> &
|
||||||
|
Pick<
|
||||||
|
import("./community").CommunityReportRecord,
|
||||||
|
"reporter_id" | "target" | "category" | "reason"
|
||||||
|
>;
|
||||||
|
Update: Partial<import("./community").CommunityReportRecord>;
|
||||||
|
Relationships: [];
|
||||||
|
};
|
||||||
account_appeals: {
|
account_appeals: {
|
||||||
Row: AccountAppealRecord;
|
Row: AccountAppealRecord;
|
||||||
Insert: Partial<AccountAppealRecord> &
|
Insert: Partial<AccountAppealRecord> &
|
||||||
@@ -782,6 +818,10 @@ export type Database = {
|
|||||||
[_ in never]: never;
|
[_ in never]: never;
|
||||||
};
|
};
|
||||||
Functions: {
|
Functions: {
|
||||||
|
map_reaction_summary: {
|
||||||
|
Args: { p_dataset: string };
|
||||||
|
Returns: { likes: number; dislikes: number }[];
|
||||||
|
};
|
||||||
admin_account_action: {
|
admin_account_action: {
|
||||||
Args: {
|
Args: {
|
||||||
p_reviewer: string;
|
p_reviewer: string;
|
||||||
|
|||||||
@@ -1,4 +1,37 @@
|
|||||||
{
|
{
|
||||||
|
"Deleted player": "Удалённый игрок",
|
||||||
|
"Open map": "Открыть карту",
|
||||||
|
"Account ID:": "ID аккаунта:",
|
||||||
|
"Map community": "Сообщество карты",
|
||||||
|
"Map reactions": "Оценки карты",
|
||||||
|
"Like": "Нравится",
|
||||||
|
"Dislike": "Не нравится",
|
||||||
|
"Comments": "Комментарии",
|
||||||
|
"Loading comments…": "Загрузка комментариев…",
|
||||||
|
"Sign in to react, comment, or report.": "Войдите, чтобы оценивать карты, писать комментарии и отправлять жалобы.",
|
||||||
|
"Write a comment": "Написать комментарий",
|
||||||
|
"Share your thoughts about this map.": "Поделитесь впечатлениями об этой карте.",
|
||||||
|
"Publish comment": "Опубликовать комментарий",
|
||||||
|
"Comment published.": "Комментарий опубликован.",
|
||||||
|
"Comment removed.": "Комментарий удалён.",
|
||||||
|
"No comments yet. Be the first to share your thoughts.": "Комментариев пока нет. Поделитесь впечатлениями первым.",
|
||||||
|
"Remove comment": "Удалить комментарий",
|
||||||
|
"Load more comments": "Загрузить ещё комментарии",
|
||||||
|
"Report comment": "Пожаловаться на комментарий",
|
||||||
|
"Report account": "Пожаловаться на аккаунт",
|
||||||
|
"Reported comment": "Жалоба на комментарий",
|
||||||
|
"Reported account": "Жалоба на аккаунт",
|
||||||
|
"Spam": "Спам",
|
||||||
|
"Advertising": "Реклама",
|
||||||
|
"Inappropriate content": "Недопустимое содержимое",
|
||||||
|
"Harassment": "Травля и оскорбления",
|
||||||
|
"Report sent for moderator review.": "Жалоба отправлена модератору.",
|
||||||
|
"A moderator will review your report. Reports do not automatically remove content or ban accounts.": "Модератор рассмотрит жалобу. Жалобы не приводят к автоматическому удалению содержимого или блокировке аккаунтов.",
|
||||||
|
"Comment and account reports": "Жалобы на комментарии и аккаунты",
|
||||||
|
"Review the evidence before taking action. Resolving a report does not automatically ban an account or remove a comment.": "Проверьте обстоятельства перед принятием мер. Закрытие жалобы не блокирует аккаунт и не удаляет комментарий автоматически.",
|
||||||
|
"No open comment or account reports.": "Открытых жалоб на комментарии и аккаунты нет.",
|
||||||
|
"Resolve report": "Закрыть жалобу",
|
||||||
|
"Load more reports": "Загрузить ещё жалобы",
|
||||||
"Forum": "Форум",
|
"Forum": "Форум",
|
||||||
"Site language": "Язык сайта",
|
"Site language": "Язык сайта",
|
||||||
"Automatic": "Автоматически",
|
"Automatic": "Автоматически",
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
-- All access goes through authenticated server routes using the service client.
|
||||||
|
create table public.map_reactions (
|
||||||
|
dataset_id uuid not null references public.datasets(id) on delete cascade,
|
||||||
|
user_id uuid not null references public.profiles(id) on delete cascade,
|
||||||
|
value smallint not null check (value in (-1, 1)),
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
primary key (dataset_id, user_id)
|
||||||
|
);
|
||||||
|
create index map_reactions_user on public.map_reactions(user_id);
|
||||||
|
create index map_reactions_totals on public.map_reactions(dataset_id, value);
|
||||||
|
|
||||||
|
create table public.map_comments (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
dataset_id uuid not null references public.datasets(id) on delete cascade,
|
||||||
|
author_id uuid references public.profiles(id) on delete set null,
|
||||||
|
body text not null check (body = btrim(body) and char_length(body) between 1 and 2000),
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
removed_at timestamptz,
|
||||||
|
removed_by uuid references public.profiles(id) on delete set null
|
||||||
|
);
|
||||||
|
create index map_comments_dataset_created on public.map_comments(dataset_id, created_at desc, id desc);
|
||||||
|
create index map_comments_author on public.map_comments(author_id);
|
||||||
|
create index map_comments_removed_by on public.map_comments(removed_by);
|
||||||
|
|
||||||
|
create table public.community_reports (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
reporter_id uuid references public.profiles(id) on delete set null,
|
||||||
|
target text not null check (target in ('comment', 'account')),
|
||||||
|
comment_id uuid references public.map_comments(id) on delete cascade,
|
||||||
|
account_id uuid references public.profiles(id) on delete cascade,
|
||||||
|
category text not null check (category in ('spam', 'advertising', 'inappropriate', 'harassment', 'other')),
|
||||||
|
reason text not null check (reason = btrim(reason) and char_length(reason) between 10 and 2000),
|
||||||
|
state text not null default 'open' check (state in ('open', 'resolved', 'dismissed')),
|
||||||
|
reviewer_id uuid references public.profiles(id) on delete set null,
|
||||||
|
reviewed_at timestamptz,
|
||||||
|
review_reason text check (char_length(review_reason) <= 2000),
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
constraint community_reports_target_link check (
|
||||||
|
(target = 'comment' and comment_id is not null and account_id is null)
|
||||||
|
or (target = 'account' and account_id is not null and comment_id is null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
create unique index community_reports_open_comment on public.community_reports(reporter_id, comment_id)
|
||||||
|
where state = 'open' and target = 'comment';
|
||||||
|
create unique index community_reports_open_account on public.community_reports(reporter_id, account_id)
|
||||||
|
where state = 'open' and target = 'account';
|
||||||
|
create index community_reports_queue on public.community_reports(state, created_at desc);
|
||||||
|
create index community_reports_reporter on public.community_reports(reporter_id);
|
||||||
|
create index community_reports_comment on public.community_reports(comment_id);
|
||||||
|
create index community_reports_account on public.community_reports(account_id);
|
||||||
|
create index community_reports_reviewer on public.community_reports(reviewer_id);
|
||||||
|
|
||||||
|
alter table public.map_reactions enable row level security;
|
||||||
|
alter table public.map_comments enable row level security;
|
||||||
|
alter table public.community_reports enable row level security;
|
||||||
|
revoke all on public.map_reactions, public.map_comments, public.community_reports from public, anon, authenticated;
|
||||||
|
grant all on public.map_reactions, public.map_comments, public.community_reports to service_role;
|
||||||
|
|
||||||
|
create function public.map_reaction_summary(p_dataset uuid)
|
||||||
|
returns table (likes bigint, dislikes bigint)
|
||||||
|
language sql stable security invoker set search_path = '' as $$
|
||||||
|
select count(*) filter (where value = 1), count(*) filter (where value = -1)
|
||||||
|
from public.map_reactions where dataset_id = p_dataset;
|
||||||
|
$$;
|
||||||
|
revoke all on function public.map_reaction_summary(uuid) from public, anon, authenticated;
|
||||||
|
grant execute on function public.map_reaction_summary(uuid) to service_role;
|
||||||
410
tests/community.test.ts
Normal file
410
tests/community.test.ts
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import type { Database } from "../shared/database";
|
||||||
|
import { communityApi } from "../lib/community-api";
|
||||||
|
import {
|
||||||
|
commentInput,
|
||||||
|
communityReportInput,
|
||||||
|
reactionInput,
|
||||||
|
} from "../shared/community";
|
||||||
|
import { ServiceError } from "../lib/service-error";
|
||||||
|
|
||||||
|
const map = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const member = "22222222-2222-4222-8222-222222222222";
|
||||||
|
const comment = "33333333-3333-4333-8333-333333333333";
|
||||||
|
const other = "44444444-4444-4444-8444-444444444444";
|
||||||
|
|
||||||
|
test("community cursors advance by timestamp and ID even if earlier rows disappear", async () => {
|
||||||
|
const created = "2026-10-17T00:00:00.000Z";
|
||||||
|
const cursor = encodeURIComponent(`${created}|${comment}`);
|
||||||
|
const f = fixture({ signedIn: false });
|
||||||
|
f.reply("datasets", { id: map });
|
||||||
|
f.reply("map_reaction_summary", [{ likes: 0, dislikes: 0 }]);
|
||||||
|
f.reply("map_comments", []);
|
||||||
|
await f.run(`maps/${map}/community?cursor=${cursor}`);
|
||||||
|
assert.ok(
|
||||||
|
f.calls.some(
|
||||||
|
(c) =>
|
||||||
|
c.method === "or" &&
|
||||||
|
c.args[0] ===
|
||||||
|
`created_at.lt.${created},and(created_at.eq.${created},id.lt.${comment})`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.ok(!f.calls.some((c) => c.method === "range"));
|
||||||
|
const staff = fixture({ role: "moderator" });
|
||||||
|
staff.reply("community_reports", []);
|
||||||
|
await staff.run(`moderation/community-reports?cursor=${cursor}`);
|
||||||
|
assert.ok(
|
||||||
|
staff.calls.some(
|
||||||
|
(c) =>
|
||||||
|
c.method === "or" &&
|
||||||
|
c.args[0] ===
|
||||||
|
`created_at.gt.${created},and(created_at.eq.${created},id.gt.${comment})`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const invalid = fixture({ signedIn: false });
|
||||||
|
invalid.reply("datasets", { id: map });
|
||||||
|
await assert.rejects(
|
||||||
|
invalid.run(`maps/${map}/community?cursor=invalid|${comment}`),
|
||||||
|
);
|
||||||
|
assert.ok(!invalid.calls.some((c) => c.method === "or"));
|
||||||
|
});
|
||||||
|
type Result = {
|
||||||
|
data: unknown;
|
||||||
|
error: { message: string; code?: string } | null;
|
||||||
|
};
|
||||||
|
function fixture(
|
||||||
|
options: { signedIn?: boolean; role?: string; inactive?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const calls: { table: string; method: string; args: unknown[] }[] = [];
|
||||||
|
const replies = new Map<string, Result[]>();
|
||||||
|
const rates: string[] = [];
|
||||||
|
const db = {
|
||||||
|
from(table: string) {
|
||||||
|
const query: Record<string, unknown> = {};
|
||||||
|
for (const method of [
|
||||||
|
"select",
|
||||||
|
"insert",
|
||||||
|
"update",
|
||||||
|
"delete",
|
||||||
|
"upsert",
|
||||||
|
"eq",
|
||||||
|
"is",
|
||||||
|
"in",
|
||||||
|
"order",
|
||||||
|
"range",
|
||||||
|
"or",
|
||||||
|
"limit",
|
||||||
|
"single",
|
||||||
|
"maybeSingle",
|
||||||
|
])
|
||||||
|
query[method] = (...args: unknown[]) => {
|
||||||
|
calls.push({ table, method, args });
|
||||||
|
return query;
|
||||||
|
};
|
||||||
|
query.then = (resolve: (value: Result) => unknown) =>
|
||||||
|
Promise.resolve(
|
||||||
|
replies.get(table)?.shift() ?? { data: null, error: null },
|
||||||
|
).then(resolve);
|
||||||
|
return query;
|
||||||
|
},
|
||||||
|
rpc(name: string, args: unknown) {
|
||||||
|
calls.push({ table: name, method: "rpc", args: [args] });
|
||||||
|
return Promise.resolve(
|
||||||
|
replies.get(name)?.shift() ?? { data: [], error: null },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
} as unknown as SupabaseClient<Database>;
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
rates,
|
||||||
|
reply(table: string, data: unknown, error: Result["error"] = null) {
|
||||||
|
const list = replies.get(table) ?? [];
|
||||||
|
list.push({ data, error });
|
||||||
|
replies.set(table, list);
|
||||||
|
},
|
||||||
|
run(path: string, method = "GET", body?: unknown, authorization?: string) {
|
||||||
|
const headers = new Headers();
|
||||||
|
if (authorization !== undefined)
|
||||||
|
headers.set("authorization", authorization);
|
||||||
|
return communityApi(
|
||||||
|
new NextRequest("https://minegeo.test/api/v1/" + path, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
}),
|
||||||
|
path.split("?")[0].split("/"),
|
||||||
|
{
|
||||||
|
db,
|
||||||
|
getUser: async () => {
|
||||||
|
if (options.inactive) throw new ServiceError("Account banned", 403);
|
||||||
|
return options.signedIn === false ? null : { id: member };
|
||||||
|
},
|
||||||
|
getProfile: async () => ({
|
||||||
|
id: member,
|
||||||
|
role: options.role ?? "contributor",
|
||||||
|
}),
|
||||||
|
jsonBody: async () => body,
|
||||||
|
limit: async (key) => {
|
||||||
|
rates.push(key);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("community validation rejects blank/oversized comments, forged authors, and invalid reactions", () => {
|
||||||
|
assert.equal(
|
||||||
|
commentInput.parse({ body: " A lovely map! " }).body,
|
||||||
|
"A lovely map!",
|
||||||
|
);
|
||||||
|
for (const body of [" ", "x".repeat(2001)])
|
||||||
|
assert.equal(commentInput.safeParse({ body }).success, false);
|
||||||
|
assert.equal(
|
||||||
|
commentInput.safeParse({ body: "Hello", author_id: other }).success,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
for (const value of [-1, 0, 1])
|
||||||
|
assert.equal(reactionInput.safeParse({ value }).success, true);
|
||||||
|
for (const value of [2, "1", null])
|
||||||
|
assert.equal(reactionInput.safeParse({ value }).success, false);
|
||||||
|
for (const category of [
|
||||||
|
"spam",
|
||||||
|
"advertising",
|
||||||
|
"inappropriate",
|
||||||
|
"harassment",
|
||||||
|
"other",
|
||||||
|
])
|
||||||
|
assert.equal(
|
||||||
|
communityReportInput.safeParse({
|
||||||
|
target: "account",
|
||||||
|
targetId: other,
|
||||||
|
category,
|
||||||
|
reason: "Enough detail for staff.",
|
||||||
|
}).success,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
for (const extra of [
|
||||||
|
{ reporter_id: other },
|
||||||
|
{ state: "resolved" },
|
||||||
|
{ target: "map" },
|
||||||
|
{ category: "unknown" },
|
||||||
|
{ reason: "short" },
|
||||||
|
])
|
||||||
|
assert.equal(
|
||||||
|
communityReportInput.safeParse({
|
||||||
|
target: "comment",
|
||||||
|
targetId: comment,
|
||||||
|
category: "spam",
|
||||||
|
reason: "Enough detail for staff.",
|
||||||
|
...extra,
|
||||||
|
}).success,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("guests, device tokens, and restricted accounts cannot create community content", async () => {
|
||||||
|
for (const [path, method, body] of [
|
||||||
|
[`maps/${map}/comments`, "POST", { body: "Hello" }],
|
||||||
|
[`maps/${map}/reaction`, "POST", { value: 1 }],
|
||||||
|
[
|
||||||
|
"community/reports",
|
||||||
|
"POST",
|
||||||
|
{
|
||||||
|
target: "account",
|
||||||
|
targetId: other,
|
||||||
|
category: "spam",
|
||||||
|
reason: "Enough detail for staff.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
] as const) {
|
||||||
|
const guest = fixture({ signedIn: false });
|
||||||
|
await assert.rejects(
|
||||||
|
guest.run(path, method, body),
|
||||||
|
(e: unknown) => e instanceof ServiceError && e.status === 401,
|
||||||
|
);
|
||||||
|
assert.equal(guest.calls.length, 0);
|
||||||
|
const device = fixture();
|
||||||
|
await assert.rejects(
|
||||||
|
device.run(path, method, body, "Bearer upload-token"),
|
||||||
|
(e: unknown) => e instanceof ServiceError && e.status === 403,
|
||||||
|
);
|
||||||
|
assert.equal(device.calls.length, 0);
|
||||||
|
await assert.rejects(
|
||||||
|
fixture({ inactive: true }).run(path, method, body),
|
||||||
|
/banned/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("regular users cannot remove comments, read reports, or review reports", async () => {
|
||||||
|
for (const [path, method] of [
|
||||||
|
[`community/comments/${comment}`, "DELETE"],
|
||||||
|
["moderation/community-reports", "GET"],
|
||||||
|
[`moderation/community-reports/${comment}`, "PATCH"],
|
||||||
|
]) {
|
||||||
|
const f = fixture();
|
||||||
|
await assert.rejects(
|
||||||
|
f.run(path, method),
|
||||||
|
(e: unknown) => e instanceof ServiceError && e.status === 403,
|
||||||
|
);
|
||||||
|
assert.equal(f.calls.length, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reaction switches use an account-bound upsert and clearing affects only that user's map reaction", async () => {
|
||||||
|
for (const value of [1, -1, 0]) {
|
||||||
|
const f = fixture();
|
||||||
|
f.reply("datasets", { id: map });
|
||||||
|
f.reply("map_reactions", null);
|
||||||
|
assert.equal(
|
||||||
|
(await f.run(`maps/${map}/reaction`, "POST", { value })).status,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
assert.deepEqual(f.rates, ["community:reaction:" + member]);
|
||||||
|
if (value === 0) {
|
||||||
|
assert.ok(
|
||||||
|
f.calls.some(
|
||||||
|
(c) =>
|
||||||
|
c.table === "map_reactions" &&
|
||||||
|
c.method === "eq" &&
|
||||||
|
c.args[0] === "user_id" &&
|
||||||
|
c.args[1] === member,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else
|
||||||
|
assert.deepEqual(f.calls.find((c) => c.method === "upsert")?.args, [
|
||||||
|
{ dataset_id: map, user_id: member, value },
|
||||||
|
{ onConflict: "dataset_id,user_id" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("comment publishing derives the author from the session and rejects unavailable maps", async () => {
|
||||||
|
const f = fixture();
|
||||||
|
f.reply("datasets", { id: map });
|
||||||
|
f.reply("map_comments", { id: comment });
|
||||||
|
assert.equal(
|
||||||
|
(await f.run(`maps/${map}/comments`, "POST", { body: " Great map " }))
|
||||||
|
.status,
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
assert.deepEqual(f.calls.find((c) => c.method === "insert")?.args, [
|
||||||
|
{ dataset_id: map, author_id: member, body: "Great map" },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(f.rates, ["community:comment:" + member]);
|
||||||
|
const missing = fixture();
|
||||||
|
await assert.rejects(
|
||||||
|
missing.run(`maps/${map}/comments`, "POST", { body: "Hello" }),
|
||||||
|
/not found/,
|
||||||
|
);
|
||||||
|
assert.ok(!missing.calls.some((c) => c.method === "insert"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("public comment reads filter removed content and return no reviewer or reporter data", async () => {
|
||||||
|
const f = fixture({ signedIn: false });
|
||||||
|
f.reply("datasets", { id: map });
|
||||||
|
f.reply("map_reaction_summary", [{ likes: 5, dislikes: 2 }]);
|
||||||
|
f.reply("map_comments", [
|
||||||
|
{
|
||||||
|
id: comment,
|
||||||
|
author_id: null,
|
||||||
|
body: "Still here",
|
||||||
|
created_at: "2026-10-17T00:00:00Z",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const data = await (await f.run(`maps/${map}/community`)).json();
|
||||||
|
assert.equal(data.viewer, null);
|
||||||
|
assert.equal(data.reaction, 0);
|
||||||
|
assert.equal(data.likes, 5);
|
||||||
|
assert.equal(data.dislikes, 2);
|
||||||
|
assert.equal(data.comments[0].author, "Deleted player");
|
||||||
|
assert.ok(
|
||||||
|
f.calls.some(
|
||||||
|
(c) =>
|
||||||
|
c.method === "is" && c.args[0] === "removed_at" && c.args[1] === null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.ok(!f.calls.some((c) => c.table === "community_reports"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("staff removal records reviewer and timestamp without destroying evidence", async () => {
|
||||||
|
for (const role of ["moderator", "admin"]) {
|
||||||
|
const f = fixture({ role });
|
||||||
|
f.reply("map_comments", { id: comment });
|
||||||
|
assert.equal(
|
||||||
|
(await f.run(`community/comments/${comment}`, "DELETE")).status,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
const update = f.calls.find((c) => c.method === "update")?.args[0] as {
|
||||||
|
removed_by: string;
|
||||||
|
removed_at: string;
|
||||||
|
};
|
||||||
|
assert.equal(update.removed_by, member);
|
||||||
|
assert.ok(Number.isFinite(Date.parse(update.removed_at)));
|
||||||
|
assert.ok(!f.calls.some((c) => c.method === "delete"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("account reports preserve reasons, block self reports, and translate duplicate errors", async () => {
|
||||||
|
const body = {
|
||||||
|
target: "account",
|
||||||
|
targetId: other,
|
||||||
|
category: "advertising",
|
||||||
|
reason: "Repeated unsolicited adverts.",
|
||||||
|
};
|
||||||
|
const f = fixture();
|
||||||
|
f.reply("profiles", { id: other });
|
||||||
|
f.reply("community_reports", null);
|
||||||
|
assert.equal((await f.run("community/reports", "POST", body)).status, 201);
|
||||||
|
assert.deepEqual(f.calls.find((c) => c.method === "insert")?.args, [
|
||||||
|
{
|
||||||
|
reporter_id: member,
|
||||||
|
target: "account",
|
||||||
|
comment_id: null,
|
||||||
|
account_id: other,
|
||||||
|
category: "advertising",
|
||||||
|
reason: body.reason,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await assert.rejects(
|
||||||
|
fixture().run("community/reports", "POST", { ...body, targetId: member }),
|
||||||
|
/own account/,
|
||||||
|
);
|
||||||
|
const duplicate = fixture();
|
||||||
|
duplicate.reply("profiles", { id: other });
|
||||||
|
duplicate.reply("community_reports", null, {
|
||||||
|
code: "23505",
|
||||||
|
message: "duplicate",
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
duplicate.run("community/reports", "POST", body),
|
||||||
|
(e: unknown) => e instanceof ServiceError && e.status === 409,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("comment reports require visible published content; staff reviews use an open-state compare and set", async () => {
|
||||||
|
const hidden = fixture();
|
||||||
|
await assert.rejects(
|
||||||
|
hidden.run("community/reports", "POST", {
|
||||||
|
target: "comment",
|
||||||
|
targetId: comment,
|
||||||
|
category: "spam",
|
||||||
|
reason: "Repeated unsolicited adverts.",
|
||||||
|
}),
|
||||||
|
/not found/,
|
||||||
|
);
|
||||||
|
assert.ok(!hidden.calls.some((c) => c.method === "insert"));
|
||||||
|
const f = fixture({ role: "moderator" });
|
||||||
|
f.reply("community_reports", { id: comment });
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await f.run(`moderation/community-reports/${comment}`, "PATCH", {
|
||||||
|
decision: "dismissed",
|
||||||
|
reason: "The comment follows the rules.",
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
f.calls.some(
|
||||||
|
(c) => c.method === "eq" && c.args[0] === "state" && c.args[1] === "open",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const update = f.calls.find((c) => c.method === "update")?.args[0] as {
|
||||||
|
reviewer_id: string;
|
||||||
|
state: string;
|
||||||
|
};
|
||||||
|
assert.equal(update.reviewer_id, member);
|
||||||
|
assert.equal(update.state, "dismissed");
|
||||||
|
const stale = fixture({ role: "admin" });
|
||||||
|
await assert.rejects(
|
||||||
|
stale.run(`moderation/community-reports/${comment}`, "PATCH", {
|
||||||
|
decision: "resolved",
|
||||||
|
reason: "The issue was addressed by staff.",
|
||||||
|
}),
|
||||||
|
(e: unknown) => e instanceof ServiceError && e.status === 409,
|
||||||
|
);
|
||||||
|
});
|
||||||
125
tests/sql/community.sql
Normal file
125
tests/sql/community.sql
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
-- Transactional fixtures; uses existing accounts on a configured project.
|
||||||
|
begin;
|
||||||
|
do $$
|
||||||
|
declare
|
||||||
|
owner uuid; member uuid; fixture uuid; comment uuid; report uuid;
|
||||||
|
table_name text; client_role text; permission text; totals record;
|
||||||
|
begin
|
||||||
|
if not exists(select 1 from auth.users) then
|
||||||
|
insert into auth.users(id) select gen_random_uuid() from generate_series(1, 2);
|
||||||
|
end if;
|
||||||
|
select id into owner from auth.users order by id limit 1;
|
||||||
|
select id into member from auth.users where id <> owner order by id limit 1;
|
||||||
|
if member is null then raise exception 'Two fixture accounts are required'; end if;
|
||||||
|
insert into public.profiles(id) values(owner),(member) on conflict(id) do nothing;
|
||||||
|
insert into public.datasets(owner_id,name,source,bounds,state)
|
||||||
|
values(owner,'Community regression','plugin','{"minX":0,"minZ":0,"maxX":64,"maxZ":64}','published')
|
||||||
|
returning id into fixture;
|
||||||
|
select * into totals from public.map_reaction_summary(fixture);
|
||||||
|
if totals.likes <> 0 or totals.dislikes <> 0 then raise exception 'Empty totals are incorrect'; end if;
|
||||||
|
insert into public.map_reactions(dataset_id,user_id,value) values(fixture,owner,1),(fixture,member,-1);
|
||||||
|
begin
|
||||||
|
insert into public.map_reactions(dataset_id,user_id,value) values(fixture,owner,-1);
|
||||||
|
raise exception 'Duplicate reaction accepted';
|
||||||
|
exception when unique_violation then null; end;
|
||||||
|
begin
|
||||||
|
update public.map_reactions set value=0 where dataset_id=fixture and user_id=owner;
|
||||||
|
raise exception 'Invalid reaction accepted';
|
||||||
|
exception when check_violation then null; end;
|
||||||
|
select * into totals from public.map_reaction_summary(fixture);
|
||||||
|
if totals.likes <> 1 or totals.dislikes <> 1 then raise exception 'Mixed totals are incorrect'; end if;
|
||||||
|
update public.map_reactions set value=1 where dataset_id=fixture and user_id=member;
|
||||||
|
select * into totals from public.map_reaction_summary(fixture);
|
||||||
|
if totals.likes <> 2 or totals.dislikes <> 0 then raise exception 'Changed reaction counted incorrectly'; end if;
|
||||||
|
delete from public.map_reactions where dataset_id=fixture and user_id=member;
|
||||||
|
select * into totals from public.map_reaction_summary(fixture);
|
||||||
|
if totals.likes <> 1 then raise exception 'Reaction withdrawal counted incorrectly'; end if;
|
||||||
|
|
||||||
|
insert into public.map_comments(dataset_id,author_id,body) values(fixture,member,'A useful map.') returning id into comment;
|
||||||
|
begin
|
||||||
|
insert into public.map_comments(dataset_id,author_id,body) values(fixture,member,' ');
|
||||||
|
raise exception 'Blank comment accepted';
|
||||||
|
exception when check_violation then null; end;
|
||||||
|
begin
|
||||||
|
update public.map_comments set body=repeat('x',2001) where id=comment;
|
||||||
|
raise exception 'Oversized comment accepted';
|
||||||
|
exception when check_violation then null; end;
|
||||||
|
begin
|
||||||
|
update public.map_comments set body=' padded ' where id=comment;
|
||||||
|
raise exception 'Untrimmed comment accepted';
|
||||||
|
exception when check_violation then null; end;
|
||||||
|
update public.map_comments set removed_at=now(),removed_by=owner where id=comment;
|
||||||
|
if not exists(select 1 from public.map_comments where id=comment and body='A useful map.' and removed_at is not null and removed_by=owner)
|
||||||
|
then raise exception 'Comment removal lost its audit record'; end if;
|
||||||
|
|
||||||
|
insert into public.community_reports(reporter_id,target,comment_id,category,reason)
|
||||||
|
values(owner,'comment',comment,'spam','Repeated unrelated messages.') returning id into report;
|
||||||
|
begin
|
||||||
|
insert into public.community_reports(reporter_id,target,comment_id,category,reason)
|
||||||
|
values(owner,'comment',comment,'advertising','Repeated advertising messages.');
|
||||||
|
raise exception 'Duplicate open comment report accepted';
|
||||||
|
exception when unique_violation then null; end;
|
||||||
|
update public.community_reports set state='dismissed',reviewer_id=member,reviewed_at=now(),review_reason='Reviewed the context.' where id=report;
|
||||||
|
insert into public.community_reports(reporter_id,target,comment_id,category,reason)
|
||||||
|
values(owner,'comment',comment,'advertising','New advertising in this comment.');
|
||||||
|
insert into public.community_reports(reporter_id,target,account_id,category,reason)
|
||||||
|
values(owner,'account',member,'advertising','Profile contains repeated advertising.');
|
||||||
|
begin
|
||||||
|
insert into public.community_reports(reporter_id,target,account_id,category,reason)
|
||||||
|
values(owner,'account',member,'spam','Repeated unsolicited messages.');
|
||||||
|
raise exception 'Duplicate open account report accepted';
|
||||||
|
exception when unique_violation then null; end;
|
||||||
|
begin
|
||||||
|
insert into public.community_reports(reporter_id,target,account_id,category,reason)
|
||||||
|
values(owner,'comment',member,'other','A mismatched report target.');
|
||||||
|
raise exception 'Mismatched report target accepted';
|
||||||
|
exception when check_violation then null; end;
|
||||||
|
begin
|
||||||
|
insert into public.community_reports(reporter_id,target,account_id,category,reason)
|
||||||
|
values(member,'account',owner,'unknown','An invalid report category.');
|
||||||
|
raise exception 'Invalid category accepted';
|
||||||
|
exception when check_violation then null; end;
|
||||||
|
begin
|
||||||
|
insert into public.community_reports(reporter_id,target,account_id,category,reason)
|
||||||
|
values(member,'account',owner,'other','short');
|
||||||
|
raise exception 'Short report accepted';
|
||||||
|
exception when check_violation then null; end;
|
||||||
|
begin
|
||||||
|
update public.community_reports set reason=repeat('x',2001) where id=report;
|
||||||
|
raise exception 'Oversized report accepted';
|
||||||
|
exception when check_violation then null; end;
|
||||||
|
begin
|
||||||
|
update public.community_reports set review_reason=repeat('x',2001) where id=report;
|
||||||
|
raise exception 'Oversized review accepted';
|
||||||
|
exception when check_violation then null; end;
|
||||||
|
begin
|
||||||
|
update public.community_reports set state='unknown' where id=report;
|
||||||
|
raise exception 'Invalid report state accepted';
|
||||||
|
exception when check_violation then null; end;
|
||||||
|
|
||||||
|
foreach table_name in array array['map_reactions','map_comments','community_reports'] loop
|
||||||
|
if not (select relrowsecurity from pg_class where oid=('public.'||table_name)::regclass)
|
||||||
|
then raise exception 'RLS disabled for %', table_name; end if;
|
||||||
|
foreach client_role in array array['anon','authenticated'] loop
|
||||||
|
foreach permission in array array['SELECT','INSERT','UPDATE','DELETE'] loop
|
||||||
|
if has_table_privilege(client_role,'public.'||table_name,permission)
|
||||||
|
then raise exception 'Client permission exposed: % % %',client_role,table_name,permission; end if;
|
||||||
|
end loop;
|
||||||
|
end loop;
|
||||||
|
if not has_table_privilege('service_role','public.'||table_name,'SELECT,INSERT,UPDATE,DELETE')
|
||||||
|
then raise exception 'Service role missing permissions on %',table_name; end if;
|
||||||
|
end loop;
|
||||||
|
if has_function_privilege('anon','public.map_reaction_summary(uuid)','EXECUTE')
|
||||||
|
or has_function_privilege('authenticated','public.map_reaction_summary(uuid)','EXECUTE')
|
||||||
|
or not has_function_privilege('service_role','public.map_reaction_summary(uuid)','EXECUTE')
|
||||||
|
then raise exception 'Reaction RPC grants incorrect'; end if;
|
||||||
|
if (select prosecdef from pg_proc where oid='public.map_reaction_summary(uuid)'::regprocedure)
|
||||||
|
then raise exception 'Reaction RPC must use caller privileges'; end if;
|
||||||
|
|
||||||
|
delete from public.datasets where id=fixture;
|
||||||
|
if exists(select 1 from public.map_comments where id=comment)
|
||||||
|
or exists(select 1 from public.map_reactions where dataset_id=fixture)
|
||||||
|
or exists(select 1 from public.community_reports where comment_id=comment)
|
||||||
|
then raise exception 'Map deletion left community content behind'; end if;
|
||||||
|
end; $$;
|
||||||
|
rollback;
|
||||||
Reference in New Issue
Block a user