User reports (#3232)

This commit is contained in:
Kalle 2026-07-18 12:46:07 +03:00 committed by GitHub
parent 7d1e680a74
commit ef0d32ef18
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 879 additions and 11 deletions

View File

@ -27,6 +27,9 @@ VITE_STATIC_ASSETS_URL=https://sendou-assets.nyc3.cdn.digitaloceanspaces.com
TWITCH_CLIENT_ID=
TWITCH_CLIENT_SECRET=
// Discord webhook new user reports are posted to (skipped when unset)
USER_REPORT_DISCORD_WEBHOOK_URL=
SKALOP_SYSTEM_MESSAGE_URL=http://localhost:5900/system
SKALOP_TOKEN=secret
REDIS_URL=redis://redis:6379

View File

@ -76,7 +76,12 @@ import {
SEED_TEAM_IMAGES,
SEED_TOURNAMENT_IMAGES,
} from "../../../scripts/seed-art-urls";
import type { ParsedMemento, Tables, UserMapModePreferences } from "../tables";
import {
type ParsedMemento,
type Tables,
USER_REPORT_CATEGORIES,
type UserMapModePreferences,
} from "../tables";
import {
ADMIN_TEST_AVATAR,
AMOUNT_OF_CALENDAR_EVENTS,
@ -236,6 +241,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
playedMatches,
variation === "NO_SQ_GROUPS" ? undefined : () => groups(variation),
friendCodes,
userReports,
lfgPosts,
variation === "NO_SCRIMS" ? undefined : scrimPosts,
variation === "NO_SCRIMS" ? undefined : scrimPostRequests,
@ -3165,6 +3171,28 @@ async function friendCodes() {
}
}
async function userReports() {
// uneven spread over the trailing 12 months so the admin tab bar graph shows variety
const monthsAgoDistribution = [
0, 0, 0, 1, 2, 2, 2, 2, 5, 5, 7, 8, 10, 11, 11,
];
await db
.insertInto("UserReport")
.values(
monthsAgoDistribution.map((monthsAgo, i) => ({
reportedUserId: NZAP_TEST_ID,
reporterUserId: 10 + i,
category: USER_REPORT_CATEGORIES[i % USER_REPORT_CATEGORIES.length],
description: faker.lorem.sentences({ min: 1, max: 3 }),
createdAt: dateToDatabaseTimestamp(
sub(new Date(), { months: monthsAgo, days: (i * 3) % 7, hours: i }),
),
})),
)
.execute();
}
async function lfgPosts() {
const allUsers = userIdsInRandomOrder(true).slice(0, 100);

View File

@ -1287,6 +1287,24 @@ export interface ModNote {
isDeleted: Generated<DBBoolean>;
}
export const USER_REPORT_CATEGORIES = [
"INAPPROPRIATE_CONTENT",
"ALTING",
"HARASSMENT",
"CHEATING",
"OTHER",
] as const;
export type UserReportCategory = (typeof USER_REPORT_CATEGORIES)[number];
export interface UserReport {
id: GeneratedAlways<number>;
reportedUserId: number;
reporterUserId: number;
category: UserReportCategory;
description: string;
createdAt: Generated<number>;
}
export interface Video {
eventId: number | null;
id: GeneratedAlways<number>;
@ -1563,6 +1581,7 @@ export interface DB {
TenStarWeapon: TenStarWeapon;
UserFriendCode: UserFriendCode;
UserWidget: UserWidget;
UserReport: UserReport;
Video: Video;
VideoMatch: VideoMatch;
VideoMatchPlayer: VideoMatchPlayer;

View File

@ -94,6 +94,35 @@ export function migrate(args: { newUserId: number; oldUserId: number }) {
.set({ userId: args.oldUserId })
.execute();
// reports between the two merged accounts would become self-reports
await trx
.deleteFrom("UserReport")
.where((eb) =>
eb.or([
eb.and([
eb("reporterUserId", "=", args.newUserId),
eb("reportedUserId", "=", args.oldUserId),
]),
eb.and([
eb("reporterUserId", "=", args.oldUserId),
eb("reportedUserId", "=", args.newUserId),
]),
]),
)
.execute();
await deleteOlderCollidingUserReports(trx, args, "reporterUserId");
await deleteOlderCollidingUserReports(trx, args, "reportedUserId");
await trx
.updateTable("UserReport")
.where("reporterUserId", "=", args.newUserId)
.set({ reporterUserId: args.oldUserId })
.execute();
await trx
.updateTable("UserReport")
.where("reportedUserId", "=", args.newUserId)
.set({ reportedUserId: args.oldUserId })
.execute();
// special case: delete same team membership to avoid unique constraint violation
await trx
.deleteFrom("AllTeamMember")
@ -141,6 +170,47 @@ export function migrate(args: { newUserId: number; oldUserId: number }) {
});
}
/**
* Merging accounts can collide on the one-report-per-pair unique index; the newer
* report (by `createdAt`, id as tie-breaker) wins and the other row is dropped.
*/
function deleteOlderCollidingUserReports(
trx: Transaction<DB>,
args: { newUserId: number; oldUserId: number },
column: "reporterUserId" | "reportedUserId",
) {
const otherColumn =
column === "reporterUserId" ? "reportedUserId" : "reporterUserId";
return trx
.deleteFrom("UserReport")
.where(column, "in", [args.newUserId, args.oldUserId])
.where((eb) =>
eb.exists(
eb
.selectFrom("UserReport as newer")
.select("newer.id")
.where(`newer.${column}`, "in", [args.newUserId, args.oldUserId])
.whereRef(`newer.${otherColumn}`, "=", `UserReport.${otherColumn}`)
.whereRef("newer.id", "!=", "UserReport.id")
.where((inner) =>
inner.or([
inner("newer.createdAt", ">", inner.ref("UserReport.createdAt")),
inner.and([
inner(
"newer.createdAt",
"=",
inner.ref("UserReport.createdAt"),
),
inner("newer.id", ">", inner.ref("UserReport.id")),
]),
]),
),
),
)
.execute();
}
async function validateMigration(
trx: Transaction<DB>,
args: { newUserId: number; oldUserId: number },

View File

@ -1,6 +1,7 @@
import clsx from "clsx";
import {
BadgeCheck,
Flag,
Megaphone,
NotebookPen,
NotebookText,
@ -25,6 +26,7 @@ import { Placement } from "~/components/Placement";
import type { XRankPlacementRegion } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { MutualFriends } from "~/features/user-page/components/MutualFriends";
import { ReportUserDialog } from "~/features/user-report/components/ReportUserDialog";
import { useLayoutSize } from "~/hooks/useMainContentWidth";
import type { BrandId } from "~/modules/in-game-lists/types";
import { assertUnreachable } from "~/utils/types";
@ -96,6 +98,7 @@ export function UserCard({
// take focus; the note view inside the card opens them
const [isNoteDialogOpen, setIsNoteDialogOpen] = React.useState(false);
const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = React.useState(false);
const [isReportDialogOpen, setIsReportDialogOpen] = React.useState(false);
const fetcher = useFetcher<UserCardFriendshipLoaderData>();
const friendshipLoadedRef = React.useRef(false);
@ -123,6 +126,11 @@ export function UserCard({
setIsDeleteConfirmOpen(true);
};
const openReportDialog = () => {
setIsOpen(false);
setIsReportDialogOpen(true);
};
if (!data) return <>{children}</>;
return (
@ -138,6 +146,7 @@ export function UserCard({
withMutualFriends={withMutualFriends}
onEditNote={openNoteDialog}
onDeleteNote={openDeleteConfirm}
onReport={user ? openReportDialog : undefined}
/>
</Dialog>
</Popover>
@ -150,6 +159,13 @@ export function UserCard({
onClose={() => setIsNoteDialogOpen(false)}
/>
) : null}
{isReportDialogOpen ? (
<ReportUserDialog
userId={data.id}
username={data.username}
onClose={() => setIsReportDialogOpen(false)}
/>
) : null}
<FormWithConfirm
isOpen={isDeleteConfirmOpen}
onOpenChange={setIsDeleteConfirmOpen}
@ -194,6 +210,7 @@ function CardContent({
withMutualFriends,
onEditNote,
onDeleteNote,
onReport,
}: {
data: UserCardData;
/** Lazy-loaded; `undefined` while the friendship fetch is in flight. */
@ -202,6 +219,8 @@ function CardContent({
withMutualFriends: boolean;
onEditNote: () => void;
onDeleteNote: () => void;
/** Not passed for logged-out viewers, hiding the report button. */
onReport: (() => void) | undefined;
}) {
const { t } = useTranslation(["common", "user"]);
const location = useLocation();
@ -266,6 +285,16 @@ function CardContent({
onPress={onNoteButtonPress}
aria-label={t("user:card.editPrivateNote")}
/>
{onReport ? (
<SendouButton
size="miniscule"
shape="circle"
icon={<Flag />}
onPress={onReport}
aria-label="Report user"
data-testid="report-user-button"
/>
) : null}
</>
)}
</div>

View File

@ -1,11 +1,16 @@
import { isSameMonth, startOfMonth, subMonths } from "date-fns";
import type { LoaderFunctionArgs } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import * as UserReportRepository from "~/features/user-report/UserReportRepository.server";
import { requireRole } from "~/modules/permissions/guards.server";
import { databaseTimestampToDate } from "~/utils/dates";
import { logger } from "~/utils/logger";
import { notFoundIfFalsy } from "~/utils/remix.server";
import { convertSnowflakeToDate } from "~/utils/users";
const REPORT_GRAPH_MONTHS = 12;
export const loader = async ({ params }: LoaderFunctionArgs) => {
const loggedInUser = requireUser();
@ -25,10 +30,33 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
const friendCodes = await UserRepository.friendCodesByUserId(user.id);
const reports = await UserReportRepository.findAllByReportedUserId(user.id);
return {
...userData,
discordId: user.discordId,
discordAccountCreatedAt: convertSnowflakeToDate(user.discordId).getTime(),
friendCodes,
reports,
reportsMonthlyCounts: reportsMonthlyCounts(reports),
};
};
function reportsMonthlyCounts(
reports: Awaited<
ReturnType<typeof UserReportRepository.findAllByReportedUserId>
>,
) {
const now = new Date();
return Array.from({ length: REPORT_GRAPH_MONTHS }, (_, i) => {
const month = startOfMonth(subMonths(now, REPORT_GRAPH_MONTHS - 1 - i));
return {
month: month.getTime(),
count: reports.filter((report) =>
isSameMonth(databaseTimestampToDate(report.createdAt), month),
).length,
};
});
}

View File

@ -1,8 +1,15 @@
import type { LoaderFunctionArgs } from "react-router";
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { notFoundIfFalsy } from "~/utils/remix.server";
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id: userId } = notFoundIfFalsy(
await UserRepository.identifierToUserId(params.identifier!),
);
const userCards = await UserCardRepository.userCards({ userIds: [userId] });
const widgetsEnabled = await UserRepository.widgetsEnabledByIdentifier(
params.identifier!,
);
@ -13,6 +20,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
widgets: notFoundIfFalsy(
await UserRepository.widgetsByUserId(params.identifier!),
),
...userCards,
};
}
@ -23,5 +31,6 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
return {
type: "old" as const,
user,
...userCards,
};
};

View File

@ -1,5 +1,5 @@
import { Plus } from "lucide-react";
import { useLoaderData, useMatches } from "react-router";
import { Link, useLoaderData, useMatches } from "react-router";
import { Divider } from "~/components/Divider";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
@ -7,7 +7,10 @@ import { FormWithConfirm } from "~/components/FormWithConfirm";
import { LocaleTime } from "~/components/LocaleTime";
import { useUser } from "~/features/auth/core/user";
import { addModNoteSchema } from "~/features/user-page/user-page-schemas";
import { ReportsBarChart } from "~/features/user-report/components/ReportsBarChart";
import { USER_REPORT_CATEGORY_LABELS } from "~/features/user-report/user-report-constants";
import { SendouForm } from "~/form";
import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
import invariant from "~/utils/invariant";
import { userPage } from "~/utils/urls";
import { action } from "../actions/u.$identifier.admin.server";
@ -44,6 +47,13 @@ export default function UserAdminPage() {
<ModNotes />
</div>
<div className="stack sm">
<Divider smallText className="font-bold">
Reports
</Divider>
<Reports />
</div>
<div className="stack sm">
<Divider smallText className="font-bold">
Ban log
@ -189,6 +199,44 @@ function NewModNoteDialog() {
);
}
function Reports() {
const data = useLoaderData<typeof loader>();
const formatDistanceToNow = useFormatDistanceToNow();
if (data.reports.length === 0) {
return <p className="text-center text-lighter italic">No reports</p>;
}
return (
<div className="stack md">
<p className="font-bold">{data.reports.length} total</p>
<ReportsBarChart monthlyCounts={data.reportsMonthlyCounts} />
<div className="stack sm" data-testid="user-reports-list">
{data.reports.map((report) => (
<details key={report.id}>
<summary>
<span className="font-bold">
{USER_REPORT_CATEGORY_LABELS[report.category]}
</span>{" "}
- By:{" "}
<Link
to={userPage({
discordId: report.reporterDiscordId,
customUrl: report.reporterCustomUrl,
})}
>
{report.reporterUsername}
</Link>{" "}
- {formatDistanceToNow(report.createdAt)}
</summary>
<p className="ml-2 whitespace-pre-wrap">{report.description}</p>
</details>
))}
</div>
</div>
);
}
function BanLog() {
const data = useLoaderData<typeof loader>();

View File

@ -20,6 +20,7 @@ import { TwitchIcon } from "~/components/icons/Twitch";
import { YouTubeIcon } from "~/components/icons/YouTube";
import { useUser } from "~/features/auth/core/user";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { UserCard } from "~/features/user-card/components/UserCard";
import { modesShort } from "~/modules/in-game-lists/modes";
import { countryCodeToTranslatedName } from "~/utils/i18n";
import invariant from "~/utils/invariant";
@ -89,10 +90,16 @@ function NewUserInfoPage() {
<div className={newStyles.container}>
<div className="stack sm">
<div className={newStyles.header}>
<Avatar user={layoutData.user} size="xmd" loading="eager" />
<UserCard userId={layoutData.user.id}>
<Avatar user={layoutData.user} size="xmd" loading="eager" />
</UserCard>
<div className={newStyles.userInfo}>
<div className={newStyles.nameGroup}>
<h1 className={newStyles.username}>{layoutData.user.username}</h1>
<h1 className={newStyles.username}>
<UserCard userId={layoutData.user.id}>
{layoutData.user.username}
</UserCard>
</h1>
<ProfileSubtitle
inGameName={layoutData.user.inGameName}
pronouns={layoutData.user.pronouns}
@ -181,15 +188,19 @@ export function OldUserInfoPage() {
<div className={styles.container}>
<div className="stack sm">
<div className={styles.avatarContainer}>
<Avatar
user={layoutData.user}
size="lg"
className={styles.avatar}
loading="eager"
/>
<UserCard userId={layoutData.user.id}>
<Avatar
user={layoutData.user}
size="lg"
className={styles.avatar}
loading="eager"
/>
</UserCard>
<div>
<h2 className={styles.name}>
<div>{layoutData.user.username}</div>
<UserCard userId={layoutData.user.id}>
<div>{layoutData.user.username}</div>
</UserCard>
<div>
{data.user.country ? (
<Flag countryCode={data.user.country} tiny />

View File

@ -0,0 +1,77 @@
import { subMonths, subYears } from "date-fns";
import { db } from "~/db/sql";
import type { TablesInsertable } from "~/db/tables";
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
/**
* Inserts a report or, when the (reporter, reported) pair already has one, overwrites
* its category and description bumping `createdAt`. Returns whether an existing report
* was updated instead of a new one created.
*/
export function upsert(
args: Omit<TablesInsertable["UserReport"], "createdAt">,
) {
return db.transaction().execute(async (trx) => {
const existing = await trx
.selectFrom("UserReport")
.select("id")
.where("reportedUserId", "=", args.reportedUserId)
.where("reporterUserId", "=", args.reporterUserId)
.executeTakeFirst();
await trx
.insertInto("UserReport")
.values(args)
.onConflict((oc) =>
oc.columns(["reportedUserId", "reporterUserId"]).doUpdateSet({
category: args.category,
description: args.description,
createdAt: databaseTimestampNow(),
}),
)
.execute();
return { isUpdate: Boolean(existing) };
});
}
/** Returns how many reports have been made against the given user in the last month and in the last year. */
export async function countRecentByReportedUserId(reportedUserId: number) {
const monthAgo = dateToDatabaseTimestamp(subMonths(new Date(), 1));
const yearAgo = dateToDatabaseTimestamp(subYears(new Date(), 1));
const row = await db
.selectFrom("UserReport")
.select((eb) => [
eb.fn
.countAll<number>()
.filterWhere("createdAt", ">=", monthAgo)
.as("lastMonth"),
eb.fn.countAll<number>().as("lastYear"),
])
.where("reportedUserId", "=", reportedUserId)
.where("createdAt", ">=", yearAgo)
.executeTakeFirstOrThrow();
return { lastMonth: row.lastMonth, lastYear: row.lastYear };
}
/** Returns all reports made against the given user, newest first, with the reporter's identifying info. */
export function findAllByReportedUserId(reportedUserId: number) {
return db
.selectFrom("UserReport")
.innerJoin("User", "User.id", "UserReport.reporterUserId")
.select([
"UserReport.id",
"UserReport.category",
"UserReport.description",
"UserReport.createdAt",
"UserReport.reporterUserId",
"User.username as reporterUsername",
"User.discordId as reporterDiscordId",
"User.customUrl as reporterCustomUrl",
])
.where("UserReport.reportedUserId", "=", reportedUserId)
.orderBy("UserReport.createdAt", "desc")
.execute();
}

View File

@ -0,0 +1,53 @@
import { useTranslation } from "react-i18next";
import { SendouDialog } from "~/components/elements/Dialog";
import { toastQueue } from "~/components/elements/Toast";
import { FormMessage } from "~/components/FormMessage";
import { SendouForm } from "~/form";
import { userReportPage } from "~/utils/urls";
import { reportUserSchema } from "../user-report-schemas";
/**
* Modal for reporting a user to the staff, posting to the `/user-report/:id` resource
* route. Re-reporting the same user overwrites the previous report. Rendered wherever
* a `UserCard` lives.
*/
export function ReportUserDialog({
userId,
username,
onClose,
}: {
userId: number;
username: string;
onClose: () => void;
}) {
const { t } = useTranslation(["user"]);
return (
<SendouDialog
heading={t("user:card.report.header", { name: username })}
onClose={onClose}
>
<SendouForm
schema={reportUserSchema}
action={userReportPage(userId)}
onSuccess={() => {
toastQueue.add(
{ message: "Report sent to the staff", variant: "success" },
{ timeout: 5000 },
);
onClose();
}}
>
{({ FormField }) => (
<>
<FormField name="category" />
<FormField name="description" />
<FormMessage type="info">
{t("user:card.report.falseReportsWarning")}
</FormMessage>
</>
)}
</SendouForm>
</SendouDialog>
);
}

View File

@ -0,0 +1,6 @@
.container {
height: 200px;
background-color: var(--color-bg-high);
border-radius: var(--radius-box);
padding: var(--s-2);
}

View File

@ -0,0 +1,104 @@
import {
BarElement,
CategoryScale,
Chart as ChartJS,
LinearScale,
Tooltip,
} from "chart.js";
import { format } from "date-fns";
import * as React from "react";
import { Bar } from "react-chartjs-2";
import { useHydrated } from "~/hooks/useHydrated";
import styles from "./ReportsBarChart.module.css";
ChartJS.register(CategoryScale, LinearScale, BarElement, Tooltip);
/**
* Bar graph of reports made against a user, one bar per calendar month.
* Theme colors are read from CSS variables like `app/components/Chart.tsx` does.
*/
export function ReportsBarChart({
monthlyCounts,
}: {
monthlyCounts: Array<{
/** Start of the calendar month as a JavaScript timestamp */
month: number;
count: number;
}>;
}) {
const isHydrated = useHydrated();
const [colors, setColors] = React.useState({
bar: "",
border: "",
borderHigh: "",
text: "",
});
React.useEffect(() => {
const resolve = () => {
const get = (v: string) =>
getComputedStyle(document.documentElement).getPropertyValue(v).trim();
setColors({
bar: get("--color-text-accent"),
border: get("--color-border"),
borderHigh: get("--color-border-high"),
text: get("--color-text-high"),
});
};
resolve();
const root = document.documentElement;
const observer = new MutationObserver(resolve);
observer.observe(root, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
if (!isHydrated) {
return <div className={styles.container} />;
}
const scaleDefaults = {
grid: { color: colors.border },
border: { color: colors.borderHigh },
ticks: { color: colors.text },
};
return (
<div className={styles.container}>
<Bar
data={{
labels: monthlyCounts.map(({ month }) =>
format(new Date(month), "MMM yy"),
),
datasets: [
{
data: monthlyCounts.map(({ count }) => count),
backgroundColor: colors.bar,
},
],
}}
options={{
animation: false,
maintainAspectRatio: false,
scales: {
x: {
...scaleDefaults,
grid: { display: false },
ticks: { ...scaleDefaults.ticks, maxRotation: 0 },
},
y: {
...scaleDefaults,
beginAtZero: true,
ticks: { ...scaleDefaults.ticks, precision: 0 },
},
},
plugins: {
legend: { display: false },
},
}}
/>
</div>
);
}

View File

@ -0,0 +1,84 @@
import type { UserReportCategory } from "~/db/tables";
import { logger } from "~/utils/logger";
import { SENDOU_INK_BASE_URL, userPage } from "~/utils/urls";
import { USER_REPORT_CATEGORY_LABELS } from "../user-report-constants";
const EMBED_DESCRIPTION_MAX_LENGTH = 1000;
/**
* Posts a rich embed about a new/updated user report to the mod channel Discord webhook.
* Fire-and-forget: meant to be called without awaiting, never throws, skipped with a log
* line when `USER_REPORT_DISCORD_WEBHOOK_URL` is unset (e.g. in development).
*/
export function sendUserReportWebhook(args: {
reportedUser: { id: number; username: string };
reporter: { username: string; discordId: string; customUrl: string | null };
category: UserReportCategory;
description: string;
isUpdate: boolean;
reportCounts: { lastMonth: number; lastYear: number };
}) {
const webhookUrl = process.env.USER_REPORT_DISCORD_WEBHOOK_URL;
if (!webhookUrl) {
logger.info(
"USER_REPORT_DISCORD_WEBHOOK_URL not set, skipping user report webhook",
);
return;
}
const reportedUserAdminUrl = `${SENDOU_INK_BASE_URL}/u/${args.reportedUser.id}/admin`;
const reporterUrl = `${SENDOU_INK_BASE_URL}${userPage(args.reporter)}`;
const body = {
embeds: [
{
title: args.isUpdate ? "User report updated" : "New user report",
fields: [
{
name: "Reported user",
value: `[${args.reportedUser.username}](${reportedUserAdminUrl})`,
},
{
name: "Reporter",
value: `[${args.reporter.username}](${reporterUrl})`,
},
{
name: "Category",
value: USER_REPORT_CATEGORY_LABELS[args.category],
},
{
name: "Description",
value: truncate(args.description),
},
{
name: "Reports against this user",
value: `Last month: ${args.reportCounts.lastMonth} • Last year: ${args.reportCounts.lastYear}`,
},
],
timestamp: new Date().toISOString(),
},
],
};
fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
.then((response) => {
if (!response.ok) {
logger.error(
`User report webhook responded with status ${response.status}`,
);
}
})
.catch((error) => {
logger.error("Failed to send user report webhook", error);
});
}
function truncate(description: string) {
if (description.length <= EMBED_DESCRIPTION_MAX_LENGTH) return description;
return `${description.slice(0, EMBED_DESCRIPTION_MAX_LENGTH)}`;
}

View File

@ -0,0 +1,56 @@
import type { ActionFunctionArgs } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseFormData } from "~/form/parse.server";
import {
errorToastIfFalsy,
notFoundIfFalsy,
parseParams,
} from "~/utils/remix.server";
import { sendUserReportWebhook } from "../core/discord-webhook.server";
import * as UserReportRepository from "../UserReportRepository.server";
import {
reportUserParamsSchema,
reportUserSchema,
} from "../user-report-schemas";
export const action = async ({ request, params }: ActionFunctionArgs) => {
const user = requireUser();
const reportedUserId = parseParams({
params,
schema: reportUserParamsSchema,
}).id;
errorToastIfFalsy(reportedUserId !== user.id, "Can't report yourself");
const reportedUser = notFoundIfFalsy(
await UserRepository.findLeanById(reportedUserId),
);
const result = await parseFormData({ request, schema: reportUserSchema });
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
const { isUpdate } = await UserReportRepository.upsert({
reportedUserId,
reporterUserId: user.id,
category: result.data.category,
description: result.data.description,
});
const reportCounts =
await UserReportRepository.countRecentByReportedUserId(reportedUserId);
sendUserReportWebhook({
reportedUser,
reporter: user,
category: result.data.category,
description: result.data.description,
isUpdate,
reportCounts,
});
return null;
};

View File

@ -0,0 +1,14 @@
import type { UserReportCategory } from "~/db/tables";
export const USER_REPORT = {
DESCRIPTION_MAX_LENGTH: 2000,
};
/** English display names, shown on the staff-only admin tab and in the Discord webhook embed. */
export const USER_REPORT_CATEGORY_LABELS: Record<UserReportCategory, string> = {
INAPPROPRIATE_CONTENT: "Inappropriate content",
ALTING: "Alting",
HARASSMENT: "Harassment",
CHEATING: "Cheating",
OTHER: "Other",
};

View File

@ -0,0 +1,28 @@
import { z } from "zod";
import { select, textAreaRequired } from "~/form/fields";
import { id } from "~/utils/zod";
import { USER_REPORT } from "./user-report-constants";
export const reportUserSchema = z.object({
category: select({
label: "labels.reportCategory",
items: [
{
label: "options.userReportCategory.INAPPROPRIATE_CONTENT",
value: "INAPPROPRIATE_CONTENT",
},
{ label: "options.userReportCategory.ALTING", value: "ALTING" },
{ label: "options.userReportCategory.HARASSMENT", value: "HARASSMENT" },
{ label: "options.userReportCategory.CHEATING", value: "CHEATING" },
{ label: "options.userReportCategory.OTHER", value: "OTHER" },
],
}),
description: textAreaRequired({
label: "labels.description",
maxLength: USER_REPORT.DESCRIPTION_MAX_LENGTH,
}),
});
export const reportUserParamsSchema = z.object({
id,
});

View File

@ -65,6 +65,8 @@ export default [
"features/user-card/routes/user-card.$id.note.ts",
),
route("/user-report/:id", "features/user-report/routes/user-report.$id.ts"),
route("/events", "features/calendar/routes/events.tsx"),
route("/suspended", "features/ban/routes/suspended.tsx"),

View File

@ -152,6 +152,8 @@ export const userCardFriendshipPage = (
export const userCardNotePage = (userId: number) => `/user-card/${userId}/note`;
export const userReportPage = (userId: number) => `/user-report/${userId}`;
interface UserLinkArgs {
discordId: Tables["User"]["discordId"];
customUrl?: Tables["User"]["customUrl"];

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

44
e2e/user-report.spec.ts Normal file
View File

@ -0,0 +1,44 @@
import { NZAP_TEST_ID } from "~/db/seed/constants";
import { LFG_PAGE } from "~/utils/urls";
import {
expect,
impersonate,
navigate,
seed,
submit,
test,
} from "./helpers/playwright";
const REPORTER_ID = 30;
test.describe("User report", () => {
test("reports a user from the card and shows it on the admin page", async ({
page,
}) => {
await seed(page);
await impersonate(page, REPORTER_ID);
await navigate({ page, url: LFG_PAGE });
await page.getByRole("button", { name: "N-ZAP" }).first().click();
await page.getByTestId("report-user-button").click();
const description = "Called my team mean names in the match chat";
await page.getByLabel("Category").selectOption("HARASSMENT");
await page.getByLabel("Description").fill(description);
await submit(page);
await expect(page.getByText("Report sent to the staff")).toBeAttached();
await impersonate(page);
await navigate({ page, url: `/u/${NZAP_TEST_ID}/admin` });
const reportsList = page.getByTestId("user-reports-list");
// 15 seeded reports + the one just made
await expect(page.getByText("16 total")).toBeVisible();
await expect(reportsList.locator("details")).toHaveCount(16);
await expect(page.getByText(description)).not.toBeVisible();
await reportsList.locator("summary").first().click();
await expect(page.getByText(description)).toBeVisible();
});
});

View File

@ -223,6 +223,12 @@
"bottomTexts.banUserNoteHelp": "",
"labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "",
"labels.reportCategory": "",
"options.userReportCategory.INAPPROPRIATE_CONTENT": "",
"options.userReportCategory.ALTING": "",
"options.userReportCategory.HARASSMENT": "",
"options.userReportCategory.CHEATING": "",
"options.userReportCategory.OTHER": "",
"labels.scrimCancelReason": "",
"bottomTexts.scrimCancelReasonHelp": "",
"bottomTexts.bioMarkdown": "",

View File

@ -210,6 +210,8 @@
"card.friendRequestSent": "",
"card.friendRequestAccepted": "",
"card.editPrivateNote": "",
"card.report.header": "",
"card.report.falseReportsWarning": "",
"card.privateNote": "",
"card.xp": "",
"card.freeAgent": "",

View File

@ -223,6 +223,12 @@
"bottomTexts.banUserNoteHelp": "",
"labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "",
"labels.reportCategory": "",
"options.userReportCategory.INAPPROPRIATE_CONTENT": "",
"options.userReportCategory.ALTING": "",
"options.userReportCategory.HARASSMENT": "",
"options.userReportCategory.CHEATING": "",
"options.userReportCategory.OTHER": "",
"labels.scrimCancelReason": "",
"bottomTexts.scrimCancelReasonHelp": "",
"bottomTexts.bioMarkdown": "",

View File

@ -210,6 +210,8 @@
"card.friendRequestSent": "",
"card.friendRequestAccepted": "",
"card.editPrivateNote": "",
"card.report.header": "",
"card.report.falseReportsWarning": "",
"card.privateNote": "",
"card.xp": "",
"card.freeAgent": "",

View File

@ -223,6 +223,12 @@
"bottomTexts.banUserNoteHelp": "This note is only visible to organization admins.",
"labels.banUserExpiresAt": "Ban expiration date",
"bottomTexts.banUserExpiresAtHelp": "Leave empty for a permanent ban",
"labels.reportCategory": "Category",
"options.userReportCategory.INAPPROPRIATE_CONTENT": "Inappropriate content",
"options.userReportCategory.ALTING": "Alting",
"options.userReportCategory.HARASSMENT": "Harassment",
"options.userReportCategory.CHEATING": "Cheating",
"options.userReportCategory.OTHER": "Other",
"labels.scrimCancelReason": "Reason for cancellation",
"bottomTexts.scrimCancelReasonHelp": "Explain why you are cancelling the scrim. This will be visible to the other team.",
"bottomTexts.bioMarkdown": "Supports Markdown",

View File

@ -210,6 +210,8 @@
"card.friendRequestSent": "Friend request sent",
"card.friendRequestAccepted": "Friend request accepted",
"card.editPrivateNote": "Edit private note",
"card.report.header": "Report {{name}}",
"card.report.falseReportsWarning": "Abuse of the reporting system or making false reports can lead to the suspension of your account.",
"card.privateNote": "Private note",
"card.xp": "XP",
"card.freeAgent": "FA",

View File

@ -223,6 +223,12 @@
"bottomTexts.banUserNoteHelp": "Esta nota solo es visible para los administradores de la organización.",
"labels.banUserExpiresAt": "Fecha de expiración del baneo",
"bottomTexts.banUserExpiresAtHelp": "Deja vacío para un baneo permanente",
"labels.reportCategory": "",
"options.userReportCategory.INAPPROPRIATE_CONTENT": "",
"options.userReportCategory.ALTING": "",
"options.userReportCategory.HARASSMENT": "",
"options.userReportCategory.CHEATING": "",
"options.userReportCategory.OTHER": "",
"labels.scrimCancelReason": "Motivo de la cancelación",
"bottomTexts.scrimCancelReasonHelp": "Explica por qué cancelas el scrim. Esto será visible para el otro equipo.",
"bottomTexts.bioMarkdown": "Compatible con Markdown",

View File

@ -211,6 +211,8 @@
"card.friendRequestSent": "",
"card.friendRequestAccepted": "",
"card.editPrivateNote": "",
"card.report.header": "",
"card.report.falseReportsWarning": "",
"card.privateNote": "",
"card.xp": "",
"card.freeAgent": "",

View File

@ -223,6 +223,12 @@
"bottomTexts.banUserNoteHelp": "",
"labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "",
"labels.reportCategory": "",
"options.userReportCategory.INAPPROPRIATE_CONTENT": "",
"options.userReportCategory.ALTING": "",
"options.userReportCategory.HARASSMENT": "",
"options.userReportCategory.CHEATING": "",
"options.userReportCategory.OTHER": "",
"labels.scrimCancelReason": "",
"bottomTexts.scrimCancelReasonHelp": "",
"bottomTexts.bioMarkdown": "",

View File

@ -211,6 +211,8 @@
"card.friendRequestSent": "",
"card.friendRequestAccepted": "",
"card.editPrivateNote": "",
"card.report.header": "",
"card.report.falseReportsWarning": "",
"card.privateNote": "",
"card.xp": "",
"card.freeAgent": "",

View File

@ -223,6 +223,12 @@
"bottomTexts.banUserNoteHelp": "",
"labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "",
"labels.reportCategory": "",
"options.userReportCategory.INAPPROPRIATE_CONTENT": "",
"options.userReportCategory.ALTING": "",
"options.userReportCategory.HARASSMENT": "",
"options.userReportCategory.CHEATING": "",
"options.userReportCategory.OTHER": "",
"labels.scrimCancelReason": "",
"bottomTexts.scrimCancelReasonHelp": "",
"bottomTexts.bioMarkdown": "",

View File

@ -211,6 +211,8 @@
"card.friendRequestSent": "",
"card.friendRequestAccepted": "",
"card.editPrivateNote": "",
"card.report.header": "",
"card.report.falseReportsWarning": "",
"card.privateNote": "",
"card.xp": "",
"card.freeAgent": "",

View File

@ -223,6 +223,12 @@
"bottomTexts.banUserNoteHelp": "",
"labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "",
"labels.reportCategory": "",
"options.userReportCategory.INAPPROPRIATE_CONTENT": "",
"options.userReportCategory.ALTING": "",
"options.userReportCategory.HARASSMENT": "",
"options.userReportCategory.CHEATING": "",
"options.userReportCategory.OTHER": "",
"labels.scrimCancelReason": "",
"bottomTexts.scrimCancelReasonHelp": "",
"bottomTexts.bioMarkdown": "",

View File

@ -211,6 +211,8 @@
"card.friendRequestSent": "",
"card.friendRequestAccepted": "",
"card.editPrivateNote": "",
"card.report.header": "",
"card.report.falseReportsWarning": "",
"card.privateNote": "",
"card.xp": "",
"card.freeAgent": "",

View File

@ -223,6 +223,12 @@
"bottomTexts.banUserNoteHelp": "",
"labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "",
"labels.reportCategory": "",
"options.userReportCategory.INAPPROPRIATE_CONTENT": "",
"options.userReportCategory.ALTING": "",
"options.userReportCategory.HARASSMENT": "",
"options.userReportCategory.CHEATING": "",
"options.userReportCategory.OTHER": "",
"labels.scrimCancelReason": "",
"bottomTexts.scrimCancelReasonHelp": "",
"bottomTexts.bioMarkdown": "",

View File

@ -211,6 +211,8 @@
"card.friendRequestSent": "",
"card.friendRequestAccepted": "",
"card.editPrivateNote": "",
"card.report.header": "",
"card.report.falseReportsWarning": "",
"card.privateNote": "",
"card.xp": "",
"card.freeAgent": "",

View File

@ -223,6 +223,12 @@
"bottomTexts.banUserNoteHelp": "",
"labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "",
"labels.reportCategory": "",
"options.userReportCategory.INAPPROPRIATE_CONTENT": "",
"options.userReportCategory.ALTING": "",
"options.userReportCategory.HARASSMENT": "",
"options.userReportCategory.CHEATING": "",
"options.userReportCategory.OTHER": "",
"labels.scrimCancelReason": "",
"bottomTexts.scrimCancelReasonHelp": "",
"bottomTexts.bioMarkdown": "",

View File

@ -211,6 +211,8 @@
"card.friendRequestSent": "",
"card.friendRequestAccepted": "",
"card.editPrivateNote": "",
"card.report.header": "",
"card.report.falseReportsWarning": "",
"card.privateNote": "",
"card.xp": "",
"card.freeAgent": "",

View File

@ -223,6 +223,12 @@
"bottomTexts.banUserNoteHelp": "",
"labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "",
"labels.reportCategory": "",
"options.userReportCategory.INAPPROPRIATE_CONTENT": "",
"options.userReportCategory.ALTING": "",
"options.userReportCategory.HARASSMENT": "",
"options.userReportCategory.CHEATING": "",
"options.userReportCategory.OTHER": "",
"labels.scrimCancelReason": "",
"bottomTexts.scrimCancelReasonHelp": "",
"bottomTexts.bioMarkdown": "",

View File

@ -208,6 +208,8 @@
"card.friendRequestSent": "",
"card.friendRequestAccepted": "",
"card.editPrivateNote": "",
"card.report.header": "",
"card.report.falseReportsWarning": "",
"card.privateNote": "",
"card.xp": "",
"card.freeAgent": "",

View File

@ -223,6 +223,12 @@
"bottomTexts.banUserNoteHelp": "",
"labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "",
"labels.reportCategory": "",
"options.userReportCategory.INAPPROPRIATE_CONTENT": "",
"options.userReportCategory.ALTING": "",
"options.userReportCategory.HARASSMENT": "",
"options.userReportCategory.CHEATING": "",
"options.userReportCategory.OTHER": "",
"labels.scrimCancelReason": "",
"bottomTexts.scrimCancelReasonHelp": "",
"bottomTexts.bioMarkdown": "",

View File

@ -208,6 +208,8 @@
"card.friendRequestSent": "",
"card.friendRequestAccepted": "",
"card.editPrivateNote": "",
"card.report.header": "",
"card.report.falseReportsWarning": "",
"card.privateNote": "",
"card.xp": "",
"card.freeAgent": "",

View File

@ -223,6 +223,12 @@
"bottomTexts.banUserNoteHelp": "",
"labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "",
"labels.reportCategory": "",
"options.userReportCategory.INAPPROPRIATE_CONTENT": "",
"options.userReportCategory.ALTING": "",
"options.userReportCategory.HARASSMENT": "",
"options.userReportCategory.CHEATING": "",
"options.userReportCategory.OTHER": "",
"labels.scrimCancelReason": "",
"bottomTexts.scrimCancelReasonHelp": "",
"bottomTexts.bioMarkdown": "",

View File

@ -210,6 +210,8 @@
"card.friendRequestSent": "",
"card.friendRequestAccepted": "",
"card.editPrivateNote": "",
"card.report.header": "",
"card.report.falseReportsWarning": "",
"card.privateNote": "",
"card.xp": "",
"card.freeAgent": "",

View File

@ -223,6 +223,12 @@
"bottomTexts.banUserNoteHelp": "",
"labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "",
"labels.reportCategory": "",
"options.userReportCategory.INAPPROPRIATE_CONTENT": "",
"options.userReportCategory.ALTING": "",
"options.userReportCategory.HARASSMENT": "",
"options.userReportCategory.CHEATING": "",
"options.userReportCategory.OTHER": "",
"labels.scrimCancelReason": "",
"bottomTexts.scrimCancelReasonHelp": "",
"bottomTexts.bioMarkdown": "",

View File

@ -212,6 +212,8 @@
"card.friendRequestSent": "",
"card.friendRequestAccepted": "",
"card.editPrivateNote": "",
"card.report.header": "",
"card.report.falseReportsWarning": "",
"card.privateNote": "",
"card.xp": "",
"card.freeAgent": "",

View File

@ -223,6 +223,12 @@
"bottomTexts.banUserNoteHelp": "",
"labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "",
"labels.reportCategory": "",
"options.userReportCategory.INAPPROPRIATE_CONTENT": "",
"options.userReportCategory.ALTING": "",
"options.userReportCategory.HARASSMENT": "",
"options.userReportCategory.CHEATING": "",
"options.userReportCategory.OTHER": "",
"labels.scrimCancelReason": "",
"bottomTexts.scrimCancelReasonHelp": "",
"bottomTexts.bioMarkdown": "",

View File

@ -211,6 +211,8 @@
"card.friendRequestSent": "",
"card.friendRequestAccepted": "",
"card.editPrivateNote": "",
"card.report.header": "",
"card.report.falseReportsWarning": "",
"card.privateNote": "",
"card.xp": "",
"card.freeAgent": "",

View File

@ -223,6 +223,12 @@
"bottomTexts.banUserNoteHelp": "",
"labels.banUserExpiresAt": "",
"bottomTexts.banUserExpiresAtHelp": "",
"labels.reportCategory": "",
"options.userReportCategory.INAPPROPRIATE_CONTENT": "",
"options.userReportCategory.ALTING": "",
"options.userReportCategory.HARASSMENT": "",
"options.userReportCategory.CHEATING": "",
"options.userReportCategory.OTHER": "",
"labels.scrimCancelReason": "",
"bottomTexts.scrimCancelReasonHelp": "",
"bottomTexts.bioMarkdown": "",

View File

@ -212,6 +212,8 @@
"card.friendRequestSent": "",
"card.friendRequestAccepted": "",
"card.editPrivateNote": "",
"card.report.header": "",
"card.report.falseReportsWarning": "",
"card.privateNote": "",
"card.xp": "",
"card.freeAgent": "",

View File

@ -223,6 +223,12 @@
"bottomTexts.banUserNoteHelp": "此备注仅对组织管理员可见。",
"labels.banUserExpiresAt": "封禁结束日期",
"bottomTexts.banUserExpiresAtHelp": "留空则表示永久封禁",
"labels.reportCategory": "",
"options.userReportCategory.INAPPROPRIATE_CONTENT": "",
"options.userReportCategory.ALTING": "",
"options.userReportCategory.HARASSMENT": "",
"options.userReportCategory.CHEATING": "",
"options.userReportCategory.OTHER": "",
"labels.scrimCancelReason": "取消原因",
"bottomTexts.scrimCancelReasonHelp": "请说明取消这场对抗战的原因。该内容将对另一支队伍公开。",
"bottomTexts.bioMarkdown": "支持 Markdown 语法",

View File

@ -209,6 +209,8 @@
"card.friendRequestSent": "",
"card.friendRequestAccepted": "",
"card.editPrivateNote": "",
"card.report.header": "",
"card.report.falseReportsWarning": "",
"card.privateNote": "",
"card.xp": "",
"card.freeAgent": "",

View File

@ -0,0 +1,25 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/* sql */ `
create table "UserReport" (
"id" integer primary key autoincrement,
"reportedUserId" integer not null,
"reporterUserId" integer not null,
"category" text not null,
"description" text not null,
"createdAt" integer default (strftime('%s', 'now')) not null,
foreign key ("reportedUserId") references "User"("id") on delete cascade,
foreign key ("reporterUserId") references "User"("id") on delete cascade,
unique ("reportedUserId", "reporterUserId")
) strict
`,
).run();
db.prepare(
/* sql */ `create index user_report_reported_user_id_idx on "UserReport"("reportedUserId")`,
).run();
db.pragma("foreign_key_check");
})();
}