User admin tab Closes #2388

This commit is contained in:
Kalle 2025-06-14 08:41:52 +03:00
parent 96fe5f0c78
commit df3772403e
25 changed files with 536 additions and 88 deletions

View File

@ -888,6 +888,24 @@ export interface UserFriendCode {
createdAt: GeneratedAlways<number>;
}
export interface BanLog {
id: GeneratedAlways<number>;
userId: number;
banned: number | null;
bannedReason: string | null;
bannedByUserId: number;
createdAt: GeneratedAlways<number>;
}
export interface ModNote {
id: GeneratedAlways<number>;
userId: number;
authorId: number;
text: string;
createdAt: GeneratedAlways<number>;
isDeleted: Generated<DBBoolean>;
}
export interface Video {
eventId: number | null;
id: GeneratedAlways<number>;
@ -1039,6 +1057,8 @@ export interface DB {
BadgeManager: BadgeManager;
BadgeOwner: BadgeOwner;
TournamentBadgeOwner: TournamentBadgeOwner;
BanLog: BanLog;
ModNote: ModNote;
Build: Build;
BuildAbility: BuildAbility;
BuildWeapon: BuildWeapon;

View File

@ -1,6 +1,6 @@
import type { Transaction } from "kysely";
import { db, sql } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
import type { DB, Tables, TablesInsertable } from "~/db/tables";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { syncXPBadges } from "../badges/queries/syncXPBadges.server";
@ -210,25 +210,85 @@ export function banUser({
userId,
banned,
bannedReason,
bannedByUserId,
}: {
userId: number;
banned: 1 | Date;
bannedReason: string | null;
/** Which user banned the user? If null then it means it was an automatic ban. */
bannedByUserId: number | null;
}) {
return db
.updateTable("User")
.set({
return db.transaction().execute(async (trx) => {
const banArgs = {
banned: banned === 1 ? banned : dateToDatabaseTimestamp(banned),
bannedReason,
})
};
await trx
.updateTable("User")
.set(banArgs)
.where("User.id", "=", userId)
.execute();
if (typeof bannedByUserId === "number") {
await trx
.insertInto("BanLog")
.values({
...banArgs,
userId,
bannedByUserId,
})
.execute();
}
});
}
export function unbanUser(userId: number) {
return db
export function unbanUser({
userId,
unbannedByUserId,
}: {
userId: number;
unbannedByUserId: number;
}) {
return db.transaction().execute(async (trx) => {
const banArgs = {
banned: 0,
bannedReason: null,
};
await trx
.updateTable("User")
.set({ banned: 0 })
.set(banArgs)
.where("User.id", "=", userId)
.execute();
await trx
.insertInto("BanLog")
.values({
...banArgs,
userId,
bannedByUserId: unbannedByUserId,
})
.execute();
});
}
export function addModNote(args: TablesInsertable["ModNote"]) {
return db.insertInto("ModNote").values(args).execute();
}
export function findModeNoteById(id: number) {
return db
.selectFrom("ModNote")
.selectAll()
.where("ModNote.id", "=", id)
.executeTakeFirst();
}
export function deleteModNote(id: number) {
return db
.updateTable("ModNote")
.set({ isDeleted: 1 })
.where("ModNote.id", "=", id)
.execute();
}

View File

@ -6,7 +6,6 @@ import { requireUser } from "~/features/auth/core/user.server";
import { refreshBannedCache } from "~/features/ban/core/banned.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { requireRole } from "~/modules/permissions/guards.server";
import { logger } from "~/utils/logger";
import {
errorToast,
parseRequestPayload,
@ -125,34 +124,24 @@ export const action = async ({ request }: ActionFunctionArgs) => {
bannedReason: data.reason ?? null,
userId: data.user,
banned: data.duration ? new Date(data.duration) : 1,
bannedByUserId: user.id,
});
refreshBannedCache();
logger.info("Banned user", {
userId: data.user,
byUserId: user.id,
reason: data.reason,
duration: data.duration
? new Date(data.duration).toLocaleString()
: undefined,
});
message = "User banned";
break;
}
case "UNBAN_USER": {
requireRole(user, "STAFF");
await AdminRepository.unbanUser(data.user);
await AdminRepository.unbanUser({
userId: data.user,
unbannedByUserId: user.id,
});
refreshBannedCache();
logger.info("Unbanned user", {
userId: data.user,
byUserId: user.id,
});
message = "User unbanned";
break;
}

View File

@ -110,6 +110,7 @@ export const action: ActionFunction = async ({ request }) => {
banned: 1,
bannedReason:
"[automatic ban] This friend code is already in use by some other account. Please contact staff on our Discord helpdesk for resolution including merging accounts.",
bannedByUserId: null,
});
refreshBannedCache();

View File

@ -339,6 +339,48 @@ export async function findLeanById(id: number) {
};
}
export function findModInfoById(id: number) {
return db
.selectFrom("User")
.select((eb) => [
"User.discordUniqueName",
"User.isVideoAdder",
"User.isArtist",
"User.isTournamentOrganizer",
"User.plusSkippedForSeasonNth",
"User.createdAt",
jsonArrayFrom(
eb
.selectFrom("ModNote")
.innerJoin("User", "User.id", "ModNote.authorId")
.select([
"ModNote.id as noteId",
"ModNote.text",
"ModNote.createdAt",
...COMMON_USER_FIELDS,
])
.where("ModNote.isDeleted", "=", 0)
.where("ModNote.userId", "=", id)
.orderBy("ModNote.createdAt", "desc"),
).as("modNotes"),
jsonArrayFrom(
eb
.selectFrom("BanLog")
.innerJoin("User", "User.id", "BanLog.bannedByUserId")
.select([
"BanLog.banned",
"BanLog.bannedReason",
"BanLog.createdAt",
...COMMON_USER_FIELDS,
])
.where("BanLog.userId", "=", id)
.orderBy("BanLog.createdAt", "desc"),
).as("banLogs"),
])
.where("User.id", "=", id)
.executeTakeFirst();
}
export function findAllPatrons() {
return db
.selectFrom("User")

View File

@ -0,0 +1,57 @@
import type { ActionFunctionArgs } from "@remix-run/node";
import * as AdminRepository from "~/features/admin/AdminRepository.server";
import { requireUser } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { adminTabActionSchema } from "~/features/user-page/user-page-schemas";
import { requireRole } from "~/modules/permissions/guards.server";
import {
badRequestIfFalsy,
notFoundIfFalsy,
parseRequestPayload,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
export const action = async ({ request, params }: ActionFunctionArgs) => {
const loggedInUser = await requireUser(request);
requireRole(loggedInUser, "STAFF");
const data = await parseRequestPayload({
request,
schema: adminTabActionSchema,
});
const user = notFoundIfFalsy(
await UserRepository.findLayoutDataByIdentifier(params.identifier!),
);
switch (data._action) {
case "ADD_MOD_NOTE": {
await AdminRepository.addModNote({
authorId: loggedInUser.id,
userId: user.id,
text: data.value,
});
break;
}
case "DELETE_MOD_NOTE": {
const note = badRequestIfFalsy(
await AdminRepository.findModeNoteById(data.noteId),
);
if (note.authorId !== loggedInUser.id) {
throw new Response(null, {
status: 401,
});
}
await AdminRepository.deleteModNote(data.noteId);
break;
}
default: {
assertUnreachable(data);
}
}
return null;
};

View File

@ -6,7 +6,7 @@ import * as UserRepository from "~/features/user-page/UserRepository.server";
import { safeParseRequestFormData } from "~/utils/remix.server";
import { errorIsSqliteUniqueConstraintFailure } from "~/utils/sql";
import { userPage } from "~/utils/urls";
import { userEditActionSchema } from "../user-page-schemas.server";
import { userEditActionSchema } from "../user-page-schemas";
export const action: ActionFunction = async ({ request }) => {
const parsedInput = await safeParseRequestFormData({

View File

@ -8,7 +8,7 @@ import {
import { normalizeFormFieldArray } from "~/utils/arrays";
import { parseRequestPayload } from "~/utils/remix.server";
import { userResultsPage } from "~/utils/urls";
import { editHighlightsActionSchema } from "../user-page-schemas.server";
import { editHighlightsActionSchema } from "../user-page-schemas";
export const action: ActionFunction = async ({ request }) => {
const user = await requireUser(request);

View File

@ -0,0 +1,30 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { requireRole } from "~/modules/permissions/guards.server";
import { logger } from "~/utils/logger";
import { notFoundIfFalsy } from "~/utils/remix.server";
import { convertSnowflakeToDate } from "~/utils/users";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const loggedInUser = await requireUser(request);
requireRole(loggedInUser, "STAFF");
const user = notFoundIfFalsy(
await UserRepository.findLayoutDataByIdentifier(params.identifier!),
);
logger.info(
`User ${loggedInUser.username} (#${loggedInUser.id}) is viewing admin tab for user ${user.username} (#${user.id})`,
);
const userData = notFoundIfFalsy(
await UserRepository.findModInfoById(user.id),
);
return {
...userData,
discordAccountCreatedAt: convertSnowflakeToDate(user.discordId).getTime(),
};
};

View File

@ -4,7 +4,7 @@ import { getUserId } from "~/features/auth/core/user.server";
import { countUnvalidatedArt } from "~/features/img-upload";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { notFoundIfFalsy } from "~/utils/remix.server";
import { userParamsSchema } from "../user-page-schemas.server";
import { userParamsSchema } from "../user-page-schemas";
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const loggedInUser = await getUserId(request);

View File

@ -7,7 +7,7 @@ import type { MainWeaponId } from "~/modules/in-game-lists/types";
import type { SerializeFrom } from "~/utils/remix";
import { notFoundIfFalsy, privatelyCachedJson } from "~/utils/remix.server";
import { sortBuilds } from "../core/build-sorting.server";
import { userParamsSchema } from "../user-page-schemas.server";
import { userParamsSchema } from "../user-page-schemas";
export type UserBuildsPageData = SerializeFrom<typeof loader>;

View File

@ -3,7 +3,7 @@ import { requireUserId } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { notFoundIfFalsy } from "~/utils/remix.server";
import { userPage } from "~/utils/urls";
import { userParamsSchema } from "../user-page-schemas.server";
import { userParamsSchema } from "../user-page-schemas";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const user = await requireUserId(request);

View File

@ -1,21 +1,13 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { getUser } from "~/features/auth/core/user.server";
import { userIsBanned } from "~/features/ban/core/banned.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { notFoundIfFalsy } from "~/utils/remix.server";
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const loggedInUser = await getUser(request);
export const loader = async ({ params }: LoaderFunctionArgs) => {
const user = notFoundIfFalsy(
await UserRepository.findProfileByIdentifier(params.identifier!),
);
return {
user,
banned:
loggedInUser?.roles.includes("ADMIN") && userIsBanned(user.id)
? await UserRepository.findBannedStatusByUserId(user.id)!
: undefined,
};
};

View File

@ -16,7 +16,7 @@ import { notFoundIfFalsy } from "~/utils/remix.server";
import {
seasonsSearchParamsSchema,
userParamsSchema,
} from "../user-page-schemas.server";
} from "../user-page-schemas";
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const { identifier } = userParamsSchema.parse(params);

View File

@ -0,0 +1,11 @@
.dl dt {
font-weight: bold;
}
.dl dt:not(:first-child) {
margin-block-start: var(--s-4);
}
.dl dd {
display: inline-block;
}

View File

@ -0,0 +1,229 @@
import { useLoaderData } from "@remix-run/react";
import type { z } from "zod/v4";
import { Divider } from "~/components/Divider";
import { Main } from "~/components/Main";
import { SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import { SendouForm } from "~/components/form/SendouForm";
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
import { PlusIcon } from "~/components/icons/Plus";
import { USER } from "~/features/user-page/user-page-constants";
import { addModNoteSchema } from "~/features/user-page/user-page-schemas";
import { databaseTimestampToDate } from "~/utils/dates";
import styles from "./u.$identifier.admin.module.css";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { useUser } from "~/features/auth/core/user";
import { action } from "../actions/u.$identifier.admin.server";
import { loader } from "../loaders/u.$identifier.admin.server";
export { loader, action };
export default function UserAdminPage() {
return (
<Main className="stack xl">
<AccountInfos />
<div className="stack sm">
<Divider smallText className="font-bold">
Mod notes
</Divider>
<ModNotes />
</div>
<div className="stack sm">
<Divider smallText className="font-bold">
Ban log
</Divider>
<BanLog />
</div>
</Main>
);
}
function AccountInfos() {
const data = useLoaderData<typeof loader>();
return (
<dl className={styles.dl}>
<dt>User account created at</dt>
<dd>
{data.createdAt
? databaseTimestampToDate(data.createdAt).toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
: "―"}
</dd>
<dt>Discord account created at</dt>
<dd>
{new Date(data.discordAccountCreatedAt).toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</dd>
<dt>Discord name</dt>
<dd>{data.discordUniqueName}</dd>
<dt>Artist role</dt>
<dd>{data.isArtist ? "Yes" : "No"}</dd>
<dt>Video adder role</dt>
<dd>{data.isVideoAdder ? "Yes" : "No"}</dd>
<dt>Tournament adder role</dt>
<dd>{data.isTournamentOrganizer ? "Yes" : "No"}</dd>
<dt>SQ leaderboard Plus Server admission skipped</dt>
<dd>
{data.plusSkippedForSeasonNth
? `For season ${data.plusSkippedForSeasonNth}`
: "No"}
</dd>
</dl>
);
}
function ModNotes() {
const user = useUser();
const data = useLoaderData<typeof loader>();
if (!data.modNotes || data.modNotes.length === 0) {
return (
<div>
<p className="text-center text-lighter italic">No mod notes</p>
<NewModNoteDialog />
</div>
);
}
return (
<div className="stack lg">
{data.modNotes.map((note) => (
<div key={note.noteId}>
<p className="font-bold">
{databaseTimestampToDate(note.createdAt).toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
<p className="ml-2">By: {note.username}</p>
<p className="ml-2 whitespace-pre-wrap">Note: {note.text}</p>
{note.discordId === user?.discordId ? (
<FormWithConfirm
dialogHeading="Delete mod note?"
fields={[
["_action", "DELETE_MOD_NOTE"],
["noteId", note.noteId],
]}
>
<SendouButton
variant="minimal-destructive"
type="submit"
size="small"
className="ml-2"
>
Delete
</SendouButton>
</FormWithConfirm>
) : null}
</div>
))}
<NewModNoteDialog key={data.modNotes.length} />
</div>
);
}
type FormFields = z.infer<typeof addModNoteSchema>;
function NewModNoteDialog() {
return (
<SendouDialog
heading="Adding a new mod note"
showCloseButton
trigger={
<SendouButton icon={<PlusIcon />} className="ml-auto mt-6">
New note
</SendouButton>
}
>
<SendouForm
schema={addModNoteSchema}
defaultValues={{
value: "",
_action: "ADD_MOD_NOTE",
}}
>
<TextAreaFormField<FormFields>
name="value"
label="Text"
maxLength={USER.MOD_NOTE_MAX_LENGTH}
bottomText="This note will be only visible to staff members."
/>
</SendouForm>
</SendouDialog>
);
}
function BanLog() {
const data = useLoaderData<typeof loader>();
if (!data.banLogs || data.banLogs.length === 0) {
return <p className="text-center text-lighter italic">No bans</p>;
}
return (
<div className="stack lg">
{data.banLogs.map((ban) => (
<div key={ban.createdAt}>
<p className="font-bold">
{databaseTimestampToDate(ban.createdAt).toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
{ban.banned === 0 ? (
<p className="text-success ml-2">Unbanned</p>
) : (
<p className="text-warning ml-2">Banned</p>
)}
<p className="ml-2">By: {ban.username}</p>
{typeof ban.banned === "number" && ban.banned !== 0 ? (
<p className="ml-2">
Banned till:{" "}
{ban.banned !== 1
? databaseTimestampToDate(ban.banned).toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
: "No end date set"}
</p>
) : null}
{ban.banned !== 0 ? (
<p className="ml-2">
Reason:{" "}
{ban.bannedReason || (
<span className="italic">No reason set</span>
)}
</p>
) : null}
</div>
))}
</div>
);
}

View File

@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test";
import type { userEditActionSchema } from "../user-page-schemas.server";
import type { userEditActionSchema } from "../user-page-schemas";
import { action as editUserProfileAction } from "./u.$identifier.edit";
const action = wrappedAction<typeof userEditActionSchema>({

View File

@ -13,7 +13,6 @@ import { TwitchIcon } from "~/components/icons/Twitch";
import { YouTubeIcon } from "~/components/icons/YouTube";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { modesShort } from "~/modules/in-game-lists/modes";
import { databaseTimestampToDate } from "~/utils/dates";
import invariant from "~/utils/invariant";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { rawSensToString } from "~/utils/strings";
@ -71,7 +70,6 @@ export default function UserInfoPage() {
) : null}
</div>
</div>
<BannedInfo />
<ExtraInfos />
<WeaponPool />
<TopPlacements />
@ -341,40 +339,3 @@ function TopPlacements() {
</Link>
);
}
function BannedInfo() {
const data = useLoaderData<typeof loader>();
const { banned, bannedReason } = data.banned ?? {};
if (!banned) return null;
const ends = (() => {
if (!banned || banned === 1) return null;
return databaseTimestampToDate(banned);
})();
return (
<div className="mb-4">
<h2 className="text-warning">Account suspended</h2>
{bannedReason ? <div>Reason: {bannedReason}</div> : null}
{ends ? (
<div suppressHydrationWarning>
Ends:{" "}
{ends.toLocaleString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
})}
</div>
) : (
<div>
Ends: <i>no end time set</i>
</div>
)}
</div>
);
}

View File

@ -4,12 +4,13 @@ import { useTranslation } from "react-i18next";
import { Main } from "~/components/Main";
import { SubNav, SubNavLink } from "~/components/SubNav";
import { useUser } from "~/features/auth/core/user";
import { useHasRole } from "~/modules/permissions/hooks";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
USER_SEARCH_PAGE,
navIconUrl,
userArtPage,
userAdminPage,
userBuildsPage,
userEditProfilePage,
userPage,
@ -61,6 +62,7 @@ export const handle: SendouRouteHandle = {
export default function UserPageLayout() {
const data = useLoaderData<typeof loader>();
const user = useUser();
const isStaff = useHasRole("STAFF");
const location = useLocation();
const { t } = useTranslation(["common", "user"]);
@ -102,10 +104,8 @@ export default function UserPageLayout() {
{t("common:pages.vods")} ({data.user.vodsCount})
</SubNavLink>
)}
{(data.user.artCount > 0 || isOwnPage) && (
<SubNavLink to={userArtPage(data.user)} end={false}>
{t("common:pages.art")} ({data.user.artCount})
</SubNavLink>
{isStaff && (
<SubNavLink to={userAdminPage(data.user)}>Admin</SubNavLink>
)}
</SubNav>
<Outlet />

View File

@ -7,6 +7,7 @@ export const USER = {
IN_GAME_NAME_DISCRIMINATOR_MAX_LENGTH: 5,
WEAPON_POOL_MAX_SIZE: 5,
COMMISSION_TEXT_MAX_LENGTH: 1000,
MOD_NOTE_MAX_LENGTH: 2000,
};
export const MATCHES_PER_SEASONS_PAGE = 8;

View File

@ -2,6 +2,7 @@ import { z } from "zod/v4";
import { BADGE } from "~/features/badges/badges-constants";
import { isCustomUrl } from "~/utils/urls";
import {
_action,
actualNumber,
checkboxValueToDbBoolean,
customCssVarObject,
@ -142,3 +143,18 @@ export const editHighlightsActionSchema = z.object({
z.union([z.array(z.string()), z.string()]),
),
});
export const addModNoteSchema = z.object({
_action: _action("ADD_MOD_NOTE"),
value: z.string().trim().min(1).max(USER.MOD_NOTE_MAX_LENGTH),
});
export const deleteModNoteSchema = z.object({
_action: _action("DELETE_MOD_NOTE"),
noteId: id,
});
export const adminTabActionSchema = z.union([
addModNoteSchema,
deleteModNoteSchema,
]);

View File

@ -51,6 +51,7 @@ export default [
"results/highlights",
"features/user-page/routes/u.$identifier.results.highlights.tsx",
),
route("admin", "features/user-page/routes/u.$identifier.admin.tsx"),
]),
route("/badges", "features/badges/routes/badges.tsx", [

View File

@ -174,6 +174,7 @@ export const newVodPage = (vodToEditId?: number) =>
`${VODS_PAGE}/new${vodToEditId ? `?vod=${vodToEditId}` : ""}`;
export const userResultsEditHighlightsPage = (user: UserLinkArgs) =>
`${userResultsPage(user)}/highlights`;
export const userAdminPage = (user: UserLinkArgs) => `${userPage(user)}/admin`;
export const artPage = (tag?: string) => `/art${tag ? `?tag=${tag}` : ""}`;
export const userArtPage = (
user: UserLinkArgs,

View File

@ -36,7 +36,7 @@ export function queryToUserIdentifier(
const DISCORD_EPOCH = 1420070400000;
// Converts a snowflake ID string into a JS Date object using the provided epoch (in ms), or Discord's epoch if not provided
function convertSnowflakeToDate(snowflake: string) {
export function convertSnowflakeToDate(snowflake: string) {
// Convert snowflake to BigInt to extract timestamp bits
// https://discord.com/developers/docs/reference#snowflakes
const milliseconds = BigInt(snowflake) >> 22n;

37
migrations/089-ban-log.js Normal file
View File

@ -0,0 +1,37 @@
export function up(db) {
db.transaction(() => {
db.prepare(
/* sql */ `create table "BanLog" (
"id" integer primary key,
"userId" integer not null,
"banned" integer,
"bannedReason" text,
"bannedByUserId" integer not null,
"createdAt" integer default (strftime('%s', 'now')) not null,
foreign key ("userId") references "User"("id") on delete restrict,
foreign key ("bannedByUserId") references "User"("id") on delete restrict
)`,
).run();
db.prepare(
/*sql*/ `create index ban_log_user_id on "BanLog"("userId")`,
).run();
db.prepare(
/* sql */ `create table "ModNote" (
"id" integer primary key,
"userId" integer not null,
"authorId" integer not null,
"text" text not null,
"createdAt" integer default (strftime('%s', 'now')) not null,
"isDeleted" integer not null default 0,
foreign key ("userId") references "User"("id") on delete restrict,
foreign key ("authorId") references "User"("id") on delete restrict
)`,
).run();
db.prepare(
/*sql*/ `create index mod_note_user_id on "ModNote"("userId")`,
).run();
})();
}