mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-07-23 03:03:05 -05:00
User reports (#3232)
This commit is contained in:
parent
7d1e680a74
commit
ef0d32ef18
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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
44
e2e/user-report.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -210,6 +210,8 @@
|
|||
"card.friendRequestSent": "",
|
||||
"card.friendRequestAccepted": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.report.header": "",
|
||||
"card.report.falseReportsWarning": "",
|
||||
"card.privateNote": "",
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -210,6 +210,8 @@
|
|||
"card.friendRequestSent": "",
|
||||
"card.friendRequestAccepted": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.report.header": "",
|
||||
"card.report.falseReportsWarning": "",
|
||||
"card.privateNote": "",
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -211,6 +211,8 @@
|
|||
"card.friendRequestSent": "",
|
||||
"card.friendRequestAccepted": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.report.header": "",
|
||||
"card.report.falseReportsWarning": "",
|
||||
"card.privateNote": "",
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -211,6 +211,8 @@
|
|||
"card.friendRequestSent": "",
|
||||
"card.friendRequestAccepted": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.report.header": "",
|
||||
"card.report.falseReportsWarning": "",
|
||||
"card.privateNote": "",
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -211,6 +211,8 @@
|
|||
"card.friendRequestSent": "",
|
||||
"card.friendRequestAccepted": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.report.header": "",
|
||||
"card.report.falseReportsWarning": "",
|
||||
"card.privateNote": "",
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -211,6 +211,8 @@
|
|||
"card.friendRequestSent": "",
|
||||
"card.friendRequestAccepted": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.report.header": "",
|
||||
"card.report.falseReportsWarning": "",
|
||||
"card.privateNote": "",
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -211,6 +211,8 @@
|
|||
"card.friendRequestSent": "",
|
||||
"card.friendRequestAccepted": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.report.header": "",
|
||||
"card.report.falseReportsWarning": "",
|
||||
"card.privateNote": "",
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -211,6 +211,8 @@
|
|||
"card.friendRequestSent": "",
|
||||
"card.friendRequestAccepted": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.report.header": "",
|
||||
"card.report.falseReportsWarning": "",
|
||||
"card.privateNote": "",
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -208,6 +208,8 @@
|
|||
"card.friendRequestSent": "",
|
||||
"card.friendRequestAccepted": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.report.header": "",
|
||||
"card.report.falseReportsWarning": "",
|
||||
"card.privateNote": "",
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -208,6 +208,8 @@
|
|||
"card.friendRequestSent": "",
|
||||
"card.friendRequestAccepted": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.report.header": "",
|
||||
"card.report.falseReportsWarning": "",
|
||||
"card.privateNote": "",
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -210,6 +210,8 @@
|
|||
"card.friendRequestSent": "",
|
||||
"card.friendRequestAccepted": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.report.header": "",
|
||||
"card.report.falseReportsWarning": "",
|
||||
"card.privateNote": "",
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -212,6 +212,8 @@
|
|||
"card.friendRequestSent": "",
|
||||
"card.friendRequestAccepted": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.report.header": "",
|
||||
"card.report.falseReportsWarning": "",
|
||||
"card.privateNote": "",
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -211,6 +211,8 @@
|
|||
"card.friendRequestSent": "",
|
||||
"card.friendRequestAccepted": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.report.header": "",
|
||||
"card.report.falseReportsWarning": "",
|
||||
"card.privateNote": "",
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -212,6 +212,8 @@
|
|||
"card.friendRequestSent": "",
|
||||
"card.friendRequestAccepted": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.report.header": "",
|
||||
"card.report.falseReportsWarning": "",
|
||||
"card.privateNote": "",
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
|
|
|
|||
|
|
@ -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 语法",
|
||||
|
|
|
|||
|
|
@ -209,6 +209,8 @@
|
|||
"card.friendRequestSent": "",
|
||||
"card.friendRequestAccepted": "",
|
||||
"card.editPrivateNote": "",
|
||||
"card.report.header": "",
|
||||
"card.report.falseReportsWarning": "",
|
||||
"card.privateNote": "",
|
||||
"card.xp": "",
|
||||
"card.freeAgent": "",
|
||||
|
|
|
|||
25
migrations/157-user-report.js
Normal file
25
migrations/157-user-report.js
Normal 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");
|
||||
})();
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user