diff --git a/.env.example b/.env.example index 4d2d24602..f7793ed23 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index f47149aa5..05c72c2b7 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -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); diff --git a/app/db/tables.ts b/app/db/tables.ts index 593a5a405..5d1cd63bf 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -1287,6 +1287,24 @@ export interface ModNote { isDeleted: Generated; } +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; + reportedUserId: number; + reporterUserId: number; + category: UserReportCategory; + description: string; + createdAt: Generated; +} + export interface Video { eventId: number | null; id: GeneratedAlways; @@ -1563,6 +1581,7 @@ export interface DB { TenStarWeapon: TenStarWeapon; UserFriendCode: UserFriendCode; UserWidget: UserWidget; + UserReport: UserReport; Video: Video; VideoMatch: VideoMatch; VideoMatchPlayer: VideoMatchPlayer; diff --git a/app/features/admin/AdminRepository.server.ts b/app/features/admin/AdminRepository.server.ts index 9e3f93f32..b5e1616f3 100644 --- a/app/features/admin/AdminRepository.server.ts +++ b/app/features/admin/AdminRepository.server.ts @@ -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, + 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, args: { newUserId: number; oldUserId: number }, diff --git a/app/features/user-card/components/UserCard.tsx b/app/features/user-card/components/UserCard.tsx index 060d3fd23..9968af5c3 100644 --- a/app/features/user-card/components/UserCard.tsx +++ b/app/features/user-card/components/UserCard.tsx @@ -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(); 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} /> @@ -150,6 +159,13 @@ export function UserCard({ onClose={() => setIsNoteDialogOpen(false)} /> ) : null} + {isReportDialogOpen ? ( + setIsReportDialogOpen(false)} + /> + ) : null} 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 ? ( + } + onPress={onReport} + aria-label="Report user" + data-testid="report-user-button" + /> + ) : null} )} diff --git a/app/features/user-page/loaders/u.$identifier.admin.server.ts b/app/features/user-page/loaders/u.$identifier.admin.server.ts index 95e4b3843..b08c1819a 100644 --- a/app/features/user-page/loaders/u.$identifier.admin.server.ts +++ b/app/features/user-page/loaders/u.$identifier.admin.server.ts @@ -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 + >, +) { + 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, + }; + }); +} diff --git a/app/features/user-page/loaders/u.$identifier.index.server.ts b/app/features/user-page/loaders/u.$identifier.index.server.ts index a9c7a2e62..50cb71500 100644 --- a/app/features/user-page/loaders/u.$identifier.index.server.ts +++ b/app/features/user-page/loaders/u.$identifier.index.server.ts @@ -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, }; }; diff --git a/app/features/user-page/routes/u.$identifier.admin.tsx b/app/features/user-page/routes/u.$identifier.admin.tsx index 92d7127d6..613076606 100644 --- a/app/features/user-page/routes/u.$identifier.admin.tsx +++ b/app/features/user-page/routes/u.$identifier.admin.tsx @@ -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() { +
+ + Reports + + +
+
Ban log @@ -189,6 +199,44 @@ function NewModNoteDialog() { ); } +function Reports() { + const data = useLoaderData(); + const formatDistanceToNow = useFormatDistanceToNow(); + + if (data.reports.length === 0) { + return

No reports

; + } + + return ( +
+

{data.reports.length} total

+ +
+ {data.reports.map((report) => ( +
+ + + {USER_REPORT_CATEGORY_LABELS[report.category]} + {" "} + - By:{" "} + + {report.reporterUsername} + {" "} + - {formatDistanceToNow(report.createdAt)} + +

{report.description}

+
+ ))} +
+
+ ); +} + function BanLog() { const data = useLoaderData(); diff --git a/app/features/user-page/routes/u.$identifier.index.tsx b/app/features/user-page/routes/u.$identifier.index.tsx index 070d73f7a..e5150f9c3 100644 --- a/app/features/user-page/routes/u.$identifier.index.tsx +++ b/app/features/user-page/routes/u.$identifier.index.tsx @@ -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() {
- + + +
-

{layoutData.user.username}

+

+ + {layoutData.user.username} + +

- + + +

-
{layoutData.user.username}
+ +
{layoutData.user.username}
+
{data.user.country ? ( diff --git a/app/features/user-report/UserReportRepository.server.ts b/app/features/user-report/UserReportRepository.server.ts new file mode 100644 index 000000000..2ddf8788a --- /dev/null +++ b/app/features/user-report/UserReportRepository.server.ts @@ -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, +) { + 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() + .filterWhere("createdAt", ">=", monthAgo) + .as("lastMonth"), + eb.fn.countAll().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(); +} diff --git a/app/features/user-report/components/ReportUserDialog.tsx b/app/features/user-report/components/ReportUserDialog.tsx new file mode 100644 index 000000000..05f2b2195 --- /dev/null +++ b/app/features/user-report/components/ReportUserDialog.tsx @@ -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 ( + + { + toastQueue.add( + { message: "Report sent to the staff", variant: "success" }, + { timeout: 5000 }, + ); + onClose(); + }} + > + {({ FormField }) => ( + <> + + + + {t("user:card.report.falseReportsWarning")} + + + )} + + + ); +} diff --git a/app/features/user-report/components/ReportsBarChart.module.css b/app/features/user-report/components/ReportsBarChart.module.css new file mode 100644 index 000000000..f80cda1f4 --- /dev/null +++ b/app/features/user-report/components/ReportsBarChart.module.css @@ -0,0 +1,6 @@ +.container { + height: 200px; + background-color: var(--color-bg-high); + border-radius: var(--radius-box); + padding: var(--s-2); +} diff --git a/app/features/user-report/components/ReportsBarChart.tsx b/app/features/user-report/components/ReportsBarChart.tsx new file mode 100644 index 000000000..193eaf953 --- /dev/null +++ b/app/features/user-report/components/ReportsBarChart.tsx @@ -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
; + } + + const scaleDefaults = { + grid: { color: colors.border }, + border: { color: colors.borderHigh }, + ticks: { color: colors.text }, + }; + + return ( +
+ + 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 }, + }, + }} + /> +
+ ); +} diff --git a/app/features/user-report/core/discord-webhook.server.ts b/app/features/user-report/core/discord-webhook.server.ts new file mode 100644 index 000000000..5b29db23c --- /dev/null +++ b/app/features/user-report/core/discord-webhook.server.ts @@ -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)}…`; +} diff --git a/app/features/user-report/routes/user-report.$id.ts b/app/features/user-report/routes/user-report.$id.ts new file mode 100644 index 000000000..b9dc603da --- /dev/null +++ b/app/features/user-report/routes/user-report.$id.ts @@ -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; +}; diff --git a/app/features/user-report/user-report-constants.ts b/app/features/user-report/user-report-constants.ts new file mode 100644 index 000000000..854a51bc6 --- /dev/null +++ b/app/features/user-report/user-report-constants.ts @@ -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 = { + INAPPROPRIATE_CONTENT: "Inappropriate content", + ALTING: "Alting", + HARASSMENT: "Harassment", + CHEATING: "Cheating", + OTHER: "Other", +}; diff --git a/app/features/user-report/user-report-schemas.ts b/app/features/user-report/user-report-schemas.ts new file mode 100644 index 000000000..f37ae1b5e --- /dev/null +++ b/app/features/user-report/user-report-schemas.ts @@ -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, +}); diff --git a/app/routes.ts b/app/routes.ts index 2a9604289..85fe3c2a7 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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"), diff --git a/app/utils/urls.ts b/app/utils/urls.ts index fff7581e2..e9c1ea45f 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -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"]; diff --git a/db-test.sqlite3 b/db-test.sqlite3 index 8d79ba741..544d6e6aa 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/seeds/db-seed-AB_RR.sqlite3 b/e2e/seeds/db-seed-AB_RR.sqlite3 index ff9524dc3..4dd164299 100644 Binary files a/e2e/seeds/db-seed-AB_RR.sqlite3 and b/e2e/seeds/db-seed-AB_RR.sqlite3 differ diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index b826d5e66..3ae87f832 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 index 721c7e249..0aff3caf9 100644 Binary files a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 and b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 differ diff --git a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 index fee961519..90b0a9a9e 100644 Binary files a/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 and b/e2e/seeds/db-seed-IN_SQ_MATCH.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index 4cbffff79..77835c937 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index b4c2422c7..ebd927897 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index 4ac37863b..354681dbd 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index 0b0fb3de7..25d77c250 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index b97f79a54..7c9388c93 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index e74093e13..f0503db62 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ diff --git a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 index 01f8c80b4..664a0c498 100644 Binary files a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 and b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 differ diff --git a/e2e/user-report.spec.ts b/e2e/user-report.spec.ts new file mode 100644 index 000000000..152f9438f --- /dev/null +++ b/e2e/user-report.spec.ts @@ -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(); + }); +}); diff --git a/locales/da/forms.json b/locales/da/forms.json index 31def925a..c709b1c60 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -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": "", diff --git a/locales/da/user.json b/locales/da/user.json index f280f3023..798051909 100644 --- a/locales/da/user.json +++ b/locales/da/user.json @@ -210,6 +210,8 @@ "card.friendRequestSent": "", "card.friendRequestAccepted": "", "card.editPrivateNote": "", + "card.report.header": "", + "card.report.falseReportsWarning": "", "card.privateNote": "", "card.xp": "", "card.freeAgent": "", diff --git a/locales/de/forms.json b/locales/de/forms.json index 1a1fd535f..892e0f712 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -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": "", diff --git a/locales/de/user.json b/locales/de/user.json index 4711041e7..660c5ff0b 100644 --- a/locales/de/user.json +++ b/locales/de/user.json @@ -210,6 +210,8 @@ "card.friendRequestSent": "", "card.friendRequestAccepted": "", "card.editPrivateNote": "", + "card.report.header": "", + "card.report.falseReportsWarning": "", "card.privateNote": "", "card.xp": "", "card.freeAgent": "", diff --git a/locales/en/forms.json b/locales/en/forms.json index cf364cb46..09ce5c747 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -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", diff --git a/locales/en/user.json b/locales/en/user.json index d1a9813a5..75091511a 100644 --- a/locales/en/user.json +++ b/locales/en/user.json @@ -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", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index b04545e7d..ef5e151e0 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -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", diff --git a/locales/es-ES/user.json b/locales/es-ES/user.json index 6e51f8407..998d4edc5 100644 --- a/locales/es-ES/user.json +++ b/locales/es-ES/user.json @@ -211,6 +211,8 @@ "card.friendRequestSent": "", "card.friendRequestAccepted": "", "card.editPrivateNote": "", + "card.report.header": "", + "card.report.falseReportsWarning": "", "card.privateNote": "", "card.xp": "", "card.freeAgent": "", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index 9302b26b5..ec89ed166 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -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": "", diff --git a/locales/es-US/user.json b/locales/es-US/user.json index 686a1789e..3ea40de6e 100644 --- a/locales/es-US/user.json +++ b/locales/es-US/user.json @@ -211,6 +211,8 @@ "card.friendRequestSent": "", "card.friendRequestAccepted": "", "card.editPrivateNote": "", + "card.report.header": "", + "card.report.falseReportsWarning": "", "card.privateNote": "", "card.xp": "", "card.freeAgent": "", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index 92fb252eb..1276e72d4 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -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": "", diff --git a/locales/fr-CA/user.json b/locales/fr-CA/user.json index 0fc61321d..1f113483c 100644 --- a/locales/fr-CA/user.json +++ b/locales/fr-CA/user.json @@ -211,6 +211,8 @@ "card.friendRequestSent": "", "card.friendRequestAccepted": "", "card.editPrivateNote": "", + "card.report.header": "", + "card.report.falseReportsWarning": "", "card.privateNote": "", "card.xp": "", "card.freeAgent": "", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index 2237f0a5f..bc2df2a18 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -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": "", diff --git a/locales/fr-EU/user.json b/locales/fr-EU/user.json index 8502b3624..f0042f695 100644 --- a/locales/fr-EU/user.json +++ b/locales/fr-EU/user.json @@ -211,6 +211,8 @@ "card.friendRequestSent": "", "card.friendRequestAccepted": "", "card.editPrivateNote": "", + "card.report.header": "", + "card.report.falseReportsWarning": "", "card.privateNote": "", "card.xp": "", "card.freeAgent": "", diff --git a/locales/he/forms.json b/locales/he/forms.json index 7c019a4ee..96a6d662f 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -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": "", diff --git a/locales/he/user.json b/locales/he/user.json index c9e8c5f58..d163a5528 100644 --- a/locales/he/user.json +++ b/locales/he/user.json @@ -211,6 +211,8 @@ "card.friendRequestSent": "", "card.friendRequestAccepted": "", "card.editPrivateNote": "", + "card.report.header": "", + "card.report.falseReportsWarning": "", "card.privateNote": "", "card.xp": "", "card.freeAgent": "", diff --git a/locales/it/forms.json b/locales/it/forms.json index 679d6e286..3ba3589ef 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -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": "", diff --git a/locales/it/user.json b/locales/it/user.json index 950f4b22d..b2dbd4773 100644 --- a/locales/it/user.json +++ b/locales/it/user.json @@ -211,6 +211,8 @@ "card.friendRequestSent": "", "card.friendRequestAccepted": "", "card.editPrivateNote": "", + "card.report.header": "", + "card.report.falseReportsWarning": "", "card.privateNote": "", "card.xp": "", "card.freeAgent": "", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index d21ee2709..aaaaa6032 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -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": "", diff --git a/locales/ja/user.json b/locales/ja/user.json index e1704a817..a78229158 100644 --- a/locales/ja/user.json +++ b/locales/ja/user.json @@ -208,6 +208,8 @@ "card.friendRequestSent": "", "card.friendRequestAccepted": "", "card.editPrivateNote": "", + "card.report.header": "", + "card.report.falseReportsWarning": "", "card.privateNote": "", "card.xp": "", "card.freeAgent": "", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index 34bb82e82..7ac716307 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -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": "", diff --git a/locales/ko/user.json b/locales/ko/user.json index 61a421717..7bad571bf 100644 --- a/locales/ko/user.json +++ b/locales/ko/user.json @@ -208,6 +208,8 @@ "card.friendRequestSent": "", "card.friendRequestAccepted": "", "card.editPrivateNote": "", + "card.report.header": "", + "card.report.falseReportsWarning": "", "card.privateNote": "", "card.xp": "", "card.freeAgent": "", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index 7d32d3596..136f6e47b 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -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": "", diff --git a/locales/nl/user.json b/locales/nl/user.json index 889a50154..0588f51d1 100644 --- a/locales/nl/user.json +++ b/locales/nl/user.json @@ -210,6 +210,8 @@ "card.friendRequestSent": "", "card.friendRequestAccepted": "", "card.editPrivateNote": "", + "card.report.header": "", + "card.report.falseReportsWarning": "", "card.privateNote": "", "card.xp": "", "card.freeAgent": "", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index 0f3531f6c..714073238 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -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": "", diff --git a/locales/pl/user.json b/locales/pl/user.json index 9048efa49..dbb381e3c 100644 --- a/locales/pl/user.json +++ b/locales/pl/user.json @@ -212,6 +212,8 @@ "card.friendRequestSent": "", "card.friendRequestAccepted": "", "card.editPrivateNote": "", + "card.report.header": "", + "card.report.falseReportsWarning": "", "card.privateNote": "", "card.xp": "", "card.freeAgent": "", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index 1dbf18349..11fc45b1e 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -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": "", diff --git a/locales/pt-BR/user.json b/locales/pt-BR/user.json index 0ac4b6f77..579d9c5cd 100644 --- a/locales/pt-BR/user.json +++ b/locales/pt-BR/user.json @@ -211,6 +211,8 @@ "card.friendRequestSent": "", "card.friendRequestAccepted": "", "card.editPrivateNote": "", + "card.report.header": "", + "card.report.falseReportsWarning": "", "card.privateNote": "", "card.xp": "", "card.freeAgent": "", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index 2b4094d99..c16da907b 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -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": "", diff --git a/locales/ru/user.json b/locales/ru/user.json index af14c6255..42ff42d70 100644 --- a/locales/ru/user.json +++ b/locales/ru/user.json @@ -212,6 +212,8 @@ "card.friendRequestSent": "", "card.friendRequestAccepted": "", "card.editPrivateNote": "", + "card.report.header": "", + "card.report.falseReportsWarning": "", "card.privateNote": "", "card.xp": "", "card.freeAgent": "", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index 5a9f21fea..7186bbf6f 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -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 语法", diff --git a/locales/zh/user.json b/locales/zh/user.json index a087c56d4..10f3861bd 100644 --- a/locales/zh/user.json +++ b/locales/zh/user.json @@ -209,6 +209,8 @@ "card.friendRequestSent": "", "card.friendRequestAccepted": "", "card.editPrivateNote": "", + "card.report.header": "", + "card.report.falseReportsWarning": "", "card.privateNote": "", "card.xp": "", "card.freeAgent": "", diff --git a/migrations/157-user-report.js b/migrations/157-user-report.js new file mode 100644 index 000000000..4975c7040 --- /dev/null +++ b/migrations/157-user-report.js @@ -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"); + })(); +}