fix(housekeeping): harden people read boundaries
This commit is contained in:
1 parent
e3f8b51d31
commit
d1382c839e
12 files changed
+1635
-446
No files matched your search
@@ -41,9 +41,12 @@ describe("People list normalization", () => {
|
||||
"id",
|
||||
),
|
||||
).toMatchObject({ pageSize: 20, offset: 0 });
|
||||
expect(
|
||||
normalizePeopleListInput({ offset: 999_999 }, ["id"], "id").offset,
|
||||
).toBe(10_000);
|
||||
});
|
||||
|
||||
it("sorts a page deterministically with an id tie breaker", () => {
|
||||
it("wraps rows already sorted and paged by the adapter", () => {
|
||||
const input = normalizePeopleListInput(
|
||||
{ pageSize: 2, offset: 1, sort: "username", order: "asc" },
|
||||
["id", "username"],
|
||||
@@ -51,20 +54,17 @@ describe("People list normalization", () => {
|
||||
);
|
||||
const page = createPeoplePage(
|
||||
[
|
||||
{ id: 4, username: "Bob" },
|
||||
{ id: 3, username: "alice" },
|
||||
{ id: 1, username: "Alice" },
|
||||
{ id: 2, username: "alice" },
|
||||
],
|
||||
4,
|
||||
input,
|
||||
(row) => row.username,
|
||||
);
|
||||
|
||||
expect(page).toEqual({
|
||||
items: [
|
||||
{ id: 2, username: "alice" },
|
||||
{ id: 3, username: "alice" },
|
||||
{ id: 1, username: "Alice" },
|
||||
],
|
||||
total: 4,
|
||||
pageSize: 2,
|
||||
@@ -116,6 +116,11 @@ describe("People canonical models", () => {
|
||||
accountCreated: toPeopleIsoDate(new Date("2026-08-29T10:20:30.000Z")),
|
||||
lastLogin: toPeopleIsoDate(1_700_000_000),
|
||||
sanctions: [],
|
||||
watched: false,
|
||||
permission: {
|
||||
rankName: "Moderator",
|
||||
ranks: [{ id: 5, name: "Moderator" }],
|
||||
},
|
||||
};
|
||||
|
||||
expect(JSON.parse(JSON.stringify(detail))).toEqual({
|
||||
@@ -132,6 +137,11 @@ describe("People canonical models", () => {
|
||||
accountCreated: "2026-08-29T10:20:30.000Z",
|
||||
lastLogin: "2023-11-14T22:13:20.000Z",
|
||||
sanctions: [],
|
||||
watched: false,
|
||||
permission: {
|
||||
rankName: "Moderator",
|
||||
ranks: [{ id: 5, name: "Moderator" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,4 +160,16 @@ describe("People canonical models", () => {
|
||||
"/ase/people/moderation/cfh/8",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects corrupt identifiers and non-serializable date values", () => {
|
||||
expect(() =>
|
||||
normalizePeopleUser(
|
||||
{ ...rawUser, id: 0 },
|
||||
{ includeMail: false, includeIp: false },
|
||||
),
|
||||
).toThrow();
|
||||
expect(() => peopleUserHref(Number.MAX_SAFE_INTEGER + 1)).toThrow();
|
||||
expect(() => peopleGuildHref(-4)).toThrow();
|
||||
expect(() => toPeopleIsoDate("not-a-date")).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -49,7 +49,7 @@ export interface PeopleSanctionSummary {
|
||||
readonly kind: string;
|
||||
readonly reason: string;
|
||||
readonly createdAt: string | null;
|
||||
readonly expiresAt: string | null;
|
||||
readonly expiresAt: string | 0 | null;
|
||||
readonly active: boolean;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,16 @@ export interface PeopleUserDetail extends PeopleUserSummary {
|
||||
readonly accountCreated: string | null;
|
||||
readonly lastLogin: string | null;
|
||||
readonly sanctions: readonly PeopleSanctionSummary[];
|
||||
readonly watched: boolean;
|
||||
readonly permission: {
|
||||
readonly rankName: string;
|
||||
readonly ranks: readonly PeoplePermissionRank[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface PeoplePermissionRank {
|
||||
readonly id: number;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
export interface PeopleMultiAccountCluster {
|
||||
@@ -135,15 +145,25 @@ export interface PeopleQueueSnapshot {
|
||||
readonly activeBans: number;
|
||||
}
|
||||
|
||||
export interface PeopleSupportStaff {
|
||||
readonly id: number;
|
||||
readonly username: string;
|
||||
readonly rank: number;
|
||||
readonly href: `/ase/people/users/${number}`;
|
||||
}
|
||||
|
||||
export interface PeopleTicketSummary {
|
||||
readonly source: "cms" | "help";
|
||||
readonly id: number;
|
||||
readonly subject: string;
|
||||
readonly status: string;
|
||||
readonly priority: string;
|
||||
readonly creatorId: number;
|
||||
readonly creatorId: number | null;
|
||||
readonly creatorUsername: string | null;
|
||||
readonly updatedAt: string | null;
|
||||
readonly href: `/ase/people/support/tickets/${number}`;
|
||||
readonly href:
|
||||
| `/ase/people/support/tickets/${number}`
|
||||
| `/ase/people/support/help-tickets/${number}`;
|
||||
}
|
||||
|
||||
export interface PeopleTicketDetail extends PeopleTicketSummary {
|
||||
@@ -157,6 +177,8 @@ export interface PeopleTicketDetail extends PeopleTicketSummary {
|
||||
readonly isStaff: boolean;
|
||||
readonly createdAt: string | null;
|
||||
}[];
|
||||
readonly queue: PeopleQueueSnapshot;
|
||||
readonly staff: readonly PeopleSupportStaff[];
|
||||
}
|
||||
|
||||
export interface PeopleHelpTicketSummary {
|
||||
@@ -180,6 +202,9 @@ export interface PeopleHelpTicketDetail extends PeopleHelpTicketSummary {
|
||||
readonly content: string;
|
||||
readonly createdAt: string | null;
|
||||
}[];
|
||||
readonly queue: PeopleQueueSnapshot;
|
||||
readonly staff: readonly PeopleSupportStaff[];
|
||||
readonly activeBan: PeopleBanSummary | null;
|
||||
}
|
||||
|
||||
export interface PeopleTicketTemplate {
|
||||
@@ -217,7 +242,7 @@ export interface PeopleBanSummary {
|
||||
readonly type: string;
|
||||
readonly reason: string;
|
||||
readonly createdAt: string | null;
|
||||
readonly expiresAt: string | null;
|
||||
readonly expiresAt: string | 0;
|
||||
readonly active: boolean;
|
||||
}
|
||||
|
||||
@@ -252,7 +277,7 @@ export interface PeopleQueries {
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
const MAX_OFFSET = 1_000_000;
|
||||
const MAX_OFFSET = 10_000;
|
||||
const MAX_SEARCH_LENGTH = 128;
|
||||
|
||||
function boundedInteger(
|
||||
@@ -297,34 +322,22 @@ export function normalizePeopleListInput(
|
||||
};
|
||||
}
|
||||
|
||||
function compareValues(left: string | number, right: string | number): number {
|
||||
if (typeof left === "number" && typeof right === "number") {
|
||||
return left - right;
|
||||
}
|
||||
return String(left).localeCompare(String(right), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
}
|
||||
|
||||
export function createPeoplePage<T extends { readonly id: number }>(
|
||||
export function createPeoplePage<T>(
|
||||
rows: readonly T[],
|
||||
total: number,
|
||||
input: NormalizedListInput,
|
||||
sortValue: (row: T) => string | number,
|
||||
): Page<T> {
|
||||
const items = [...rows]
|
||||
.sort((left, right) => {
|
||||
const primary = compareValues(sortValue(left), sortValue(right));
|
||||
return (
|
||||
(input.order === "desc" ? -primary : primary) || left.id - right.id
|
||||
);
|
||||
})
|
||||
.slice(input.offset, input.offset + input.pageSize);
|
||||
|
||||
if (
|
||||
!Number.isSafeInteger(total) ||
|
||||
total < 0 ||
|
||||
rows.length > input.pageSize
|
||||
) {
|
||||
throw new Error("invalid People page");
|
||||
}
|
||||
assertPeopleSerializable(rows);
|
||||
return {
|
||||
items,
|
||||
total: boundedInteger(total, rows.length, 0, Number.MAX_SAFE_INTEGER),
|
||||
items: [...rows],
|
||||
total,
|
||||
pageSize: input.pageSize,
|
||||
offset: input.offset,
|
||||
};
|
||||
@@ -335,6 +348,22 @@ export function toPeopleNumber(value: unknown, fallback = 0): number {
|
||||
return Number.isSafeInteger(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function positiveSafeInteger(value: unknown): number {
|
||||
const parsed = typeof value === "bigint" ? Number(value) : Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
||||
throw new Error("invalid People identifier");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function nonNegativeSafeInteger(value: unknown): number {
|
||||
const parsed = typeof value === "bigint" ? Number(value) : Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
||||
throw new Error("invalid People number");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function toPeopleIsoDate(value: unknown): string | null {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
let date: Date;
|
||||
@@ -348,40 +377,47 @@ export function toPeopleIsoDate(value: unknown): string | null {
|
||||
)
|
||||
: new Date(String(value));
|
||||
}
|
||||
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
||||
if (!Number.isFinite(date.getTime())) throw new Error("invalid People date");
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function nullableString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value !== "string") throw new Error("invalid People string");
|
||||
const normalized = value.trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function peopleUserHref(id: number): `/ase/people/users/${number}` {
|
||||
positiveSafeInteger(id);
|
||||
return `/ase/people/users/${id}`;
|
||||
}
|
||||
|
||||
export function peopleGuildHref(
|
||||
id: number,
|
||||
): `/ase/people/community/guilds/${number}` {
|
||||
positiveSafeInteger(id);
|
||||
return `/ase/people/community/guilds/${id}`;
|
||||
}
|
||||
|
||||
export function peopleTicketHref(
|
||||
id: number,
|
||||
): `/ase/people/support/tickets/${number}` {
|
||||
positiveSafeInteger(id);
|
||||
return `/ase/people/support/tickets/${id}`;
|
||||
}
|
||||
|
||||
export function peopleHelpTicketHref(
|
||||
id: number,
|
||||
): `/ase/people/support/help-tickets/${number}` {
|
||||
positiveSafeInteger(id);
|
||||
return `/ase/people/support/help-tickets/${id}`;
|
||||
}
|
||||
|
||||
export function peopleCfhHref(
|
||||
id: number,
|
||||
): `/ase/people/moderation/cfh/${number}` {
|
||||
positiveSafeInteger(id);
|
||||
return `/ase/people/moderation/cfh/${id}`;
|
||||
}
|
||||
|
||||
@@ -389,12 +425,22 @@ export function normalizePeopleUser(
|
||||
row: PeopleUserRecord,
|
||||
projection: { readonly includeMail: boolean; readonly includeIp: boolean },
|
||||
): PeopleUserSummary {
|
||||
const id = toPeopleNumber(row.id);
|
||||
const bannedUntil = toPeopleNumber(row.bannedUntil, 0);
|
||||
const id = positiveSafeInteger(row.id);
|
||||
if (typeof row.username !== "string" || row.username.trim().length === 0) {
|
||||
throw new Error("invalid People username");
|
||||
}
|
||||
const rank = nonNegativeSafeInteger(row.rank);
|
||||
const bannedUntil =
|
||||
row.bannedUntil === null || row.bannedUntil === undefined
|
||||
? null
|
||||
: nonNegativeSafeInteger(row.bannedUntil);
|
||||
const onlineValues = new Set([true, false, 0, 1, "0", "1", "true", "false"]);
|
||||
if (!onlineValues.has(row.online as never))
|
||||
throw new Error("invalid People online value");
|
||||
return {
|
||||
id,
|
||||
username: String(row.username ?? ""),
|
||||
rank: toPeopleNumber(row.rank),
|
||||
username: row.username,
|
||||
rank,
|
||||
online:
|
||||
row.online === true ||
|
||||
row.online === 1 ||
|
||||
@@ -402,7 +448,54 @@ export function normalizePeopleUser(
|
||||
row.online === "true",
|
||||
mail: projection.includeMail ? nullableString(row.mail) : null,
|
||||
ipCurrent: projection.includeIp ? nullableString(row.ipCurrent) : null,
|
||||
bannedUntil: bannedUntil > 0 ? bannedUntil : null,
|
||||
bannedUntil,
|
||||
href: peopleUserHref(id),
|
||||
};
|
||||
}
|
||||
|
||||
const DATE_KEYS = new Set([
|
||||
"accountCreated",
|
||||
"createdAt",
|
||||
"expiresAt",
|
||||
"lastLogin",
|
||||
"updatedAt",
|
||||
]);
|
||||
|
||||
export function assertPeopleSerializable(value: unknown): void {
|
||||
function visit(current: unknown, key = ""): void {
|
||||
if (
|
||||
current === null ||
|
||||
typeof current === "string" ||
|
||||
typeof current === "boolean"
|
||||
) {
|
||||
if (
|
||||
typeof current === "string" &&
|
||||
DATE_KEYS.has(key) &&
|
||||
!Number.isFinite(Date.parse(current))
|
||||
) {
|
||||
throw new Error("invalid People date string");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof current === "number") {
|
||||
if (!Number.isSafeInteger(current))
|
||||
throw new Error("invalid People number");
|
||||
if ((key === "id" || key.endsWith("Id")) && current <= 0) {
|
||||
throw new Error("invalid People identifier");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(current)) {
|
||||
for (const item of current) visit(item);
|
||||
return;
|
||||
}
|
||||
if (typeof current !== "object" || current instanceof Date) {
|
||||
throw new Error("non-serializable People value");
|
||||
}
|
||||
for (const [childKey, child] of Object.entries(current)) {
|
||||
if (child === undefined) throw new Error("undefined People value");
|
||||
visit(child, childKey);
|
||||
}
|
||||
}
|
||||
visit(value);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
import {
|
||||
assertPeopleSerializable,
|
||||
createPeoplePage,
|
||||
type ListInput,
|
||||
normalizePeopleListInput,
|
||||
@@ -77,9 +78,15 @@ export function createPeopleCommunityQuery(
|
||||
}
|
||||
try {
|
||||
const guild = await adapters.loadGuild(input.id);
|
||||
return guild === null
|
||||
? fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId)
|
||||
: ok({ kind: "guild" as const, guild }, correlationId);
|
||||
if (guild === null) {
|
||||
return fail(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
assertPeopleSerializable(guild);
|
||||
return ok({ kind: "guild" as const, guild }, correlationId);
|
||||
} catch {
|
||||
return unavailable(correlationId);
|
||||
}
|
||||
@@ -87,7 +94,9 @@ export function createPeopleCommunityQuery(
|
||||
|
||||
const list = normalizePeopleListInput(
|
||||
input.list,
|
||||
["id", "username", "name"],
|
||||
input.routeId === "people.community.online"
|
||||
? ["id", "username"]
|
||||
: ["id", "name"],
|
||||
"id",
|
||||
);
|
||||
try {
|
||||
@@ -96,9 +105,7 @@ export function createPeopleCommunityQuery(
|
||||
return ok(
|
||||
{
|
||||
kind: "online" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
||||
list.sort === "username" ? row.username : row.id,
|
||||
),
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -108,9 +115,7 @@ export function createPeopleCommunityQuery(
|
||||
return ok(
|
||||
{
|
||||
kind: "guilds" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
||||
list.sort === "name" ? row.name : row.id,
|
||||
),
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -122,11 +127,13 @@ export function createPeopleCommunityQuery(
|
||||
}
|
||||
|
||||
function resultRows<T>(result: unknown): T[] {
|
||||
if (!Array.isArray(result)) return [];
|
||||
return Array.isArray(result[0]) ? (result[0] as T[]) : [];
|
||||
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||
throw new Error("invalid People driver result");
|
||||
}
|
||||
return result[0] as T[];
|
||||
}
|
||||
|
||||
export const peopleCommunityAdapters: PeopleCommunityAdapters = {
|
||||
const peopleCommunityAdapters: PeopleCommunityAdapters = {
|
||||
async loadOnline(input) {
|
||||
const [{ sql }, { db }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
@@ -144,7 +151,7 @@ export const peopleCommunityAdapters: PeopleCommunityAdapters = {
|
||||
ORDER BY ${input.sort === "username" ? sql`username` : sql`id`} ${
|
||||
input.order === "desc" ? sql`DESC` : sql`ASC`
|
||||
}, id ASC
|
||||
LIMIT ${input.offset + input.pageSize}
|
||||
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||
`),
|
||||
db.execute(sql`SELECT COUNT(*) AS total FROM users WHERE ${where}`),
|
||||
]);
|
||||
@@ -187,7 +194,7 @@ export const peopleCommunityAdapters: PeopleCommunityAdapters = {
|
||||
ORDER BY ${input.sort === "name" ? sql`g.name` : sql`g.id`} ${
|
||||
input.order === "desc" ? sql`DESC` : sql`ASC`
|
||||
}, g.id ASC
|
||||
LIMIT ${input.offset + input.pageSize}
|
||||
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||
`),
|
||||
db.execute(sql`
|
||||
SELECT COUNT(*) AS total
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
import {
|
||||
assertPeopleSerializable,
|
||||
createPeoplePage,
|
||||
type ListInput,
|
||||
normalizePeopleListInput,
|
||||
@@ -79,10 +80,15 @@ export type PeopleModerationQueryData =
|
||||
const broadCapability = anyCapability(
|
||||
PERMS.MODERATION_VIEW,
|
||||
PERMS.MOD_CFH_VIEW,
|
||||
PERMS.BANS_VIEW,
|
||||
PERMS.MOD_ACTIONS,
|
||||
PERMS.MODERATION_EDIT,
|
||||
PERMS.MOD_BANS_VIEW,
|
||||
PERMS.SETTINGS_VIEW,
|
||||
PERMS.WORDFILTER_VIEW,
|
||||
PERMS.BANS_VIEW,
|
||||
PERMS.MOD_TICKETS_VIEW,
|
||||
PERMS.TICKETS_VIEW,
|
||||
PERMS.MOD_TEAM_VIEW,
|
||||
PERMS.MOD_USERS_VIEW,
|
||||
PERMS.USERS_VIEW,
|
||||
);
|
||||
|
||||
function routeCapability(routeId: PeopleModerationQueryInput["routeId"]) {
|
||||
@@ -136,10 +142,12 @@ export function createPeopleModerationQuery(
|
||||
|
||||
try {
|
||||
if (input.routeId === "people.moderation.overview") {
|
||||
const snapshot = await adapters.loadOverview();
|
||||
assertPeopleSerializable(snapshot);
|
||||
return ok(
|
||||
{
|
||||
kind: "overview" as const,
|
||||
snapshot: await adapters.loadOverview(),
|
||||
snapshot,
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -154,39 +162,44 @@ export function createPeopleModerationQuery(
|
||||
);
|
||||
}
|
||||
const ticket = await adapters.loadCfhDetail(input.id);
|
||||
return ticket === null
|
||||
? fail("NOT_FOUND", "errors.housekeeping.notFound", correlationId)
|
||||
: ok({ kind: "cfh-detail" as const, ticket }, correlationId);
|
||||
if (ticket === null) {
|
||||
return fail(
|
||||
"NOT_FOUND",
|
||||
"errors.housekeeping.notFound",
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
assertPeopleSerializable(ticket);
|
||||
return ok({ kind: "cfh-detail" as const, ticket }, correlationId);
|
||||
}
|
||||
if (input.routeId === "people.moderation.ip") {
|
||||
const rules = await adapters.loadIpRules();
|
||||
assertPeopleSerializable(rules);
|
||||
return ok({ kind: "ip-rules" as const, ...rules }, correlationId);
|
||||
}
|
||||
if (input.routeId === "people.moderation.vpn") {
|
||||
const settings = await adapters.loadVpnSettings();
|
||||
assertPeopleSerializable(settings);
|
||||
return ok(
|
||||
{
|
||||
kind: "vpn" as const,
|
||||
settings: await adapters.loadVpnSettings(),
|
||||
settings,
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
const list = normalizePeopleListInput(
|
||||
input.list,
|
||||
["id", "username", "createdAt", "word"],
|
||||
"id",
|
||||
);
|
||||
const allowedSorts =
|
||||
input.routeId === "people.moderation.word-filter"
|
||||
? ["id", "word"]
|
||||
: ["id", "username", "createdAt"];
|
||||
const list = normalizePeopleListInput(input.list, allowedSorts, "id");
|
||||
if (input.routeId === "people.moderation.cfh") {
|
||||
const result = await adapters.loadCfh(list);
|
||||
return ok(
|
||||
{
|
||||
kind: "cfh" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
||||
list.sort === "username"
|
||||
? (row.reportedUsername ?? "")
|
||||
: row.id,
|
||||
),
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -196,9 +209,7 @@ export function createPeopleModerationQuery(
|
||||
return ok(
|
||||
{
|
||||
kind: "bans" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
||||
list.sort === "username" ? (row.username ?? "") : row.id,
|
||||
),
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -207,9 +218,7 @@ export function createPeopleModerationQuery(
|
||||
return ok(
|
||||
{
|
||||
kind: "word-filter" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
||||
list.sort === "word" ? row.word : row.id,
|
||||
),
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -225,11 +234,13 @@ export function createPeopleModerationQuery(
|
||||
}
|
||||
|
||||
function resultRows<T>(result: unknown): T[] {
|
||||
if (!Array.isArray(result)) return [];
|
||||
return Array.isArray(result[0]) ? (result[0] as T[]) : [];
|
||||
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||
throw new Error("invalid People driver result");
|
||||
}
|
||||
return result[0] as T[];
|
||||
}
|
||||
|
||||
export const peopleModerationAdapters: PeopleModerationAdapters = {
|
||||
const peopleModerationAdapters: PeopleModerationAdapters = {
|
||||
async loadOverview() {
|
||||
const [{ sql }, { db }, { getMinStaffRank }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
@@ -276,10 +287,10 @@ export const peopleModerationAdapters: PeopleModerationAdapters = {
|
||||
LEFT JOIN users reported ON reported.id = c.reported_id
|
||||
LEFT JOIN users moderator ON moderator.id = c.mod_id
|
||||
${where}
|
||||
ORDER BY ${input.sort === "username" ? sql`reported.username` : sql`c.id`} ${
|
||||
ORDER BY ${input.sort === "username" ? sql`reported.username` : input.sort === "createdAt" ? sql`c.timestamp` : sql`c.id`} ${
|
||||
input.order === "asc" ? sql`ASC` : sql`DESC`
|
||||
}, c.id ASC
|
||||
LIMIT ${input.offset + input.pageSize}
|
||||
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||
`),
|
||||
db.execute(sql`
|
||||
SELECT COUNT(*) AS total FROM support_tickets c
|
||||
@@ -333,7 +344,7 @@ export const peopleModerationAdapters: PeopleModerationAdapters = {
|
||||
LEFT JOIN bans b ON b.user_id = c.reported_id
|
||||
AND (b.ban_expire = 0 OR b.ban_expire > UNIX_TIMESTAMP())
|
||||
WHERE c.id = ${id}
|
||||
ORDER BY b.timestamp DESC, b.id DESC
|
||||
ORDER BY (b.ban_expire = 0) DESC, b.ban_expire DESC, b.timestamp DESC, b.id DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
const row = resultRows<{
|
||||
@@ -382,8 +393,8 @@ export const peopleModerationAdapters: PeopleModerationAdapters = {
|
||||
createdAt: toPeopleIsoDate(row.banCreatedAt),
|
||||
expiresAt:
|
||||
row.banExpiresAt === 0
|
||||
? null
|
||||
: toPeopleIsoDate(row.banExpiresAt),
|
||||
? 0
|
||||
: (toPeopleIsoDate(row.banExpiresAt) as string),
|
||||
active: true,
|
||||
},
|
||||
};
|
||||
@@ -410,7 +421,7 @@ export const peopleModerationAdapters: PeopleModerationAdapters = {
|
||||
ORDER BY ${input.sort === "username" ? sql`u.username` : input.sort === "createdAt" ? sql`b.timestamp` : sql`b.id`} ${
|
||||
input.order === "asc" ? sql`ASC` : sql`DESC`
|
||||
}, b.id ASC
|
||||
LIMIT ${input.offset + input.pageSize}
|
||||
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||
`),
|
||||
db.execute(sql`
|
||||
SELECT COUNT(*) AS total FROM bans b
|
||||
@@ -438,7 +449,10 @@ export const peopleModerationAdapters: PeopleModerationAdapters = {
|
||||
type: row.type,
|
||||
reason: row.reason,
|
||||
createdAt: toPeopleIsoDate(row.createdAt),
|
||||
expiresAt: row.expiresAt === 0 ? null : toPeopleIsoDate(row.expiresAt),
|
||||
expiresAt:
|
||||
row.expiresAt === 0
|
||||
? (0 as const)
|
||||
: (toPeopleIsoDate(row.expiresAt) as string),
|
||||
active: true,
|
||||
}));
|
||||
return {
|
||||
@@ -508,7 +522,7 @@ export const peopleModerationAdapters: PeopleModerationAdapters = {
|
||||
ORDER BY ${input.sort === "word" ? sql`word` : sql`id`} ${
|
||||
input.order === "desc" ? sql`DESC` : sql`ASC`
|
||||
}, id ASC
|
||||
LIMIT ${input.offset + input.pageSize}
|
||||
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||
`),
|
||||
db.execute(
|
||||
sql`SELECT COUNT(*) AS total FROM website_wordfilter ${where}`,
|
||||
|
||||
+318
-129
@@ -1,148 +1,337 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { peopleCommunityAdapters } from "./community";
|
||||
import { peopleModerationAdapters } from "./moderation";
|
||||
import { peopleStaffAdapters } from "./staff";
|
||||
import { peopleSupportAdapters } from "./support";
|
||||
import { buildPeopleUserSelection, peopleUsersAdapters } from "./users";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
|
||||
describe("People production adapter contracts", () => {
|
||||
it("keeps each matrix-backed source behind a narrow server adapter", () => {
|
||||
expect(Object.keys(peopleUsersAdapters).sort()).toEqual([
|
||||
"loadMultiAccounts",
|
||||
"loadSanctions",
|
||||
"loadUser",
|
||||
"loadUsers",
|
||||
]);
|
||||
expect(Object.keys(peopleCommunityAdapters).sort()).toEqual([
|
||||
"loadGuild",
|
||||
"loadGuilds",
|
||||
"loadOnline",
|
||||
]);
|
||||
expect(Object.keys(peopleStaffAdapters).sort()).toEqual([
|
||||
"loadApplications",
|
||||
"loadModerationTeam",
|
||||
"loadTeams",
|
||||
]);
|
||||
expect(Object.keys(peopleSupportAdapters).sort()).toEqual([
|
||||
"loadHelpTicket",
|
||||
"loadHelpTickets",
|
||||
"loadQueue",
|
||||
"loadTemplates",
|
||||
"loadTicket",
|
||||
"loadTickets",
|
||||
]);
|
||||
expect(Object.keys(peopleModerationAdapters).sort()).toEqual([
|
||||
"loadBans",
|
||||
"loadCfh",
|
||||
"loadCfhDetail",
|
||||
"loadIpRules",
|
||||
"loadOverview",
|
||||
"loadVpnSettings",
|
||||
"loadWordFilter",
|
||||
]);
|
||||
const publicRuntimeExports = new Map([
|
||||
["./users", ["createPeopleUsersQuery", "peopleUsersQuery"]],
|
||||
["./community", ["createPeopleCommunityQuery", "peopleCommunityQuery"]],
|
||||
["./staff", ["createPeopleStaffQuery", "peopleStaffQuery"]],
|
||||
["./support", ["createPeopleSupportQuery", "peopleSupportQuery"]],
|
||||
["./moderation", ["createPeopleModerationQuery", "peopleModerationQuery"]],
|
||||
] as const);
|
||||
|
||||
describe("People production authorization boundary", () => {
|
||||
it("exports only context-authorized query factories and singleton surfaces", async () => {
|
||||
for (const [modulePath, expected] of publicRuntimeExports) {
|
||||
const runtime = await import(modulePath);
|
||||
expect(Object.keys(runtime).sort(), modulePath).toEqual(
|
||||
[...expected].sort(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("selects mail and current IP only when their independent projections allow it", () => {
|
||||
const user = {
|
||||
id: "id",
|
||||
username: "username",
|
||||
rank: "rank",
|
||||
online: "online",
|
||||
mail: "mail",
|
||||
ipCurrent: "ipCurrent",
|
||||
password: "password",
|
||||
authTicket: "authTicket",
|
||||
secretKey: "secretKey",
|
||||
twoFactorSecret: "twoFactorSecret",
|
||||
};
|
||||
it("keeps the PII selection builder private and excludes secret columns", () => {
|
||||
const source = readFileSync(new URL("./users.ts", import.meta.url), "utf8");
|
||||
expect(source).toContain("function buildPeopleUserSelection");
|
||||
expect(source).not.toContain("export function buildPeopleUserSelection");
|
||||
for (const forbidden of [
|
||||
"password",
|
||||
"authTicket",
|
||||
"secretKey",
|
||||
"twoFactorSecret",
|
||||
]) {
|
||||
expect(source).not.toContain(forbidden);
|
||||
}
|
||||
expect(source).toContain('import("@/lib/services/watch")');
|
||||
expect(source).toMatch(/FROM permissions ORDER BY id ASC/);
|
||||
});
|
||||
|
||||
expect(
|
||||
buildPeopleUserSelection(user, {
|
||||
includeMail: false,
|
||||
includeIp: false,
|
||||
}),
|
||||
).toEqual({
|
||||
id: "id",
|
||||
username: "username",
|
||||
rank: "rank",
|
||||
online: "online",
|
||||
});
|
||||
expect(
|
||||
buildPeopleUserSelection(user, {
|
||||
includeMail: true,
|
||||
includeIp: false,
|
||||
}),
|
||||
).toEqual({
|
||||
id: "id",
|
||||
username: "username",
|
||||
rank: "rank",
|
||||
online: "online",
|
||||
mail: "mail",
|
||||
});
|
||||
expect(
|
||||
buildPeopleUserSelection(user, {
|
||||
includeMail: false,
|
||||
includeIp: true,
|
||||
}),
|
||||
).toEqual({
|
||||
id: "id",
|
||||
username: "username",
|
||||
rank: "rank",
|
||||
online: "online",
|
||||
ipCurrent: "ipCurrent",
|
||||
});
|
||||
expect(
|
||||
Object.keys(
|
||||
buildPeopleUserSelection(user, {
|
||||
includeMail: true,
|
||||
includeIp: true,
|
||||
}),
|
||||
),
|
||||
).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
"password",
|
||||
"authTicket",
|
||||
"secretKey",
|
||||
"twoFactorSecret",
|
||||
]),
|
||||
it("uses a strict DB-paged unified support boundary without changing legacy tolerance", () => {
|
||||
const supportSource = readFileSync(
|
||||
new URL("./support.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const inboxSource = readFileSync(
|
||||
new URL("../../../../../lib/admin/ticket-inbox.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
expect(supportSource).toContain("fetchUnifiedTicketInbox");
|
||||
expect(supportSource).toContain("strict: true");
|
||||
expect(inboxSource).toContain("if (options.strict)");
|
||||
expect(inboxSource).toContain("UNION ALL");
|
||||
expect(inboxSource).toMatch(/LIMIT \$\{perPage\} OFFSET \$\{offset\}/);
|
||||
expect(inboxSource).toContain(".catch(() => [])");
|
||||
});
|
||||
|
||||
it("returns the stable prefix needed for offset pagination of multi-account clusters", async () => {
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
[
|
||||
{ ipCurrent: "203.0.113.1", accountCount: 2 },
|
||||
{ ipCurrent: "203.0.113.2", accountCount: 2 },
|
||||
{ ipCurrent: "203.0.113.3", accountCount: 2 },
|
||||
],
|
||||
])
|
||||
.mockResolvedValue([
|
||||
[
|
||||
{ id: 1, username: "one", rank: 1, online: "0" },
|
||||
{ id: 2, username: "two", rank: 1, online: "0" },
|
||||
],
|
||||
]);
|
||||
it("pages multi-account groups and loads their accounts in one bounded batch", async () => {
|
||||
vi.resetModules();
|
||||
const groups = [
|
||||
{ ipCurrent: "203.0.113.1", accountCount: 2 },
|
||||
{ ipCurrent: "203.0.113.2", accountCount: 2 },
|
||||
{ ipCurrent: "203.0.113.3", accountCount: 2 },
|
||||
{ ipCurrent: "203.0.113.4", accountCount: 2 },
|
||||
];
|
||||
const execute = vi.fn(
|
||||
async (query: {
|
||||
strings: readonly string[];
|
||||
values: readonly unknown[];
|
||||
}) => {
|
||||
const text = query.strings.join("?");
|
||||
if (text.includes("ROW_NUMBER() OVER")) {
|
||||
return [
|
||||
[
|
||||
{
|
||||
...groups[1],
|
||||
total: groups.length,
|
||||
id: 3,
|
||||
username: "three",
|
||||
rank: 1,
|
||||
online: "0",
|
||||
},
|
||||
{
|
||||
...groups[1],
|
||||
total: groups.length,
|
||||
id: 5,
|
||||
username: "five",
|
||||
rank: 1,
|
||||
online: "0",
|
||||
},
|
||||
{
|
||||
...groups[2],
|
||||
total: groups.length,
|
||||
id: 4,
|
||||
username: "four",
|
||||
rank: 1,
|
||||
online: "0",
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
if (text.includes("COUNT(*) AS total") && text.includes("GROUP BY")) {
|
||||
return [[{ total: groups.length }]];
|
||||
}
|
||||
if (text.includes("GROUP BY ip_current")) {
|
||||
return [text.includes("LIMIT") ? groups.slice(1, 3) : groups];
|
||||
}
|
||||
if (text.includes("ip_current IN")) {
|
||||
return [
|
||||
[
|
||||
{
|
||||
id: 3,
|
||||
username: "three",
|
||||
rank: 1,
|
||||
online: "0",
|
||||
ipCurrent: groups[1].ipCurrent,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
username: "four",
|
||||
rank: 1,
|
||||
online: "0",
|
||||
ipCurrent: groups[2].ipCurrent,
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
return [[{ id: 1, username: "one", rank: 1, online: "0" }]];
|
||||
},
|
||||
);
|
||||
const sql = (strings: TemplateStringsArray, ...values: unknown[]) => ({
|
||||
strings,
|
||||
strings: [...strings],
|
||||
values,
|
||||
});
|
||||
vi.doMock("drizzle-orm", () => ({ sql }));
|
||||
vi.doMock("@/lib/db", () => ({ db: { execute } }));
|
||||
|
||||
const result = await peopleUsersAdapters.loadMultiAccounts({
|
||||
search: "",
|
||||
pageSize: 1,
|
||||
offset: 1,
|
||||
sort: "id",
|
||||
order: "asc",
|
||||
const permissions = new Set<string>([PERMS.USERS_VIEW]);
|
||||
const capability: HousekeepingCapabilityContext = {
|
||||
actor: { id: 42, username: "operator", rank: 99 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
const { peopleUsersQuery } = await import("./users");
|
||||
const result = await peopleUsersQuery.run(capability, {
|
||||
routeId: "people.users.multi-accounts",
|
||||
list: { offset: 1, pageSize: 2 },
|
||||
});
|
||||
|
||||
expect(result.rows.map((row) => row.key)).toEqual([
|
||||
"203.0.113.1",
|
||||
"203.0.113.2",
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
page: {
|
||||
items: [{ key: groups[1].ipCurrent }, { key: groups[2].ipCurrent }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(execute.mock.calls.length).toBeLessThanOrEqual(3);
|
||||
expect(
|
||||
execute.mock.calls.map(([query]) => query.strings.join("?")).join("\n"),
|
||||
).not.toContain("WHERE ip_current = ?");
|
||||
});
|
||||
|
||||
it("maps a malformed driver result to dependency unavailable", async () => {
|
||||
vi.resetModules();
|
||||
const sql = (strings: TemplateStringsArray, ...values: unknown[]) => ({
|
||||
strings: [...strings],
|
||||
values,
|
||||
});
|
||||
vi.doMock("drizzle-orm", () => ({ sql }));
|
||||
vi.doMock("@/lib/db", () => ({
|
||||
db: { execute: vi.fn(async () => ({ malformed: true })) },
|
||||
}));
|
||||
const permissions = new Set<string>([PERMS.USERS_VIEW]);
|
||||
const capability: HousekeepingCapabilityContext = {
|
||||
actor: { id: 42, username: "operator", rank: 99 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
const { peopleCommunityQuery } = await import("./community");
|
||||
const result = await peopleCommunityQuery.run(capability, {
|
||||
routeId: "people.community.online",
|
||||
list: {},
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves permanent active bans as zero in list and detail DTOs", async () => {
|
||||
vi.resetModules();
|
||||
const sql = (strings: TemplateStringsArray, ...values: unknown[]) => ({
|
||||
strings: [...strings],
|
||||
values,
|
||||
});
|
||||
const execute = vi.fn(async (query: { strings: readonly string[] }) => {
|
||||
const text = query.strings.join("?");
|
||||
if (text.includes("SELECT COUNT(*) AS total FROM bans"))
|
||||
return [[{ total: 1 }]];
|
||||
if (text.includes("FROM bans b")) {
|
||||
return [
|
||||
[
|
||||
{
|
||||
id: 71,
|
||||
userId: 7,
|
||||
username: "Seven",
|
||||
staffId: 9,
|
||||
staffUsername: "Moderator",
|
||||
type: "account",
|
||||
reason: "permanent",
|
||||
createdAt: 1_700_000_000,
|
||||
expiresAt: 0,
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
return [
|
||||
[
|
||||
{
|
||||
id: 41,
|
||||
state: 1,
|
||||
senderId: 2,
|
||||
senderUsername: "Sender",
|
||||
reportedId: 7,
|
||||
reportedUsername: "Seven",
|
||||
moderatorId: 9,
|
||||
issue: "issue",
|
||||
roomId: 3,
|
||||
createdAt: 1_700_000_000,
|
||||
banId: 71,
|
||||
banUserId: 7,
|
||||
banStaffId: 9,
|
||||
banType: "account",
|
||||
banReason: "permanent",
|
||||
banCreatedAt: 1_700_000_000,
|
||||
banExpiresAt: 0,
|
||||
},
|
||||
],
|
||||
];
|
||||
});
|
||||
vi.doMock("drizzle-orm", () => ({ sql }));
|
||||
vi.doMock("@/lib/db", () => ({ db: { execute } }));
|
||||
const permissions = new Set<string>([
|
||||
PERMS.MOD_BANS_VIEW,
|
||||
PERMS.MOD_CFH_VIEW,
|
||||
]);
|
||||
expect(result.total).toBe(3);
|
||||
const capability: HousekeepingCapabilityContext = {
|
||||
actor: { id: 42, username: "operator", rank: 99 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
const { peopleModerationQuery } = await import("./moderation");
|
||||
const list = await peopleModerationQuery.run(capability, {
|
||||
routeId: "people.moderation.bans",
|
||||
list: {},
|
||||
});
|
||||
const detail = await peopleModerationQuery.run(capability, {
|
||||
routeId: "people.moderation.cfh-detail",
|
||||
id: 41,
|
||||
});
|
||||
expect(list).toMatchObject({
|
||||
ok: true,
|
||||
data: { page: { items: [{ expiresAt: 0, active: true }] } },
|
||||
});
|
||||
expect(detail).toMatchObject({
|
||||
ok: true,
|
||||
data: { ticket: { activeBan: { expiresAt: 0, active: true } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the strict unified ticket inbox and includes help-center rows", async () => {
|
||||
vi.resetModules();
|
||||
const fetchUnifiedTicketInbox = vi.fn(async () => ({
|
||||
rows: [
|
||||
{
|
||||
key: "help-12",
|
||||
kind: "help" as const,
|
||||
id: "12",
|
||||
title: "Help ticket",
|
||||
user: "Seven",
|
||||
userId: 7,
|
||||
status: "open",
|
||||
open: true,
|
||||
sortAt: Date.parse("2026-08-20T00:00:00.000Z"),
|
||||
date: "2026-08-20",
|
||||
href: "/admin/help-tickets/12",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
lastPage: 1,
|
||||
cmsTotal: 0,
|
||||
helpTotal: 1,
|
||||
}));
|
||||
vi.doMock("@/lib/admin/ticket-inbox", () => ({ fetchUnifiedTicketInbox }));
|
||||
vi.doMock("drizzle-orm", () => ({
|
||||
sql: (strings: TemplateStringsArray, ...values: unknown[]) => ({
|
||||
strings: [...strings],
|
||||
values,
|
||||
}),
|
||||
}));
|
||||
vi.doMock("@/lib/db", () => ({
|
||||
db: { execute: vi.fn(async () => ({ malformed: true })) },
|
||||
}));
|
||||
const permissions = new Set<string>([PERMS.TICKETS_VIEW]);
|
||||
const capability: HousekeepingCapabilityContext = {
|
||||
actor: { id: 42, username: "operator", rank: 99 },
|
||||
isSuperAdmin: false,
|
||||
has: (slug) => permissions.has(slug),
|
||||
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
|
||||
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
|
||||
};
|
||||
const { peopleSupportQuery } = await import("./support");
|
||||
const result = await peopleSupportQuery.run(capability, {
|
||||
routeId: "people.support.tickets",
|
||||
list: { sort: "updatedAt", order: "desc" },
|
||||
});
|
||||
expect(fetchUnifiedTicketInbox).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ strict: true, page: 1, perPage: 20 }),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
page: {
|
||||
items: [{ id: 12, href: "/ase/people/support/help-tickets/12" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PERMS } from "@/lib/permission-slugs";
|
||||
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
|
||||
import { PEOPLE_ROUTES } from "../routes";
|
||||
import { createPeopleCommunityQuery } from "./community";
|
||||
import { createPeopleModerationQuery } from "./moderation";
|
||||
import { createPeopleStaffQuery } from "./staff";
|
||||
@@ -29,9 +30,75 @@ const rawUser = (id: number, username: string) => ({
|
||||
});
|
||||
|
||||
describe("People users query", () => {
|
||||
it("normalizes list input, sorts stable ties, and projects PII for admin users view", async () => {
|
||||
it("includes watched state and permission-rank data required by detail and edit", async () => {
|
||||
const query = createPeopleUsersQuery({
|
||||
loadUsers: async () => ({ rows: [], total: 0 }),
|
||||
loadUser: async () => ({
|
||||
...rawUser(9, "Nine"),
|
||||
motto: "",
|
||||
look: "hd-180-1",
|
||||
accountCreated: 1_700_000_000,
|
||||
lastLogin: 1_700_000_100,
|
||||
}),
|
||||
loadMultiAccounts: async () => ({ rows: [], total: 0 }),
|
||||
loadSanctions: async () => [],
|
||||
loadWatchedUserIds: async () => new Set([9]),
|
||||
loadPermissionRanks: async () => [
|
||||
{ id: 1, name: "User" },
|
||||
{ id: 5, name: "Moderator" },
|
||||
],
|
||||
} as never);
|
||||
const result = await query.run(context([PERMS.USERS_VIEW]), {
|
||||
routeId: "people.users.detail",
|
||||
id: 9,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
user: {
|
||||
watched: true,
|
||||
permission: {
|
||||
rankName: "Moderator",
|
||||
ranks: [
|
||||
{ id: 1, name: "User" },
|
||||
{ id: 5, name: "Moderator" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves a DB-paged username window without locale re-sort or offset slicing", async () => {
|
||||
const query = createPeopleUsersQuery({
|
||||
loadUsers: async () => ({
|
||||
rows: [rawUser(10, "user10"), rawUser(2, "user2")],
|
||||
total: 20,
|
||||
}),
|
||||
loadUser: async () => null,
|
||||
loadMultiAccounts: async () => ({ rows: [], total: 0 }),
|
||||
loadSanctions: async () => [],
|
||||
loadWatchedUserIds: async () => new Set(),
|
||||
loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }],
|
||||
});
|
||||
|
||||
const result = await query.run(context([PERMS.USERS_VIEW]), {
|
||||
routeId: "people.users.list",
|
||||
list: { sort: "username", order: "asc", offset: 2, pageSize: 2 },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: {
|
||||
kind: "users",
|
||||
page: { items: [{ id: 10 }, { id: 2 }], offset: 2, pageSize: 2 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes list input and projects PII for admin users view", async () => {
|
||||
const loadUsers = vi.fn(async () => ({
|
||||
rows: [rawUser(3, "alice"), rawUser(1, "Alice"), rawUser(2, "alice")],
|
||||
rows: [rawUser(1, "Alice"), rawUser(2, "alice"), rawUser(3, "alice")],
|
||||
total: 3,
|
||||
}));
|
||||
const query = createPeopleUsersQuery({
|
||||
@@ -39,6 +106,8 @@ describe("People users query", () => {
|
||||
loadUser: async () => null,
|
||||
loadMultiAccounts: async () => ({ rows: [], total: 0 }),
|
||||
loadSanctions: async () => [],
|
||||
loadWatchedUserIds: async () => new Set(),
|
||||
loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }],
|
||||
});
|
||||
|
||||
const result = await query.run(context([PERMS.USERS_VIEW]), {
|
||||
@@ -94,6 +163,8 @@ describe("People users query", () => {
|
||||
loadUser: async () => null,
|
||||
loadMultiAccounts: async () => ({ rows: [], total: 0 }),
|
||||
loadSanctions: async () => [],
|
||||
loadWatchedUserIds: async () => new Set(),
|
||||
loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }],
|
||||
});
|
||||
|
||||
const moderator = await query.run(context([PERMS.MOD_USERS_VIEW]), {
|
||||
@@ -132,6 +203,8 @@ describe("People users query", () => {
|
||||
loadUser,
|
||||
loadMultiAccounts: async () => ({ rows: [], total: 0 }),
|
||||
loadSanctions: async () => [],
|
||||
loadWatchedUserIds: async () => new Set(),
|
||||
loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }],
|
||||
});
|
||||
|
||||
const invalid = await query.run(context([PERMS.USERS_VIEW]), {
|
||||
@@ -184,6 +257,8 @@ describe("People users query", () => {
|
||||
active: true,
|
||||
},
|
||||
],
|
||||
loadWatchedUserIds: async () => new Set(),
|
||||
loadPermissionRanks: async () => [{ id: 5, name: "Moderator" }],
|
||||
});
|
||||
|
||||
const result = await query.run(context([PERMS.USERS_VIEW]), {
|
||||
@@ -208,6 +283,35 @@ describe("People users query", () => {
|
||||
});
|
||||
|
||||
describe("People community and staff queries", () => {
|
||||
it("fails closed when a community adapter returns a corrupt entity id", async () => {
|
||||
const query = createPeopleCommunityQuery({
|
||||
loadOnline: async () => ({ rows: [], total: 0 }),
|
||||
loadGuilds: async () => ({ rows: [], total: 0 }),
|
||||
loadGuild: async () =>
|
||||
({
|
||||
id: 0,
|
||||
name: "Corrupt",
|
||||
description: "",
|
||||
ownerId: -1,
|
||||
ownerUsername: null,
|
||||
memberCount: 0,
|
||||
createdAt: null,
|
||||
href: "/ase/people/community/guilds/0",
|
||||
roomId: 0,
|
||||
threadCount: 0,
|
||||
members: [],
|
||||
}) as never,
|
||||
});
|
||||
const result = await query.run(context([PERMS.USERS_VIEW]), {
|
||||
routeId: "people.community.guild-detail",
|
||||
id: 1,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("loads online, guild list, and guild detail workflows through narrow adapters", async () => {
|
||||
const loadOnline = vi.fn(async () => ({ rows: [], total: 0 }));
|
||||
const loadGuilds = vi.fn(async () => ({
|
||||
@@ -317,6 +421,161 @@ describe("People community and staff queries", () => {
|
||||
});
|
||||
|
||||
describe("People support query", () => {
|
||||
it("hydrates desk/detail support context and the help-ticket active ban", async () => {
|
||||
const queue = { tickets: 2, helpTickets: 1, cfh: 3, activeBans: 4 };
|
||||
const staff = [
|
||||
{
|
||||
id: 8,
|
||||
username: "Helper",
|
||||
rank: 5,
|
||||
href: "/ase/people/users/8" as const,
|
||||
},
|
||||
];
|
||||
const activeBan = {
|
||||
id: 31,
|
||||
userId: 7,
|
||||
username: "Seven",
|
||||
staffId: 8,
|
||||
staffUsername: "Helper",
|
||||
type: "account",
|
||||
reason: "permanent",
|
||||
createdAt: "2026-08-01T00:00:00.000Z",
|
||||
expiresAt: 0 as const,
|
||||
active: true,
|
||||
};
|
||||
const query = createPeopleSupportQuery({
|
||||
loadQueue: async () => queue,
|
||||
loadTickets: async () => ({ rows: [], total: 0 }),
|
||||
loadTicket: async () => ({
|
||||
id: 11,
|
||||
subject: "CMS ticket",
|
||||
status: "open",
|
||||
priority: "normal",
|
||||
creatorId: 7,
|
||||
creatorUsername: "Seven",
|
||||
updatedAt: "2026-08-20T00:00:00.000Z",
|
||||
href: "/ase/people/support/tickets/11",
|
||||
category: "general",
|
||||
assigneeId: null,
|
||||
messages: [],
|
||||
}),
|
||||
loadTemplates: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTicket: async () => ({
|
||||
id: 12,
|
||||
title: "Help ticket",
|
||||
open: true,
|
||||
userId: 7,
|
||||
username: "Seven",
|
||||
updatedAt: "2026-08-20T00:00:00.000Z",
|
||||
href: "/ase/people/support/help-tickets/12",
|
||||
categoryId: 2,
|
||||
categoryName: "Help",
|
||||
content: "Body",
|
||||
replies: [],
|
||||
}),
|
||||
loadSupportStaff: async () => staff,
|
||||
loadActiveBan: async () => activeBan,
|
||||
} as never);
|
||||
|
||||
const desk = await query.run(context([PERMS.TICKETS_VIEW]), {
|
||||
routeId: "people.support.ticket-desk",
|
||||
list: {},
|
||||
});
|
||||
const detail = await query.run(context([PERMS.TICKETS_VIEW]), {
|
||||
routeId: "people.support.ticket-detail",
|
||||
id: 11,
|
||||
});
|
||||
const help = await query.run(context([PERMS.TICKETS_VIEW]), {
|
||||
routeId: "people.support.help-ticket-detail",
|
||||
id: 12,
|
||||
});
|
||||
expect(desk).toMatchObject({
|
||||
ok: true,
|
||||
data: { kind: "ticket-desk", queue, staff },
|
||||
});
|
||||
expect(detail).toMatchObject({
|
||||
ok: true,
|
||||
data: { ticket: { queue, staff } },
|
||||
});
|
||||
expect(help).toMatchObject({
|
||||
ok: true,
|
||||
data: { ticket: { queue, staff, activeBan } },
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when support returns a corrupt detail shape", async () => {
|
||||
const query = createPeopleSupportQuery({
|
||||
loadQueue: async () => ({
|
||||
tickets: 0,
|
||||
helpTickets: 0,
|
||||
cfh: 0,
|
||||
activeBans: 0,
|
||||
}),
|
||||
loadTickets: async () => ({ rows: [], total: 0 }),
|
||||
loadTicket: async () =>
|
||||
({ id: 0, href: "/ase/people/support/tickets/0" }) as never,
|
||||
loadTemplates: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTicket: async () => null,
|
||||
loadSupportStaff: async () => [],
|
||||
loadActiveBan: async () => null,
|
||||
});
|
||||
const result = await query.run(context([PERMS.TICKETS_VIEW]), {
|
||||
routeId: "people.support.ticket-detail",
|
||||
id: 1,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["status", [7, 3]],
|
||||
["updatedAt", [8, 4]],
|
||||
] as const)(
|
||||
"preserves the DB-paged ticket window for %s ordering",
|
||||
async (sort, ids) => {
|
||||
const rows = ids.map((id, index) => ({
|
||||
source: "cms" as const,
|
||||
id,
|
||||
subject: `Ticket ${id}`,
|
||||
status: index === 0 ? "open" : "pending",
|
||||
priority: "normal",
|
||||
creatorId: 1,
|
||||
creatorUsername: "Reporter",
|
||||
updatedAt: `2026-08-${20 - index}T00:00:00.000Z`,
|
||||
href: `/ase/people/support/tickets/${id}` as const,
|
||||
}));
|
||||
const query = createPeopleSupportQuery({
|
||||
loadQueue: async () => ({
|
||||
tickets: 0,
|
||||
helpTickets: 0,
|
||||
cfh: 0,
|
||||
activeBans: 0,
|
||||
}),
|
||||
loadTickets: async () => ({ rows, total: 40 }),
|
||||
loadTicket: async () => null,
|
||||
loadTemplates: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTicket: async () => null,
|
||||
loadSupportStaff: async () => [],
|
||||
loadActiveBan: async () => null,
|
||||
});
|
||||
|
||||
const result = await query.run(context([PERMS.TICKETS_VIEW]), {
|
||||
routeId: "people.support.ticket-desk",
|
||||
list: { sort, order: "desc", offset: 10, pageSize: 2 },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
data: { page: { items: ids.map((id) => ({ id })), offset: 10 } },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("returns the four-source queue snapshot and fails closed when a count fails", async () => {
|
||||
const loadQueue = vi
|
||||
.fn()
|
||||
@@ -334,6 +593,8 @@ describe("People support query", () => {
|
||||
loadTemplates: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTicket: async () => null,
|
||||
loadSupportStaff: async () => [],
|
||||
loadActiveBan: async () => null,
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -370,6 +631,8 @@ describe("People support query", () => {
|
||||
loadTemplates: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTickets: async () => ({ rows: [], total: 0 }),
|
||||
loadHelpTicket: async () => null,
|
||||
loadSupportStaff: async () => [],
|
||||
loadActiveBan: async () => null,
|
||||
});
|
||||
|
||||
for (const routeId of [
|
||||
@@ -402,6 +665,59 @@ describe("People support query", () => {
|
||||
});
|
||||
|
||||
describe("People moderation query", () => {
|
||||
it("declares exactly the same eleven-permission union as the moderation overview route", () => {
|
||||
const query = createPeopleModerationQuery({
|
||||
loadOverview: async () => ({
|
||||
tickets: 0,
|
||||
helpTickets: 0,
|
||||
cfh: 0,
|
||||
activeBans: 0,
|
||||
staffOnline: 0,
|
||||
recentActions: 0,
|
||||
}),
|
||||
loadCfh: async () => ({ rows: [], total: 0 }),
|
||||
loadCfhDetail: async () => null,
|
||||
loadBans: async () => ({ rows: [], total: 0 }),
|
||||
loadIpRules: async () => ({ blacklist: [], whitelist: [] }),
|
||||
loadVpnSettings: async () => [],
|
||||
loadWordFilter: async () => ({ rows: [], total: 0 }),
|
||||
});
|
||||
const route = PEOPLE_ROUTES.find(
|
||||
(candidate) => candidate.id === "people.moderation.overview",
|
||||
);
|
||||
expect(route).toBeDefined();
|
||||
expect(query.capability).toEqual(route?.capability);
|
||||
expect(query.capability.slugs).toHaveLength(11);
|
||||
});
|
||||
|
||||
it("fails closed when moderation returns a corrupt detail shape", async () => {
|
||||
const query = createPeopleModerationQuery({
|
||||
loadOverview: async () => ({
|
||||
tickets: 0,
|
||||
helpTickets: 0,
|
||||
cfh: 0,
|
||||
activeBans: 0,
|
||||
staffOnline: 0,
|
||||
recentActions: 0,
|
||||
}),
|
||||
loadCfh: async () => ({ rows: [], total: 0 }),
|
||||
loadCfhDetail: async () =>
|
||||
({ id: -1, href: "/ase/people/moderation/cfh/-1" }) as never,
|
||||
loadBans: async () => ({ rows: [], total: 0 }),
|
||||
loadIpRules: async () => ({ blacklist: [], whitelist: [] }),
|
||||
loadVpnSettings: async () => [],
|
||||
loadWordFilter: async () => ({ rows: [], total: 0 }),
|
||||
});
|
||||
const result = await query.run(context([PERMS.MOD_CFH_VIEW]), {
|
||||
routeId: "people.moderation.cfh-detail",
|
||||
id: 1,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEPENDENCY_UNAVAILABLE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("covers overview, CFH, bans, IP, VPN, and word-filter read workflows", async () => {
|
||||
const query = createPeopleModerationQuery({
|
||||
loadOverview: async () => ({
|
||||
|
||||
@@ -75,11 +75,13 @@ export function createPeopleStaffQuery(
|
||||
const authorization = authorizeHousekeeping(context, requirement);
|
||||
if (!authorization.ok) return authorization;
|
||||
const correlationId = authorization.correlationId;
|
||||
const list = normalizePeopleListInput(
|
||||
input.list,
|
||||
["id", "name", "username", "createdAt"],
|
||||
"id",
|
||||
);
|
||||
const allowedSorts =
|
||||
input.routeId === "people.staff.applications"
|
||||
? ["id", "username", "createdAt"]
|
||||
: input.routeId === "people.staff.teams"
|
||||
? ["id", "name"]
|
||||
: ["id", "username"];
|
||||
const list = normalizePeopleListInput(input.list, allowedSorts, "id");
|
||||
|
||||
try {
|
||||
if (input.routeId === "people.staff.applications") {
|
||||
@@ -87,13 +89,7 @@ export function createPeopleStaffQuery(
|
||||
return ok(
|
||||
{
|
||||
kind: "applications" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
||||
list.sort === "username"
|
||||
? (row.username ?? "")
|
||||
: list.sort === "createdAt"
|
||||
? (row.createdAt ?? "")
|
||||
: row.id,
|
||||
),
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -104,9 +100,7 @@ export function createPeopleStaffQuery(
|
||||
return ok(
|
||||
{
|
||||
kind: "teams" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
||||
list.sort === "name" ? row.name : row.id,
|
||||
),
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -116,9 +110,7 @@ export function createPeopleStaffQuery(
|
||||
return ok(
|
||||
{
|
||||
kind: "moderation-team" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
||||
list.sort === "username" ? row.username : row.id,
|
||||
),
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -134,11 +126,13 @@ export function createPeopleStaffQuery(
|
||||
}
|
||||
|
||||
function resultRows<T>(result: unknown): T[] {
|
||||
if (!Array.isArray(result)) return [];
|
||||
return Array.isArray(result[0]) ? (result[0] as T[]) : [];
|
||||
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||
throw new Error("invalid People driver result");
|
||||
}
|
||||
return result[0] as T[];
|
||||
}
|
||||
|
||||
export const peopleStaffAdapters: PeopleStaffAdapters = {
|
||||
const peopleStaffAdapters: PeopleStaffAdapters = {
|
||||
async loadApplications(input) {
|
||||
const [{ sql }, { db }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
@@ -158,7 +152,7 @@ export const peopleStaffAdapters: PeopleStaffAdapters = {
|
||||
ORDER BY ${input.sort === "username" ? sql`u.username` : input.sort === "createdAt" ? sql`a.created_at` : sql`a.id`} ${
|
||||
input.order === "asc" ? sql`ASC` : sql`DESC`
|
||||
}, a.id ASC
|
||||
LIMIT ${input.offset + input.pageSize}
|
||||
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||
`),
|
||||
db.execute(sql`
|
||||
SELECT COUNT(*) AS total
|
||||
@@ -205,7 +199,7 @@ export const peopleStaffAdapters: PeopleStaffAdapters = {
|
||||
ORDER BY ${input.sort === "name" ? sql`rank_name` : sql`id`} ${
|
||||
input.order === "desc" ? sql`DESC` : sql`ASC`
|
||||
}, id ASC
|
||||
LIMIT ${input.offset + input.pageSize}
|
||||
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||
`),
|
||||
db.execute(sql`SELECT COUNT(*) AS total FROM website_teams ${where}`),
|
||||
]);
|
||||
@@ -248,7 +242,7 @@ export const peopleStaffAdapters: PeopleStaffAdapters = {
|
||||
ORDER BY ${input.sort === "username" ? sql`u.username` : sql`u.id`} ${
|
||||
input.order === "desc" ? sql`DESC` : sql`ASC`
|
||||
}, u.id ASC
|
||||
LIMIT ${input.offset + input.pageSize}
|
||||
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||
`),
|
||||
db.execute(sql`
|
||||
SELECT COUNT(*) AS total FROM users u
|
||||
|
||||
@@ -9,13 +9,16 @@ import {
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
import {
|
||||
assertPeopleSerializable,
|
||||
createPeoplePage,
|
||||
type ListInput,
|
||||
normalizePeopleListInput,
|
||||
type Page,
|
||||
type PeopleBanSummary,
|
||||
type PeopleHelpTicketDetail,
|
||||
type PeopleHelpTicketSummary,
|
||||
type PeopleQueueSnapshot,
|
||||
type PeopleSupportStaff,
|
||||
type PeopleTicketDetail,
|
||||
type PeopleTicketSummary,
|
||||
type PeopleTicketTemplate,
|
||||
@@ -29,19 +32,27 @@ interface SupportRows<T> {
|
||||
readonly total: number;
|
||||
}
|
||||
|
||||
type PeopleTicketRecord = Omit<PeopleTicketDetail, "queue" | "staff">;
|
||||
type PeopleHelpTicketRecord = Omit<
|
||||
PeopleHelpTicketDetail,
|
||||
"queue" | "staff" | "activeBan"
|
||||
>;
|
||||
|
||||
export interface PeopleSupportAdapters {
|
||||
loadQueue(): Promise<PeopleQueueSnapshot>;
|
||||
loadTickets(
|
||||
input: ReturnType<typeof normalizePeopleListInput>,
|
||||
): Promise<SupportRows<PeopleTicketSummary>>;
|
||||
loadTicket(id: number): Promise<PeopleTicketDetail | null>;
|
||||
loadTicket(id: number): Promise<PeopleTicketRecord | null>;
|
||||
loadTemplates(
|
||||
input: ReturnType<typeof normalizePeopleListInput>,
|
||||
): Promise<SupportRows<PeopleTicketTemplate>>;
|
||||
loadHelpTickets(
|
||||
input: ReturnType<typeof normalizePeopleListInput>,
|
||||
): Promise<SupportRows<PeopleHelpTicketSummary>>;
|
||||
loadHelpTicket(id: number): Promise<PeopleHelpTicketDetail | null>;
|
||||
loadHelpTicket(id: number): Promise<PeopleHelpTicketRecord | null>;
|
||||
loadSupportStaff(): Promise<readonly PeopleSupportStaff[]>;
|
||||
loadActiveBan(userId: number): Promise<PeopleBanSummary | null>;
|
||||
}
|
||||
|
||||
export type PeopleSupportQueryInput =
|
||||
@@ -65,6 +76,12 @@ export type PeopleSupportQueryInput =
|
||||
export type PeopleSupportQueryData =
|
||||
| { readonly kind: "queue"; readonly queue: PeopleQueueSnapshot }
|
||||
| { readonly kind: "tickets"; readonly page: Page<PeopleTicketSummary> }
|
||||
| {
|
||||
readonly kind: "ticket-desk";
|
||||
readonly page: Page<PeopleTicketSummary>;
|
||||
readonly queue: PeopleQueueSnapshot;
|
||||
readonly staff: readonly PeopleSupportStaff[];
|
||||
}
|
||||
| {
|
||||
readonly kind: "ticket-templates";
|
||||
readonly page: Page<PeopleTicketTemplate>;
|
||||
@@ -119,10 +136,9 @@ export function createPeopleSupportQuery(
|
||||
|
||||
try {
|
||||
if (input.routeId === "people.support.queue") {
|
||||
return ok(
|
||||
{ kind: "queue" as const, queue: await adapters.loadQueue() },
|
||||
correlationId,
|
||||
);
|
||||
const queue = await adapters.loadQueue();
|
||||
assertPeopleSerializable(queue);
|
||||
return ok({ kind: "queue" as const, queue }, correlationId);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -142,40 +158,52 @@ export function createPeopleSupportQuery(
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
const helpTicket =
|
||||
input.routeId === "people.support.help-ticket-detail"
|
||||
? (ticket as PeopleHelpTicketRecord)
|
||||
: null;
|
||||
const [queue, staff, activeBan] = await Promise.all([
|
||||
adapters.loadQueue(),
|
||||
adapters.loadSupportStaff(),
|
||||
helpTicket?.userId !== null && helpTicket?.userId !== undefined
|
||||
? adapters.loadActiveBan(helpTicket.userId)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
const hydrated =
|
||||
input.routeId === "people.support.ticket-detail"
|
||||
? { ...ticket, queue, staff }
|
||||
: { ...ticket, queue, staff, activeBan };
|
||||
assertPeopleSerializable(hydrated);
|
||||
return input.routeId === "people.support.ticket-detail"
|
||||
? ok(
|
||||
{
|
||||
kind: "ticket" as const,
|
||||
ticket: ticket as PeopleTicketDetail,
|
||||
ticket: hydrated as PeopleTicketDetail,
|
||||
},
|
||||
correlationId,
|
||||
)
|
||||
: ok(
|
||||
{
|
||||
kind: "help-ticket" as const,
|
||||
ticket: ticket as PeopleHelpTicketDetail,
|
||||
ticket: hydrated as PeopleHelpTicketDetail,
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
const list = normalizePeopleListInput(
|
||||
input.list,
|
||||
["id", "subject", "title", "status", "updatedAt", "sortOrder"],
|
||||
"id",
|
||||
);
|
||||
const allowedSorts =
|
||||
input.routeId === "people.support.ticket-templates"
|
||||
? ["id", "title", "sortOrder"]
|
||||
: input.routeId === "people.support.help-tickets"
|
||||
? ["id", "title", "updatedAt"]
|
||||
: ["id", "subject", "status", "updatedAt"];
|
||||
const list = normalizePeopleListInput(input.list, allowedSorts, "id");
|
||||
if (input.routeId === "people.support.ticket-templates") {
|
||||
const result = await adapters.loadTemplates(list);
|
||||
return ok(
|
||||
{
|
||||
kind: "ticket-templates" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
||||
list.sort === "title"
|
||||
? row.title
|
||||
: list.sort === "sortOrder"
|
||||
? row.sortOrder
|
||||
: row.id,
|
||||
),
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -186,9 +214,25 @@ export function createPeopleSupportQuery(
|
||||
return ok(
|
||||
{
|
||||
kind: "help-tickets" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
||||
list.sort === "title" ? row.title : row.id,
|
||||
),
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.routeId === "people.support.ticket-desk") {
|
||||
const [result, queue, staff] = await Promise.all([
|
||||
adapters.loadTickets(list),
|
||||
adapters.loadQueue(),
|
||||
adapters.loadSupportStaff(),
|
||||
]);
|
||||
assertPeopleSerializable({ queue, staff });
|
||||
return ok(
|
||||
{
|
||||
kind: "ticket-desk" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
queue,
|
||||
staff,
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -198,9 +242,7 @@ export function createPeopleSupportQuery(
|
||||
return ok(
|
||||
{
|
||||
kind: "tickets" as const,
|
||||
page: createPeoplePage(result.rows, result.total, list, (row) =>
|
||||
list.sort === "subject" ? row.subject : row.id,
|
||||
),
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -212,84 +254,74 @@ export function createPeopleSupportQuery(
|
||||
}
|
||||
|
||||
function resultRows<T>(result: unknown): T[] {
|
||||
if (!Array.isArray(result)) return [];
|
||||
return Array.isArray(result[0]) ? (result[0] as T[]) : [];
|
||||
if (!Array.isArray(result) || !Array.isArray(result[0])) {
|
||||
throw new Error("invalid People driver result");
|
||||
}
|
||||
return result[0] as T[];
|
||||
}
|
||||
|
||||
export const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
async loadQueue() {
|
||||
const [{ sql }, { db }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
import("@/lib/db"),
|
||||
]);
|
||||
const result = await db.execute(sql`
|
||||
const [{ sql }, { db }, { fetchTicketQueueOpenCounts }] = await Promise.all(
|
||||
[
|
||||
import("drizzle-orm"),
|
||||
import("@/lib/db"),
|
||||
import("@/lib/admin/ticket-queue-counts"),
|
||||
],
|
||||
);
|
||||
const [ticketCounts, result] = await Promise.all([
|
||||
fetchTicketQueueOpenCounts({ strict: true }),
|
||||
db.execute(sql`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM website_tickets WHERE status <> 'closed') AS tickets,
|
||||
(SELECT COUNT(*) FROM website_help_center_tickets WHERE open = 1) AS helpTickets,
|
||||
(SELECT COUNT(*) FROM support_tickets WHERE state <> 2) AS cfh,
|
||||
(SELECT COUNT(*) FROM bans WHERE ban_expire = 0 OR ban_expire > UNIX_TIMESTAMP()) AS activeBans
|
||||
`);
|
||||
`),
|
||||
]);
|
||||
const row = resultRows<{
|
||||
tickets: number;
|
||||
helpTickets: number;
|
||||
cfh: number;
|
||||
activeBans: number;
|
||||
}>(result)[0];
|
||||
if (!row) throw new Error("queue counts unavailable");
|
||||
return {
|
||||
tickets: Number(row.tickets),
|
||||
helpTickets: Number(row.helpTickets),
|
||||
tickets: ticketCounts.cmsOpen,
|
||||
helpTickets: ticketCounts.helpOpen,
|
||||
cfh: Number(row.cfh),
|
||||
activeBans: Number(row.activeBans),
|
||||
};
|
||||
},
|
||||
async loadTickets(input) {
|
||||
const [{ sql }, { db }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
import("@/lib/db"),
|
||||
]);
|
||||
const pattern = `%${input.search}%`;
|
||||
const where = input.search
|
||||
? sql`WHERE t.subject LIKE ${pattern} OR t.status LIKE ${pattern} OR u.username LIKE ${pattern}`
|
||||
: sql``;
|
||||
const [rowsResult, countResult] = await Promise.all([
|
||||
db.execute(sql`
|
||||
SELECT t.id, t.subject, t.status, t.priority, t.creator_id AS creatorId,
|
||||
u.username AS creatorUsername, t.updated_at AS updatedAt
|
||||
FROM website_tickets t
|
||||
LEFT JOIN users u ON u.id = t.creator_id
|
||||
${where}
|
||||
ORDER BY ${input.sort === "subject" ? sql`t.subject` : input.sort === "status" ? sql`t.status` : input.sort === "updatedAt" ? sql`t.updated_at` : sql`t.id`} ${
|
||||
input.order === "asc" ? sql`ASC` : sql`DESC`
|
||||
}, t.id ASC
|
||||
LIMIT ${input.offset + input.pageSize}
|
||||
`),
|
||||
db.execute(sql`
|
||||
SELECT COUNT(*) AS total FROM website_tickets t
|
||||
LEFT JOIN users u ON u.id = t.creator_id ${where}
|
||||
`),
|
||||
]);
|
||||
const rows = resultRows<{
|
||||
id: number;
|
||||
subject: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
creatorId: number;
|
||||
creatorUsername: string | null;
|
||||
updatedAt: Date | string | null;
|
||||
}>(rowsResult).map((row) => ({
|
||||
id: Number(row.id),
|
||||
subject: row.subject,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
creatorId: Number(row.creatorId),
|
||||
creatorUsername: row.creatorUsername,
|
||||
updatedAt: toPeopleIsoDate(row.updatedAt),
|
||||
href: peopleTicketHref(Number(row.id)),
|
||||
}));
|
||||
const { fetchUnifiedTicketInbox } = await import(
|
||||
"@/lib/admin/ticket-inbox"
|
||||
);
|
||||
const result = await fetchUnifiedTicketInbox({
|
||||
strict: true,
|
||||
page: Math.floor(input.offset / input.pageSize) + 1,
|
||||
perPage: input.pageSize,
|
||||
offset: input.offset,
|
||||
search: input.search,
|
||||
openOnly: false,
|
||||
base: "admin",
|
||||
sort: input.sort as "id" | "subject" | "status" | "updatedAt",
|
||||
order: input.order,
|
||||
});
|
||||
const rows = result.rows.map((row) => {
|
||||
const id = Number(row.id);
|
||||
return {
|
||||
source: row.kind,
|
||||
id,
|
||||
subject: row.title,
|
||||
status: row.status,
|
||||
priority: "normal",
|
||||
creatorId: row.userId,
|
||||
creatorUsername: row.user === "—" ? null : row.user,
|
||||
updatedAt: row.sortAt === 0 ? null : toPeopleIsoDate(row.sortAt),
|
||||
href:
|
||||
row.kind === "cms" ? peopleTicketHref(id) : peopleHelpTicketHref(id),
|
||||
};
|
||||
});
|
||||
return {
|
||||
rows,
|
||||
total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0),
|
||||
total: result.total,
|
||||
};
|
||||
},
|
||||
async loadTicket(id) {
|
||||
@@ -313,6 +345,7 @@ export const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
LEFT JOIN users u ON u.id = m.user_id
|
||||
WHERE m.ticket_id = ${id}
|
||||
ORDER BY m.created_at ASC, m.id ASC
|
||||
LIMIT 500
|
||||
`),
|
||||
]);
|
||||
const row = resultRows<{
|
||||
@@ -328,6 +361,7 @@ export const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
}>(ticketResult)[0];
|
||||
if (!row) return null;
|
||||
return {
|
||||
source: "cms",
|
||||
id: Number(row.id),
|
||||
subject: row.subject,
|
||||
category: row.category,
|
||||
@@ -371,7 +405,7 @@ export const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
ORDER BY ${input.sort === "title" ? sql`title` : input.sort === "sortOrder" ? sql`sort_order` : sql`id`} ${
|
||||
input.order === "desc" ? sql`DESC` : sql`ASC`
|
||||
}, id ASC
|
||||
LIMIT ${input.offset + input.pageSize}
|
||||
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||
`),
|
||||
db.execute(sql`
|
||||
SELECT COUNT(*) AS total FROM website_ticket_templates ${where}
|
||||
@@ -408,7 +442,7 @@ export const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
ORDER BY ${input.sort === "title" ? sql`t.title` : input.sort === "updatedAt" ? sql`t.updated_at` : sql`t.id`} ${
|
||||
input.order === "asc" ? sql`ASC` : sql`DESC`
|
||||
}, t.id ASC
|
||||
LIMIT ${input.offset + input.pageSize}
|
||||
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||
`),
|
||||
db.execute(sql`
|
||||
SELECT COUNT(*) AS total FROM website_help_center_tickets t
|
||||
@@ -458,6 +492,7 @@ export const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
LEFT JOIN users u ON u.id = r.user_id
|
||||
WHERE r.ticket_id = ${id}
|
||||
ORDER BY r.created_at ASC, r.id ASC
|
||||
LIMIT 500
|
||||
`),
|
||||
]);
|
||||
const row = resultRows<{
|
||||
@@ -498,6 +533,75 @@ export const peopleSupportAdapters: PeopleSupportAdapters = {
|
||||
})),
|
||||
};
|
||||
},
|
||||
async loadSupportStaff() {
|
||||
const [{ sql }, { db }, { getMinStaffRank }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
import("@/lib/db"),
|
||||
import("@/lib/admin/min-staff-rank"),
|
||||
]);
|
||||
const minStaffRank = await getMinStaffRank();
|
||||
const result = await db.execute(sql`
|
||||
SELECT id, username, rank FROM users
|
||||
WHERE rank >= ${minStaffRank}
|
||||
ORDER BY username ASC, id ASC
|
||||
LIMIT 200
|
||||
`);
|
||||
return resultRows<{ id: number; username: string; rank: number }>(
|
||||
result,
|
||||
).map((row) => ({
|
||||
id: Number(row.id),
|
||||
username: row.username,
|
||||
rank: Number(row.rank),
|
||||
href: `/ase/people/users/${Number(row.id)}` as const,
|
||||
}));
|
||||
},
|
||||
async loadActiveBan(userId) {
|
||||
const [{ sql }, { db }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
import("@/lib/db"),
|
||||
]);
|
||||
const result = await db.execute(sql`
|
||||
SELECT b.id, b.user_id AS userId, target.username,
|
||||
b.user_staff_id AS staffId, staff.username AS staffUsername,
|
||||
b.type, b.ban_reason AS reason, b.timestamp AS createdAt,
|
||||
b.ban_expire AS expiresAt
|
||||
FROM bans b
|
||||
LEFT JOIN users target ON target.id = b.user_id
|
||||
LEFT JOIN users staff ON staff.id = b.user_staff_id
|
||||
WHERE b.user_id = ${userId}
|
||||
AND (b.ban_expire = 0 OR b.ban_expire > UNIX_TIMESTAMP())
|
||||
ORDER BY (b.ban_expire = 0) DESC, b.ban_expire DESC,
|
||||
b.timestamp DESC, b.id DESC
|
||||
LIMIT 1
|
||||
`);
|
||||
const row = resultRows<{
|
||||
id: number;
|
||||
userId: number;
|
||||
username: string | null;
|
||||
staffId: number;
|
||||
staffUsername: string | null;
|
||||
type: string;
|
||||
reason: string;
|
||||
createdAt: Date | string | number | null;
|
||||
expiresAt: number;
|
||||
}>(result)[0];
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: Number(row.id),
|
||||
userId: Number(row.userId),
|
||||
username: row.username,
|
||||
staffId: Number(row.staffId),
|
||||
staffUsername: row.staffUsername,
|
||||
type: row.type,
|
||||
reason: row.reason,
|
||||
createdAt: toPeopleIsoDate(row.createdAt),
|
||||
expiresAt:
|
||||
Number(row.expiresAt) === 0
|
||||
? 0
|
||||
: (toPeopleIsoDate(row.expiresAt) as string),
|
||||
active: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const peopleSupportQuery = createPeopleSupportQuery(
|
||||
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
ok,
|
||||
} from "../../../foundation/contracts";
|
||||
import {
|
||||
assertPeopleSerializable,
|
||||
createPeoplePage,
|
||||
type ListInput,
|
||||
normalizePeopleListInput,
|
||||
normalizePeopleUser,
|
||||
type Page,
|
||||
type PeopleMultiAccountCluster,
|
||||
type PeoplePermissionRank,
|
||||
type PeopleSanctionSummary,
|
||||
type PeopleUserDetail,
|
||||
type PeopleUserRecord,
|
||||
@@ -31,7 +33,7 @@ interface PeopleUserColumns {
|
||||
readonly ipCurrent: unknown;
|
||||
}
|
||||
|
||||
export function buildPeopleUserSelection<T extends PeopleUserColumns>(
|
||||
function buildPeopleUserSelection<T extends PeopleUserColumns>(
|
||||
user: T,
|
||||
projection: { readonly includeMail: boolean; readonly includeIp: boolean },
|
||||
) {
|
||||
@@ -71,6 +73,8 @@ export interface PeopleUsersAdapters {
|
||||
readonly total: number;
|
||||
}>;
|
||||
loadSanctions(userId: number): Promise<readonly PeopleSanctionSummary[]>;
|
||||
loadWatchedUserIds(staffId: number): Promise<ReadonlySet<number>>;
|
||||
loadPermissionRanks(): Promise<readonly PeoplePermissionRank[]>;
|
||||
}
|
||||
|
||||
export type PeopleUsersQueryInput =
|
||||
@@ -141,16 +145,10 @@ export function createPeopleUsersQuery(
|
||||
const rows = result.rows.map((row) =>
|
||||
normalizePeopleUser(row, projection),
|
||||
);
|
||||
const sortValue = (row: PeopleUserSummary) =>
|
||||
list.sort === "username"
|
||||
? row.username
|
||||
: list.sort === "rank"
|
||||
? row.rank
|
||||
: row.id;
|
||||
return ok(
|
||||
{
|
||||
kind: "users" as const,
|
||||
page: createPeoplePage(rows, result.total, list, sortValue),
|
||||
page: createPeoplePage(rows, result.total, list),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -160,26 +158,17 @@ export function createPeopleUsersQuery(
|
||||
}
|
||||
|
||||
if (input.routeId === "people.users.multi-accounts") {
|
||||
const list = normalizePeopleListInput(input.list, ["id"], "id");
|
||||
const list = normalizePeopleListInput(
|
||||
input.list,
|
||||
["key", "accountCount"],
|
||||
"key",
|
||||
);
|
||||
try {
|
||||
const result = await adapters.loadMultiAccounts(list);
|
||||
const rows = result.rows.map((row, index) => ({
|
||||
...row,
|
||||
id: index + 1,
|
||||
}));
|
||||
const page = createPeoplePage(
|
||||
rows,
|
||||
result.total,
|
||||
list,
|
||||
(row) => row.id,
|
||||
);
|
||||
return ok(
|
||||
{
|
||||
kind: "multi-accounts" as const,
|
||||
page: {
|
||||
...page,
|
||||
items: page.items.map(({ id: _id, ...row }) => row),
|
||||
} as Page<PeopleMultiAccountCluster>,
|
||||
page: createPeoplePage(result.rows, result.total, list),
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -206,18 +195,29 @@ export function createPeopleUsersQuery(
|
||||
correlationId,
|
||||
);
|
||||
}
|
||||
const sanctions = await adapters.loadSanctions(input.id);
|
||||
const [sanctions, watchedUserIds, permissionRanks] = await Promise.all([
|
||||
adapters.loadSanctions(input.id),
|
||||
adapters.loadWatchedUserIds(context.actor.id),
|
||||
adapters.loadPermissionRanks(),
|
||||
]);
|
||||
const rank = Number(row.rank);
|
||||
const rankName = permissionRanks.find((item) => item.id === rank)?.name;
|
||||
if (!rankName) throw new Error("People permission rank unavailable");
|
||||
const user = {
|
||||
...normalizePeopleUser(row, projection),
|
||||
motto: String(row.motto ?? ""),
|
||||
look: String(row.look ?? ""),
|
||||
accountCreated: toPeopleIsoDate(row.accountCreated),
|
||||
lastLogin: toPeopleIsoDate(row.lastLogin),
|
||||
sanctions,
|
||||
watched: watchedUserIds.has(input.id),
|
||||
permission: { rankName, ranks: permissionRanks },
|
||||
};
|
||||
assertPeopleSerializable(user);
|
||||
return ok(
|
||||
{
|
||||
kind: "user" as const,
|
||||
user: {
|
||||
...normalizePeopleUser(row, projection),
|
||||
motto: String(row.motto ?? ""),
|
||||
look: String(row.look ?? ""),
|
||||
accountCreated: toPeopleIsoDate(row.accountCreated),
|
||||
lastLogin: toPeopleIsoDate(row.lastLogin),
|
||||
sanctions,
|
||||
},
|
||||
user,
|
||||
},
|
||||
correlationId,
|
||||
);
|
||||
@@ -229,14 +229,15 @@ export function createPeopleUsersQuery(
|
||||
}
|
||||
|
||||
function resultRows<T>(result: unknown): T[] {
|
||||
if (!Array.isArray(result)) return [];
|
||||
if (!Array.isArray(result)) throw new Error("invalid People driver result");
|
||||
const rows = result[0];
|
||||
return Array.isArray(rows) ? (rows as T[]) : [];
|
||||
if (!Array.isArray(rows)) throw new Error("invalid People driver rows");
|
||||
return rows as T[];
|
||||
}
|
||||
|
||||
export const peopleUsersAdapters: PeopleUsersAdapters = {
|
||||
const peopleUsersAdapters: PeopleUsersAdapters = {
|
||||
async loadUsers(input, projection) {
|
||||
const [{ and, asc, count, desc, eq, like, or }, { Ban, db, User }] =
|
||||
const [{ and, asc, count, desc, eq, gt, like, or }, { Ban, db, User }] =
|
||||
await Promise.all([import("drizzle-orm"), import("@/lib/db")]);
|
||||
const searchConditions = input.search
|
||||
? [
|
||||
@@ -279,8 +280,9 @@ export const peopleUsersAdapters: PeopleUsersAdapters = {
|
||||
.select(selection)
|
||||
.from(User)
|
||||
.where(where)
|
||||
.orderBy(direction(sortColumn), asc(User.id))
|
||||
.limit(input.offset + input.pageSize),
|
||||
.orderBy(direction(sortColumn), direction(User.id))
|
||||
.limit(input.pageSize)
|
||||
.offset(input.offset),
|
||||
db.select({ total: count() }).from(User).where(where),
|
||||
]);
|
||||
const ids = (rows as unknown as PeopleUserRecord[])
|
||||
@@ -292,11 +294,24 @@ export const peopleUsersAdapters: PeopleUsersAdapters = {
|
||||
: await db
|
||||
.select({ userId: Ban.userId, banExpire: Ban.banExpire })
|
||||
.from(Ban)
|
||||
.where(or(...ids.map((id) => eq(Ban.userId, id))));
|
||||
.where(
|
||||
and(
|
||||
or(...ids.map((id) => eq(Ban.userId, id))),
|
||||
or(
|
||||
eq(Ban.banExpire, 0),
|
||||
gt(Ban.banExpire, Math.floor(Date.now() / 1000)),
|
||||
),
|
||||
),
|
||||
);
|
||||
const latestBan = new Map<number, number>();
|
||||
for (const ban of activeBans) {
|
||||
const current = latestBan.get(ban.userId) ?? 0;
|
||||
if (ban.banExpire === 0 || ban.banExpire > current) {
|
||||
const hasCurrent = latestBan.has(ban.userId);
|
||||
const current = latestBan.get(ban.userId);
|
||||
if (
|
||||
!hasCurrent ||
|
||||
(current !== 0 &&
|
||||
(ban.banExpire === 0 || ban.banExpire > (current ?? 0)))
|
||||
) {
|
||||
latestBan.set(ban.userId, ban.banExpire);
|
||||
}
|
||||
}
|
||||
@@ -309,7 +324,7 @@ export const peopleUsersAdapters: PeopleUsersAdapters = {
|
||||
};
|
||||
},
|
||||
async loadUser(id, projection) {
|
||||
const [{ desc, eq }, { Ban, db, User }] = await Promise.all([
|
||||
const [{ and, desc, eq, gt, or }, { Ban, db, User }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
import("@/lib/db"),
|
||||
]);
|
||||
@@ -332,15 +347,31 @@ export const peopleUsersAdapters: PeopleUsersAdapters = {
|
||||
.where(eq(User.id, id))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
const [ban] = await db
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const bans = await db
|
||||
.select({ banExpire: Ban.banExpire })
|
||||
.from(Ban)
|
||||
.where(eq(Ban.userId, id))
|
||||
.where(
|
||||
and(
|
||||
eq(Ban.userId, id),
|
||||
or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now)),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(Ban.timestamp), desc(Ban.id))
|
||||
.limit(1);
|
||||
.limit(100);
|
||||
let bannedUntil: number | null = null;
|
||||
for (const ban of bans) {
|
||||
if (ban.banExpire === 0) {
|
||||
bannedUntil = 0;
|
||||
break;
|
||||
}
|
||||
if (bannedUntil === null || ban.banExpire > bannedUntil) {
|
||||
bannedUntil = ban.banExpire;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...(row as unknown as PeopleUserDetailRecord),
|
||||
bannedUntil: ban?.banExpire ?? null,
|
||||
bannedUntil,
|
||||
};
|
||||
},
|
||||
async loadMultiAccounts(input) {
|
||||
@@ -348,49 +379,71 @@ export const peopleUsersAdapters: PeopleUsersAdapters = {
|
||||
import("drizzle-orm"),
|
||||
import("@/lib/db"),
|
||||
]);
|
||||
const groupResult = await db.execute(sql`
|
||||
SELECT ip_current AS ipCurrent, COUNT(*) AS accountCount
|
||||
FROM users
|
||||
WHERE ip_current <> ''
|
||||
GROUP BY ip_current
|
||||
HAVING COUNT(*) >= 2
|
||||
ORDER BY accountCount DESC, ip_current ASC
|
||||
const search = input.search
|
||||
? sql`AND ip_current LIKE ${`%${input.search}%`}`
|
||||
: sql``;
|
||||
const sortColumn =
|
||||
input.sort === "accountCount" ? sql`accountCount` : sql`ipCurrent`;
|
||||
const direction = input.order === "desc" ? sql`DESC` : sql`ASC`;
|
||||
const result = await db.execute(sql`
|
||||
WITH grouped AS (
|
||||
SELECT ip_current AS ipCurrent, COUNT(*) AS accountCount,
|
||||
COUNT(*) OVER () AS total
|
||||
FROM users
|
||||
WHERE ip_current <> '' ${search}
|
||||
GROUP BY ip_current
|
||||
HAVING COUNT(*) >= 2
|
||||
), paged AS (
|
||||
SELECT ipCurrent, accountCount, total
|
||||
FROM grouped
|
||||
ORDER BY ${sortColumn} ${direction}, ipCurrent ASC
|
||||
LIMIT ${input.pageSize} OFFSET ${input.offset}
|
||||
), ranked_accounts AS (
|
||||
SELECT u.id, u.username, u.rank, u.online, u.ip_current AS ipCurrent,
|
||||
ROW_NUMBER() OVER (PARTITION BY u.ip_current ORDER BY u.id ASC) AS accountPosition
|
||||
FROM users u
|
||||
INNER JOIN paged p ON p.ipCurrent = u.ip_current
|
||||
)
|
||||
SELECT p.ipCurrent, p.accountCount, p.total,
|
||||
a.id, a.username, a.rank, a.online
|
||||
FROM paged p
|
||||
INNER JOIN ranked_accounts a ON a.ipCurrent = p.ipCurrent
|
||||
WHERE a.accountPosition <= 100
|
||||
ORDER BY ${sortColumn} ${direction}, p.ipCurrent ASC, a.id ASC
|
||||
`);
|
||||
const groups = resultRows<{ ipCurrent: string; accountCount: number }>(
|
||||
groupResult,
|
||||
);
|
||||
const filtered = input.search
|
||||
? groups.filter((group) => group.ipCurrent.includes(input.search))
|
||||
: groups;
|
||||
const selected = filtered.slice(0, input.offset + input.pageSize);
|
||||
const rows = await Promise.all(
|
||||
selected.map(async (group) => {
|
||||
const usersResult = await db.execute(sql`
|
||||
SELECT id, username, rank, online
|
||||
FROM users
|
||||
WHERE ip_current = ${group.ipCurrent}
|
||||
ORDER BY id ASC
|
||||
`);
|
||||
const accounts = resultRows<{
|
||||
id: number;
|
||||
username: string;
|
||||
rank: number;
|
||||
online: string;
|
||||
}>(usersResult).map((user) => ({
|
||||
id: Number(user.id),
|
||||
username: user.username,
|
||||
rank: Number(user.rank),
|
||||
online: user.online === "1",
|
||||
href: `/ase/people/users/${Number(user.id)}` as const,
|
||||
}));
|
||||
return {
|
||||
key: group.ipCurrent,
|
||||
accountCount: Number(group.accountCount),
|
||||
accounts,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return { rows, total: filtered.length };
|
||||
const records = resultRows<{
|
||||
ipCurrent: string;
|
||||
accountCount: number | bigint;
|
||||
total: number | bigint;
|
||||
id: number;
|
||||
username: string;
|
||||
rank: number;
|
||||
online: string;
|
||||
}>(result);
|
||||
const clusters = new Map<string, PeopleMultiAccountCluster>();
|
||||
for (const record of records) {
|
||||
const existing = clusters.get(record.ipCurrent);
|
||||
const account = {
|
||||
id: Number(record.id),
|
||||
username: record.username,
|
||||
rank: Number(record.rank),
|
||||
online: record.online === "1",
|
||||
href: `/ase/people/users/${Number(record.id)}` as const,
|
||||
};
|
||||
if (existing) {
|
||||
(existing.accounts as (typeof account)[]).push(account);
|
||||
} else {
|
||||
clusters.set(record.ipCurrent, {
|
||||
key: record.ipCurrent,
|
||||
accountCount: Number(record.accountCount),
|
||||
accounts: [account],
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
rows: [...clusters.values()],
|
||||
total: Number(records[0]?.total ?? 0),
|
||||
};
|
||||
},
|
||||
async loadSanctions(userId) {
|
||||
const [{ desc, eq }, { Ban, db }] = await Promise.all([
|
||||
@@ -415,10 +468,31 @@ export const peopleUsersAdapters: PeopleUsersAdapters = {
|
||||
kind: row.type,
|
||||
reason: row.reason,
|
||||
createdAt: toPeopleIsoDate(row.createdAt),
|
||||
expiresAt: row.expiresAt === 0 ? null : toPeopleIsoDate(row.expiresAt),
|
||||
expiresAt: row.expiresAt === 0 ? 0 : toPeopleIsoDate(row.expiresAt),
|
||||
active: row.expiresAt === 0 || row.expiresAt > now,
|
||||
}));
|
||||
},
|
||||
async loadWatchedUserIds(staffId) {
|
||||
const { getWatchedUserIds } = await import("@/lib/services/watch");
|
||||
return getWatchedUserIds(staffId);
|
||||
},
|
||||
async loadPermissionRanks() {
|
||||
const [{ sql }, { db }] = await Promise.all([
|
||||
import("drizzle-orm"),
|
||||
import("@/lib/db"),
|
||||
]);
|
||||
const result = await db.execute(sql`
|
||||
SELECT id, rank_name AS name FROM permissions ORDER BY id ASC
|
||||
`);
|
||||
const rows = resultRows<{ id: number | bigint; name: string }>(result);
|
||||
return rows.map((row) => {
|
||||
const id = Number(row.id);
|
||||
if (!Number.isSafeInteger(id) || id <= 0 || !row.name.trim()) {
|
||||
throw new Error("invalid People permission rank");
|
||||
}
|
||||
return { id, name: row.name };
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const peopleUsersQuery = createPeopleUsersQuery(peopleUsersAdapters);
|
||||
Reference in new issue
Block a user