mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-03 00:27:30 -05:00
User reports (#3232)
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
77
app/features/user-report/UserReportRepository.server.ts
Normal file
77
app/features/user-report/UserReportRepository.server.ts
Normal 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();
|
||||
}
|
||||
53
app/features/user-report/components/ReportUserDialog.tsx
Normal file
53
app/features/user-report/components/ReportUserDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.container {
|
||||
height: 200px;
|
||||
background-color: var(--color-bg-high);
|
||||
border-radius: var(--radius-box);
|
||||
padding: var(--s-2);
|
||||
}
|
||||
104
app/features/user-report/components/ReportsBarChart.tsx
Normal file
104
app/features/user-report/components/ReportsBarChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
app/features/user-report/core/discord-webhook.server.ts
Normal file
84
app/features/user-report/core/discord-webhook.server.ts
Normal 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)}…`;
|
||||
}
|
||||
56
app/features/user-report/routes/user-report.$id.ts
Normal file
56
app/features/user-report/routes/user-report.$id.ts
Normal 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;
|
||||
};
|
||||
14
app/features/user-report/user-report-constants.ts
Normal file
14
app/features/user-report/user-report-constants.ts
Normal 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",
|
||||
};
|
||||
28
app/features/user-report/user-report-schemas.ts
Normal file
28
app/features/user-report/user-report-schemas.ts
Normal 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,
|
||||
});
|
||||
Reference in New Issue
Block a user