feat(housekeeping): model people workflows

This commit is contained in:
Simo committed 2026-08-29 01:17:51 +02:00
1 parent c325c53774
commit e3f8b51d31
13 files changed
+3736

No files matched your search

@@ -0,0 +1,133 @@
# Task 11 — People workflow read models
Status: DONE
## Delivered scope
- Added the exact 24-route People catalog covering the 39 migration-matrix entries across users, multi-account review, online/community, guilds, staff applications/teams, support tickets/help tickets, CFH, moderation overview, bans, IP rules, VPN settings, and word filter workflows.
- Added canonical, JSON-serializable People DTOs, `/ase/people` link builders, bounded list normalization, and stable sorting with numeric-ID tie breaking.
- Added injected query factories and narrow server-only production adapters for user/detail, community/guild, staff/applications/teams, support queues/tickets/help/CFH, and moderation/bans/sanctions sources.
- Reused foundation `HousekeepingResult`, error codes, capability context, authorization, and canonical href contracts. People-local `ListInput` and `Page` were added because no shared foundation equivalents exist in this checkout.
- Kept the People manifest, global handlers, pages, mutations, providers, widgets, search, and inbox unchanged for Task 12.
## Security and behavior decisions
- User mail and current IP remain independently nullable fields. Each is projected only when the capability context contains the existing `PERMS.USERS_VIEW`; `PERMS.MOD_USERS_VIEW` alone receives the safe base projection with both values set to `null`, and a context with neither permission is forbidden.
- No new ACL slug or rank threshold was introduced. Staff filtering reuses the existing `getMinStaffRank()` source.
- The production user selection is explicit and excludes passwords, authentication tickets, secrets, and two-factor material. VPN settings intentionally exclude `vpn_api_key`.
- Adapters fail closed. Missing detail entities map to `NOT_FOUND`; invalid identifiers to `VALIDATION`; adapter and count failures to `DEPENDENCY_UNAVAILABLE`. No partial-result shape is returned because no People DTO explicitly names failed sources.
- Pagination clamps page size to 100 and offset to 1,000,000. Production list adapters fetch the full prefix required for in-memory stable sorting/pagination, avoiding double-offset truncation.
## Strict TDD evidence
### Cycle 1 — exact route catalog
RED:
```text
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/routes.test.ts
Test Files 1 failed
Error: Cannot find module './routes'
```
GREEN:
```text
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/routes.test.ts
Test Files 1 passed (1)
Tests 3 passed (3)
```
### Cycle 2 — canonical models and normalizers
RED:
```text
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/models.test.ts
Test Files 1 failed
Error: Cannot find module './models'
```
GREEN:
```text
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/models.test.ts
Test Files 1 passed (1)
Tests 5 passed (5)
```
### Cycle 3 — injected-adapter read queries
RED:
```text
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-queries.test.ts
Test Files 1 failed
Error: Cannot find module './community'
```
GREEN:
```text
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-queries.test.ts
Test Files 1 passed (1)
Tests 10 passed (10)
```
Production-source contract RED:
```text
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts
Test Files 1 failed (1)
Tests 2 failed (2)
Reason: production adapters and buildPeopleUserSelection were not yet exported.
```
Pagination regression RED after adding the production contract fixture:
```text
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts
Test Files 1 failed (1)
Tests 1 failed | 2 passed (3)
Expected ["203.0.113.1", "203.0.113.2"], received ["203.0.113.2"].
```
GREEN after the minimal prefix-fetch correction:
```text
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts
Test Files 1 passed (1)
Tests 3 passed (3)
```
The query tests cover adversarial page size/offset/search, stable tie sorting, empty/missing entities, adapter and count failures, PII capability combinations, serializable DTOs, explicit source projection, and production pagination.
## Verification
```text
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people/routes.test.ts src/features/housekeeping/domains/people/models.test.ts src/features/housekeeping/domains/people/queries/people-queries.test.ts src/features/housekeeping/domains/people/queries/people-adapters-production.test.ts
Test Files 4 passed (4)
Tests 21 passed (21)
pnpm exec vitest run --coverage.enabled=false src/features/housekeeping/domains/people src/features/housekeeping/foundation/foundation-source-contract.test.ts src/features/housekeeping/foundation/authorization.test.ts src/features/housekeeping/foundation/capability-context.test.ts src/features/housekeeping/foundation/contracts/contracts.test.ts
Test Files 8 passed (8)
Tests 65 passed (65)
pnpm test:housekeeping
Test Files 49 passed (49)
Tests 416 passed (416)
pnpm typecheck
tsc --noEmit
Exit 0
pnpm exec biome check --formatter-enabled=false <12 exact Task 11 TypeScript files>
Checked 12 files. No fixes applied.
git diff --check
Exit 0
```
Both `pnpm test:housekeeping` and `pnpm typecheck` emitted the environment warning: the repository requires Node `>=26.8.1 <27`, while this host runs Node `v26.7.0` with pnpm `11.24.0`. Tests and typecheck still exited successfully.
No database operation, deployment, push, or pull-request update was performed.
@@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest";
import {
createPeoplePage,
normalizePeopleListInput,
normalizePeopleUser,
type PeopleUserDetail,
peopleCfhHref,
peopleGuildHref,
peopleHelpTicketHref,
peopleTicketHref,
peopleUserHref,
toPeopleIsoDate,
} from "./models";
describe("People list normalization", () => {
it("clamps adversarial page size and offset values and bounds search text", () => {
expect(
normalizePeopleListInput(
{
pageSize: 99_999,
offset: -50,
search: ` alice\u0000${"x".repeat(300)} `,
sort: "not-a-column",
order: "sideways",
},
["id", "username"],
"username",
),
).toEqual({
pageSize: 100,
offset: 0,
search: `alicex${"x".repeat(122)}`,
sort: "username",
order: "asc",
});
expect(
normalizePeopleListInput(
{ pageSize: Number.NaN, offset: Number.POSITIVE_INFINITY },
["id"],
"id",
),
).toMatchObject({ pageSize: 20, offset: 0 });
});
it("sorts a page deterministically with an id tie breaker", () => {
const input = normalizePeopleListInput(
{ pageSize: 2, offset: 1, sort: "username", order: "asc" },
["id", "username"],
"id",
);
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" },
],
total: 4,
pageSize: 2,
offset: 1,
});
});
});
describe("People canonical models", () => {
const rawUser = {
id: 7n,
username: "Alice",
rank: "5",
online: "1",
mail: "[email protected]",
ipCurrent: "203.0.113.9",
bannedUntil: "1700000000",
};
it("projects mail and current IP independently and never substitutes a redaction", () => {
expect(
normalizePeopleUser(rawUser, { includeMail: false, includeIp: false }),
).toEqual({
id: 7,
username: "Alice",
rank: 5,
online: true,
mail: null,
ipCurrent: null,
bannedUntil: 1_700_000_000,
href: "/ase/people/users/7",
});
expect(
normalizePeopleUser(rawUser, { includeMail: true, includeIp: false }),
).toMatchObject({ mail: "[email protected]", ipCurrent: null });
expect(
normalizePeopleUser(rawUser, { includeMail: false, includeIp: true }),
).toMatchObject({ mail: null, ipCurrent: "203.0.113.9" });
});
it("normalizes BigInt and Date values to JSON-safe DTO primitives", () => {
const detail: PeopleUserDetail = {
...normalizePeopleUser(rawUser, {
includeMail: true,
includeIp: true,
}),
motto: "Hello",
look: "hd-180-1",
accountCreated: toPeopleIsoDate(new Date("2026-08-29T10:20:30.000Z")),
lastLogin: toPeopleIsoDate(1_700_000_000),
sanctions: [],
};
expect(JSON.parse(JSON.stringify(detail))).toEqual({
id: 7,
username: "Alice",
rank: 5,
online: true,
mail: "[email protected]",
ipCurrent: "203.0.113.9",
bannedUntil: 1_700_000_000,
href: "/ase/people/users/7",
motto: "Hello",
look: "hd-180-1",
accountCreated: "2026-08-29T10:20:30.000Z",
lastLogin: "2023-11-14T22:13:20.000Z",
sanctions: [],
});
});
it("builds canonical People entity links only", () => {
expect([
peopleUserHref(4),
peopleGuildHref(5),
peopleTicketHref(6),
peopleHelpTicketHref(7),
peopleCfhHref(8),
]).toEqual([
"/ase/people/users/4",
"/ase/people/community/guilds/5",
"/ase/people/support/tickets/6",
"/ase/people/support/help-tickets/7",
"/ase/people/moderation/cfh/8",
]);
});
});
@@ -0,0 +1,408 @@
import type { HousekeepingResult } from "../../foundation/contracts";
export interface ListInput {
readonly search?: string | null;
readonly pageSize?: number | null;
readonly offset?: number | null;
readonly sort?: string | null;
readonly order?: string | null;
}
export interface NormalizedListInput {
readonly search: string;
readonly pageSize: number;
readonly offset: number;
readonly sort: string;
readonly order: "asc" | "desc";
}
export interface Page<T> {
readonly items: readonly T[];
readonly total: number;
readonly pageSize: number;
readonly offset: number;
}
export interface PeopleUserRecord {
readonly id: unknown;
readonly username: unknown;
readonly rank: unknown;
readonly online: unknown;
readonly mail?: unknown;
readonly ipCurrent?: unknown;
readonly bannedUntil?: unknown;
}
export interface PeopleUserSummary {
readonly id: number;
readonly username: string;
readonly rank: number;
readonly online: boolean;
readonly mail: string | null;
readonly ipCurrent: string | null;
readonly bannedUntil: number | null;
readonly href: `/ase/people/users/${number}`;
}
export interface PeopleSanctionSummary {
readonly id: number;
readonly kind: string;
readonly reason: string;
readonly createdAt: string | null;
readonly expiresAt: string | null;
readonly active: boolean;
}
export interface PeopleUserDetail extends PeopleUserSummary {
readonly motto: string;
readonly look: string;
readonly accountCreated: string | null;
readonly lastLogin: string | null;
readonly sanctions: readonly PeopleSanctionSummary[];
}
export interface PeopleMultiAccountCluster {
readonly key: string;
readonly accountCount: number;
readonly accounts: readonly Pick<
PeopleUserSummary,
"id" | "username" | "rank" | "online" | "href"
>[];
}
export interface PeopleOnlineUser {
readonly id: number;
readonly username: string;
readonly motto: string;
readonly look: string;
readonly href: `/ase/people/users/${number}`;
}
export interface PeopleGuildSummary {
readonly id: number;
readonly name: string;
readonly description: string;
readonly ownerId: number;
readonly ownerUsername: string | null;
readonly memberCount: number;
readonly createdAt: string | null;
readonly href: `/ase/people/community/guilds/${number}`;
}
export interface PeopleGuildDetail extends PeopleGuildSummary {
readonly roomId: number;
readonly threadCount: number;
readonly members: readonly {
readonly id: number;
readonly username: string;
readonly level: number;
readonly href: `/ase/people/users/${number}`;
}[];
}
export interface PeopleStaffApplication {
readonly id: number;
readonly userId: number;
readonly username: string | null;
readonly rankId: number;
readonly content: string;
readonly createdAt: string | null;
}
export interface PeopleTeam {
readonly id: number;
readonly name: string;
readonly rank: number;
readonly hidden: boolean;
readonly badge: string | null;
readonly jobDescription: string | null;
}
export interface PeopleModerationTeamMember {
readonly id: number;
readonly username: string;
readonly rank: number;
readonly openCfh: number;
readonly openTickets: number;
readonly actionCount: number;
readonly href: `/ase/people/users/${number}`;
}
export interface PeopleQueueSnapshot {
readonly tickets: number;
readonly helpTickets: number;
readonly cfh: number;
readonly activeBans: number;
}
export interface PeopleTicketSummary {
readonly id: number;
readonly subject: string;
readonly status: string;
readonly priority: string;
readonly creatorId: number;
readonly creatorUsername: string | null;
readonly updatedAt: string | null;
readonly href: `/ase/people/support/tickets/${number}`;
}
export interface PeopleTicketDetail extends PeopleTicketSummary {
readonly category: string;
readonly assigneeId: number | null;
readonly messages: readonly {
readonly id: number;
readonly userId: number;
readonly username: string | null;
readonly message: string;
readonly isStaff: boolean;
readonly createdAt: string | null;
}[];
}
export interface PeopleHelpTicketSummary {
readonly id: number;
readonly title: string;
readonly open: boolean;
readonly userId: number | null;
readonly username: string | null;
readonly updatedAt: string | null;
readonly href: `/ase/people/support/help-tickets/${number}`;
}
export interface PeopleHelpTicketDetail extends PeopleHelpTicketSummary {
readonly categoryId: number | null;
readonly categoryName: string | null;
readonly content: string;
readonly replies: readonly {
readonly id: number;
readonly userId: number;
readonly username: string | null;
readonly content: string;
readonly createdAt: string | null;
}[];
}
export interface PeopleTicketTemplate {
readonly id: number;
readonly title: string;
readonly content: string;
readonly category: string;
readonly sortOrder: number;
}
export interface PeopleCfhSummary {
readonly id: number;
readonly state: number;
readonly senderId: number;
readonly senderUsername: string | null;
readonly reportedId: number;
readonly reportedUsername: string | null;
readonly moderatorId: number;
readonly issue: string;
readonly createdAt: string | null;
readonly href: `/ase/people/moderation/cfh/${number}`;
}
export interface PeopleCfhDetail extends PeopleCfhSummary {
readonly roomId: number;
readonly activeBan: PeopleBanSummary | null;
}
export interface PeopleBanSummary {
readonly id: number;
readonly userId: number;
readonly username: string | null;
readonly staffId: number;
readonly staffUsername: string | null;
readonly type: string;
readonly reason: string;
readonly createdAt: string | null;
readonly expiresAt: string | null;
readonly active: boolean;
}
export interface PeopleModerationSnapshot extends PeopleQueueSnapshot {
readonly staffOnline: number;
readonly recentActions: number;
}
export interface PeopleIpRule {
readonly id: number;
readonly ip: string;
readonly note: string | null;
readonly createdAt: string | null;
}
export interface PeopleVpnSetting {
readonly key: string;
readonly value: string;
}
export interface PeopleWordFilterEntry {
readonly id: number;
readonly word: string;
readonly replacement: string | null;
}
export interface PeopleQueries {
users(input: ListInput): Promise<HousekeepingResult<Page<PeopleUserSummary>>>;
user(id: number): Promise<HousekeepingResult<PeopleUserDetail>>;
queue(): Promise<HousekeepingResult<PeopleQueueSnapshot>>;
}
const DEFAULT_PAGE_SIZE = 20;
const MAX_PAGE_SIZE = 100;
const MAX_OFFSET = 1_000_000;
const MAX_SEARCH_LENGTH = 128;
function boundedInteger(
value: number | null | undefined,
fallback: number,
minimum: number,
maximum: number,
): number {
if (!Number.isFinite(value)) return fallback;
return Math.min(Math.max(Math.trunc(value as number), minimum), maximum);
}
export function normalizePeopleListInput(
input: ListInput,
allowedSorts: readonly string[],
defaultSort: string,
): NormalizedListInput {
const safeDefault = allowedSorts.includes(defaultSort)
? defaultSort
: (allowedSorts[0] ?? "id");
const requestedSort = input.sort?.trim() ?? "";
const search = Array.from(input.search ?? "")
.filter((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint >= 32 && codePoint !== 127;
})
.join("")
.trim()
.slice(0, MAX_SEARCH_LENGTH);
return {
pageSize: boundedInteger(
input.pageSize,
DEFAULT_PAGE_SIZE,
1,
MAX_PAGE_SIZE,
),
offset: boundedInteger(input.offset, 0, 0, MAX_OFFSET),
search,
sort: allowedSorts.includes(requestedSort) ? requestedSort : safeDefault,
order: input.order === "desc" ? "desc" : "asc",
};
}
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 }>(
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);
return {
items,
total: boundedInteger(total, rows.length, 0, Number.MAX_SAFE_INTEGER),
pageSize: input.pageSize,
offset: input.offset,
};
}
export function toPeopleNumber(value: unknown, fallback = 0): number {
const parsed = typeof value === "bigint" ? Number(value) : Number(value);
return Number.isSafeInteger(parsed) ? parsed : fallback;
}
export function toPeopleIsoDate(value: unknown): string | null {
if (value === null || value === undefined || value === "") return null;
let date: Date;
if (value instanceof Date) {
date = value;
} else {
const numeric = Number(value);
date = Number.isFinite(numeric)
? new Date(
Math.abs(numeric) < 1_000_000_000_000 ? numeric * 1000 : numeric,
)
: new Date(String(value));
}
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
}
function nullableString(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim();
return normalized.length > 0 ? normalized : null;
}
export function peopleUserHref(id: number): `/ase/people/users/${number}` {
return `/ase/people/users/${id}`;
}
export function peopleGuildHref(
id: number,
): `/ase/people/community/guilds/${number}` {
return `/ase/people/community/guilds/${id}`;
}
export function peopleTicketHref(
id: number,
): `/ase/people/support/tickets/${number}` {
return `/ase/people/support/tickets/${id}`;
}
export function peopleHelpTicketHref(
id: number,
): `/ase/people/support/help-tickets/${number}` {
return `/ase/people/support/help-tickets/${id}`;
}
export function peopleCfhHref(
id: number,
): `/ase/people/moderation/cfh/${number}` {
return `/ase/people/moderation/cfh/${id}`;
}
export function normalizePeopleUser(
row: PeopleUserRecord,
projection: { readonly includeMail: boolean; readonly includeIp: boolean },
): PeopleUserSummary {
const id = toPeopleNumber(row.id);
const bannedUntil = toPeopleNumber(row.bannedUntil, 0);
return {
id,
username: String(row.username ?? ""),
rank: toPeopleNumber(row.rank),
online:
row.online === true ||
row.online === 1 ||
row.online === "1" ||
row.online === "true",
mail: projection.includeMail ? nullableString(row.mail) : null,
ipCurrent: projection.includeIp ? nullableString(row.ipCurrent) : null,
bannedUntil: bannedUntil > 0 ? bannedUntil : null,
href: peopleUserHref(id),
};
}
@@ -0,0 +1,289 @@
import "server-only";
import { PERMS } from "@/lib/permission-slugs";
import { authorizeHousekeeping } from "../../../foundation/authorization";
import {
anyCapability,
fail,
type HousekeepingQuery,
ok,
} from "../../../foundation/contracts";
import {
createPeoplePage,
type ListInput,
normalizePeopleListInput,
type Page,
type PeopleGuildDetail,
type PeopleGuildSummary,
type PeopleOnlineUser,
peopleGuildHref,
peopleUserHref,
toPeopleIsoDate,
} from "../models";
export interface PeopleCommunityAdapters {
loadOnline(input: ReturnType<typeof normalizePeopleListInput>): Promise<{
readonly rows: readonly PeopleOnlineUser[];
readonly total: number;
}>;
loadGuilds(input: ReturnType<typeof normalizePeopleListInput>): Promise<{
readonly rows: readonly PeopleGuildSummary[];
readonly total: number;
}>;
loadGuild(id: number): Promise<PeopleGuildDetail | null>;
}
export type PeopleCommunityQueryInput =
| { readonly routeId: "people.community.online"; readonly list: ListInput }
| { readonly routeId: "people.community.guilds"; readonly list: ListInput }
| { readonly routeId: "people.community.guild-detail"; readonly id: number };
export type PeopleCommunityQueryData =
| { readonly kind: "online"; readonly page: Page<PeopleOnlineUser> }
| { readonly kind: "guilds"; readonly page: Page<PeopleGuildSummary> }
| { readonly kind: "guild"; readonly guild: PeopleGuildDetail };
function unavailable(correlationId: string) {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
correlationId,
);
}
export function createPeopleCommunityQuery(
adapters: PeopleCommunityAdapters,
): HousekeepingQuery<PeopleCommunityQueryInput, PeopleCommunityQueryData> {
return {
id: "people.community.query",
owner: "people",
capability: anyCapability(PERMS.USERS_VIEW),
async run(context, input) {
const authorization = authorizeHousekeeping(
context,
anyCapability(PERMS.USERS_VIEW),
);
if (!authorization.ok) return authorization;
const correlationId = authorization.correlationId;
if (input.routeId === "people.community.guild-detail") {
if (!Number.isSafeInteger(input.id) || input.id <= 0) {
return fail(
"VALIDATION",
"errors.housekeeping.validation",
correlationId,
{ id: ["invalid"] },
);
}
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);
} catch {
return unavailable(correlationId);
}
}
const list = normalizePeopleListInput(
input.list,
["id", "username", "name"],
"id",
);
try {
if (input.routeId === "people.community.online") {
const result = await adapters.loadOnline(list);
return ok(
{
kind: "online" as const,
page: createPeoplePage(result.rows, result.total, list, (row) =>
list.sort === "username" ? row.username : row.id,
),
},
correlationId,
);
}
const result = await adapters.loadGuilds(list);
return ok(
{
kind: "guilds" as const,
page: createPeoplePage(result.rows, result.total, list, (row) =>
list.sort === "name" ? row.name : row.id,
),
},
correlationId,
);
} catch {
return unavailable(correlationId);
}
},
};
}
function resultRows<T>(result: unknown): T[] {
if (!Array.isArray(result)) return [];
return Array.isArray(result[0]) ? (result[0] as T[]) : [];
}
export const peopleCommunityAdapters: PeopleCommunityAdapters = {
async loadOnline(input) {
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const pattern = `%${input.search}%`;
const where = input.search
? sql`online = '1' AND (username LIKE ${pattern} OR motto LIKE ${pattern})`
: sql`online = '1'`;
const [rowsResult, countResult] = await Promise.all([
db.execute(sql`
SELECT id, username, motto, look
FROM users
WHERE ${where}
ORDER BY ${input.sort === "username" ? sql`username` : sql`id`} ${
input.order === "desc" ? sql`DESC` : sql`ASC`
}, id ASC
LIMIT ${input.offset + input.pageSize}
`),
db.execute(sql`SELECT COUNT(*) AS total FROM users WHERE ${where}`),
]);
const rows = resultRows<{
id: number;
username: string;
motto: string;
look: string;
}>(rowsResult).map((row) => ({
id: Number(row.id),
username: row.username,
motto: row.motto,
look: row.look,
href: peopleUserHref(Number(row.id)),
}));
const total = Number(
resultRows<{ total: number }>(countResult)[0]?.total ?? 0,
);
return { rows, total };
},
async loadGuilds(input) {
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const pattern = `%${input.search}%`;
const where = input.search
? sql`WHERE g.name LIKE ${pattern} OR g.description LIKE ${pattern} OR u.username LIKE ${pattern}`
: sql``;
const [rowsResult, countResult] = await Promise.all([
db.execute(sql`
SELECT g.id, g.name, g.description, g.user_id AS ownerId,
u.username AS ownerUsername, g.date_created AS createdAt,
COUNT(gm.id) AS memberCount
FROM guilds g
LEFT JOIN users u ON u.id = g.user_id
LEFT JOIN guilds_members gm ON gm.guild_id = g.id
${where}
GROUP BY g.id, g.name, g.description, g.user_id, u.username, g.date_created
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}
`),
db.execute(sql`
SELECT COUNT(*) AS total
FROM guilds g LEFT JOIN users u ON u.id = g.user_id
${where}
`),
]);
const rows = resultRows<{
id: number;
name: string;
description: string;
ownerId: number;
ownerUsername: string | null;
memberCount: number;
createdAt: number;
}>(rowsResult).map((row) => ({
id: Number(row.id),
name: row.name,
description: row.description,
ownerId: Number(row.ownerId),
ownerUsername: row.ownerUsername,
memberCount: Number(row.memberCount),
createdAt: toPeopleIsoDate(row.createdAt),
href: peopleGuildHref(Number(row.id)),
}));
return {
rows,
total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0),
};
},
async loadGuild(id) {
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const [guildResult, membersResult, threadsResult] = await Promise.all([
db.execute(sql`
SELECT g.id, g.name, g.description, g.user_id AS ownerId,
u.username AS ownerUsername, g.room_id AS roomId,
g.date_created AS createdAt, COUNT(gm.id) AS memberCount
FROM guilds g
LEFT JOIN users u ON u.id = g.user_id
LEFT JOIN guilds_members gm ON gm.guild_id = g.id
WHERE g.id = ${id}
GROUP BY g.id, g.name, g.description, g.user_id, u.username, g.room_id, g.date_created
LIMIT 1
`),
db.execute(sql`
SELECT u.id, u.username, gm.level_id AS level
FROM guilds_members gm
JOIN users u ON u.id = gm.user_id
WHERE gm.guild_id = ${id}
ORDER BY gm.level_id DESC, u.username ASC, u.id ASC
`),
db.execute(sql`
SELECT COUNT(*) AS total FROM guilds_forums_threads WHERE guild_id = ${id}
`),
]);
const row = resultRows<{
id: number;
name: string;
description: string;
ownerId: number;
ownerUsername: string | null;
roomId: number;
createdAt: number;
memberCount: number;
}>(guildResult)[0];
if (!row) return null;
return {
id: Number(row.id),
name: row.name,
description: row.description,
ownerId: Number(row.ownerId),
ownerUsername: row.ownerUsername,
memberCount: Number(row.memberCount),
createdAt: toPeopleIsoDate(row.createdAt),
href: peopleGuildHref(Number(row.id)),
roomId: Number(row.roomId),
threadCount: Number(
resultRows<{ total: number }>(threadsResult)[0]?.total ?? 0,
),
members: resultRows<{
id: number;
username: string;
level: number;
}>(membersResult).map((member) => ({
id: Number(member.id),
username: member.username,
level: Number(member.level),
href: peopleUserHref(Number(member.id)),
})),
};
},
};
export const peopleCommunityQuery = createPeopleCommunityQuery(
peopleCommunityAdapters,
);
@@ -0,0 +1,533 @@
import "server-only";
import { PERMS } from "@/lib/permission-slugs";
import { authorizeHousekeeping } from "../../../foundation/authorization";
import {
anyCapability,
fail,
type HousekeepingQuery,
ok,
} from "../../../foundation/contracts";
import {
createPeoplePage,
type ListInput,
normalizePeopleListInput,
type Page,
type PeopleBanSummary,
type PeopleCfhDetail,
type PeopleCfhSummary,
type PeopleIpRule,
type PeopleModerationSnapshot,
type PeopleVpnSetting,
type PeopleWordFilterEntry,
peopleCfhHref,
toPeopleIsoDate,
} from "../models";
interface ModerationRows<T> {
readonly rows: readonly T[];
readonly total: number;
}
export interface PeopleModerationAdapters {
loadOverview(): Promise<PeopleModerationSnapshot>;
loadCfh(
input: ReturnType<typeof normalizePeopleListInput>,
): Promise<ModerationRows<PeopleCfhSummary>>;
loadCfhDetail(id: number): Promise<PeopleCfhDetail | null>;
loadBans(
input: ReturnType<typeof normalizePeopleListInput>,
): Promise<ModerationRows<PeopleBanSummary>>;
loadIpRules(): Promise<{
readonly blacklist: readonly PeopleIpRule[];
readonly whitelist: readonly PeopleIpRule[];
}>;
loadVpnSettings(): Promise<readonly PeopleVpnSetting[]>;
loadWordFilter(
input: ReturnType<typeof normalizePeopleListInput>,
): Promise<ModerationRows<PeopleWordFilterEntry>>;
}
export type PeopleModerationQueryInput =
| { readonly routeId: "people.moderation.overview" }
| { readonly routeId: "people.moderation.cfh"; readonly list: ListInput }
| { readonly routeId: "people.moderation.cfh-detail"; readonly id: number }
| { readonly routeId: "people.moderation.bans"; readonly list: ListInput }
| { readonly routeId: "people.moderation.ip" }
| { readonly routeId: "people.moderation.vpn" }
| {
readonly routeId: "people.moderation.word-filter";
readonly list: ListInput;
};
export type PeopleModerationQueryData =
| { readonly kind: "overview"; readonly snapshot: PeopleModerationSnapshot }
| { readonly kind: "cfh"; readonly page: Page<PeopleCfhSummary> }
| { readonly kind: "cfh-detail"; readonly ticket: PeopleCfhDetail }
| { readonly kind: "bans"; readonly page: Page<PeopleBanSummary> }
| {
readonly kind: "ip-rules";
readonly blacklist: readonly PeopleIpRule[];
readonly whitelist: readonly PeopleIpRule[];
}
| { readonly kind: "vpn"; readonly settings: readonly PeopleVpnSetting[] }
| {
readonly kind: "word-filter";
readonly page: Page<PeopleWordFilterEntry>;
};
const broadCapability = anyCapability(
PERMS.MODERATION_VIEW,
PERMS.MOD_CFH_VIEW,
PERMS.BANS_VIEW,
PERMS.MOD_BANS_VIEW,
PERMS.SETTINGS_VIEW,
PERMS.WORDFILTER_VIEW,
);
function routeCapability(routeId: PeopleModerationQueryInput["routeId"]) {
if (
routeId === "people.moderation.cfh" ||
routeId === "people.moderation.cfh-detail"
) {
return anyCapability(PERMS.MODERATION_VIEW, PERMS.MOD_CFH_VIEW);
}
if (routeId === "people.moderation.bans") {
return anyCapability(PERMS.BANS_VIEW, PERMS.MOD_BANS_VIEW);
}
if (
routeId === "people.moderation.ip" ||
routeId === "people.moderation.vpn"
) {
return anyCapability(PERMS.SETTINGS_VIEW);
}
if (routeId === "people.moderation.word-filter") {
return anyCapability(PERMS.WORDFILTER_VIEW);
}
return anyCapability(
PERMS.MODERATION_VIEW,
PERMS.MOD_CFH_VIEW,
PERMS.MOD_ACTIONS,
PERMS.MODERATION_EDIT,
PERMS.MOD_BANS_VIEW,
PERMS.BANS_VIEW,
PERMS.MOD_TICKETS_VIEW,
PERMS.TICKETS_VIEW,
PERMS.MOD_TEAM_VIEW,
PERMS.MOD_USERS_VIEW,
PERMS.USERS_VIEW,
);
}
export function createPeopleModerationQuery(
adapters: PeopleModerationAdapters,
): HousekeepingQuery<PeopleModerationQueryInput, PeopleModerationQueryData> {
return {
id: "people.moderation.query",
owner: "people",
capability: broadCapability,
async run(context, input) {
const authorization = authorizeHousekeeping(
context,
routeCapability(input.routeId),
);
if (!authorization.ok) return authorization;
const correlationId = authorization.correlationId;
try {
if (input.routeId === "people.moderation.overview") {
return ok(
{
kind: "overview" as const,
snapshot: await adapters.loadOverview(),
},
correlationId,
);
}
if (input.routeId === "people.moderation.cfh-detail") {
if (!Number.isSafeInteger(input.id) || input.id <= 0) {
return fail(
"VALIDATION",
"errors.housekeeping.validation",
correlationId,
{ id: ["invalid"] },
);
}
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 (input.routeId === "people.moderation.ip") {
const rules = await adapters.loadIpRules();
return ok({ kind: "ip-rules" as const, ...rules }, correlationId);
}
if (input.routeId === "people.moderation.vpn") {
return ok(
{
kind: "vpn" as const,
settings: await adapters.loadVpnSettings(),
},
correlationId,
);
}
const list = normalizePeopleListInput(
input.list,
["id", "username", "createdAt", "word"],
"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,
),
},
correlationId,
);
}
if (input.routeId === "people.moderation.bans") {
const result = await adapters.loadBans(list);
return ok(
{
kind: "bans" as const,
page: createPeoplePage(result.rows, result.total, list, (row) =>
list.sort === "username" ? (row.username ?? "") : row.id,
),
},
correlationId,
);
}
const result = await adapters.loadWordFilter(list);
return ok(
{
kind: "word-filter" as const,
page: createPeoplePage(result.rows, result.total, list, (row) =>
list.sort === "word" ? row.word : row.id,
),
},
correlationId,
);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
correlationId,
);
}
},
};
}
function resultRows<T>(result: unknown): T[] {
if (!Array.isArray(result)) return [];
return Array.isArray(result[0]) ? (result[0] as T[]) : [];
}
export const peopleModerationAdapters: PeopleModerationAdapters = {
async loadOverview() {
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
(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,
(SELECT COUNT(*) FROM users WHERE online = '1' AND rank >= ${minStaffRank}) AS staffOnline,
(SELECT COUNT(*) FROM admin_audit_log WHERE action LIKE 'mod_%') AS recentActions
`);
const row = resultRows<PeopleModerationSnapshot>(result)[0];
if (!row) throw new Error("moderation overview unavailable");
return {
tickets: Number(row.tickets),
helpTickets: Number(row.helpTickets),
cfh: Number(row.cfh),
activeBans: Number(row.activeBans),
staffOnline: Number(row.staffOnline),
recentActions: Number(row.recentActions),
};
},
async loadCfh(input) {
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const pattern = `%${input.search}%`;
const where = input.search
? sql`WHERE c.issue LIKE ${pattern} OR sender.username LIKE ${pattern} OR reported.username LIKE ${pattern} OR moderator.username LIKE ${pattern}`
: sql``;
const [rowsResult, countResult] = await Promise.all([
db.execute(sql`
SELECT c.id, c.state, c.sender_id AS senderId, sender.username AS senderUsername,
c.reported_id AS reportedId, reported.username AS reportedUsername,
c.mod_id AS moderatorId, c.issue, c.timestamp AS createdAt
FROM support_tickets c
LEFT JOIN users sender ON sender.id = c.sender_id
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`} ${
input.order === "asc" ? sql`ASC` : sql`DESC`
}, c.id ASC
LIMIT ${input.offset + input.pageSize}
`),
db.execute(sql`
SELECT COUNT(*) AS total FROM support_tickets c
LEFT JOIN users sender ON sender.id = c.sender_id
LEFT JOIN users reported ON reported.id = c.reported_id
LEFT JOIN users moderator ON moderator.id = c.mod_id ${where}
`),
]);
const rows = resultRows<{
id: number;
state: number;
senderId: number;
senderUsername: string | null;
reportedId: number;
reportedUsername: string | null;
moderatorId: number;
issue: string;
createdAt: number;
}>(rowsResult).map((row) => ({
id: Number(row.id),
state: Number(row.state),
senderId: Number(row.senderId),
senderUsername: row.senderUsername,
reportedId: Number(row.reportedId),
reportedUsername: row.reportedUsername,
moderatorId: Number(row.moderatorId),
issue: row.issue,
createdAt: toPeopleIsoDate(row.createdAt),
href: peopleCfhHref(Number(row.id)),
}));
return {
rows,
total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0),
};
},
async loadCfhDetail(id) {
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const result = await db.execute(sql`
SELECT c.id, c.state, c.sender_id AS senderId, sender.username AS senderUsername,
c.reported_id AS reportedId, reported.username AS reportedUsername,
c.mod_id AS moderatorId, c.issue, c.room_id AS roomId,
c.timestamp AS createdAt, b.id AS banId, b.user_id AS banUserId,
b.user_staff_id AS banStaffId, b.type AS banType, b.ban_reason AS banReason,
b.timestamp AS banCreatedAt, b.ban_expire AS banExpiresAt
FROM support_tickets c
LEFT JOIN users sender ON sender.id = c.sender_id
LEFT JOIN users reported ON reported.id = c.reported_id
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
LIMIT 1
`);
const row = resultRows<{
id: number;
state: number;
senderId: number;
senderUsername: string | null;
reportedId: number;
reportedUsername: string | null;
moderatorId: number;
issue: string;
roomId: number;
createdAt: number;
banId: number | null;
banUserId: number | null;
banStaffId: number | null;
banType: string | null;
banReason: string | null;
banCreatedAt: number | null;
banExpiresAt: number | null;
}>(result)[0];
if (!row) return null;
return {
id: Number(row.id),
state: Number(row.state),
senderId: Number(row.senderId),
senderUsername: row.senderUsername,
reportedId: Number(row.reportedId),
reportedUsername: row.reportedUsername,
moderatorId: Number(row.moderatorId),
issue: row.issue,
roomId: Number(row.roomId),
createdAt: toPeopleIsoDate(row.createdAt),
href: peopleCfhHref(Number(row.id)),
activeBan:
row.banId === null
? null
: {
id: Number(row.banId),
userId: Number(row.banUserId),
username: row.reportedUsername,
staffId: Number(row.banStaffId),
staffUsername: null,
type: row.banType ?? "account",
reason: row.banReason ?? "",
createdAt: toPeopleIsoDate(row.banCreatedAt),
expiresAt:
row.banExpiresAt === 0
? null
: toPeopleIsoDate(row.banExpiresAt),
active: true,
},
};
},
async loadBans(input) {
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const pattern = `%${input.search}%`;
const search = input.search
? sql`AND (u.username LIKE ${pattern} OR staff.username LIKE ${pattern} OR b.ban_reason LIKE ${pattern})`
: sql``;
const [rowsResult, countResult] = await Promise.all([
db.execute(sql`
SELECT b.id, b.user_id AS userId, u.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 u ON u.id = b.user_id
LEFT JOIN users staff ON staff.id = b.user_staff_id
WHERE (b.ban_expire = 0 OR b.ban_expire > UNIX_TIMESTAMP()) ${search}
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}
`),
db.execute(sql`
SELECT COUNT(*) AS total FROM bans b
LEFT JOIN users u ON u.id = b.user_id
LEFT JOIN users staff ON staff.id = b.user_staff_id
WHERE (b.ban_expire = 0 OR b.ban_expire > UNIX_TIMESTAMP()) ${search}
`),
]);
const rows = resultRows<{
id: number;
userId: number;
username: string | null;
staffId: number;
staffUsername: string | null;
type: string;
reason: string;
createdAt: number;
expiresAt: number;
}>(rowsResult).map((row) => ({
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: row.expiresAt === 0 ? null : toPeopleIsoDate(row.expiresAt),
active: true,
}));
return {
rows,
total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0),
};
},
async loadIpRules() {
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const [blacklistResult, whitelistResult] = await Promise.all([
db.execute(sql`
SELECT id, ip_address AS ip, asn AS note, created_at AS createdAt
FROM website_ip_blacklist ORDER BY id DESC LIMIT 200
`),
db.execute(sql`
SELECT id, ip_address AS ip, asn AS note, created_at AS createdAt
FROM website_ip_whitelist ORDER BY id DESC LIMIT 200
`),
]);
const mapRows = (result: unknown): PeopleIpRule[] =>
resultRows<{
id: bigint | number;
ip: string;
note: string | null;
createdAt: Date | string | null;
}>(result).map((row) => ({
id: Number(row.id),
ip: row.ip,
note: row.note,
createdAt: toPeopleIsoDate(row.createdAt),
}));
return {
blacklist: mapRows(blacklistResult),
whitelist: mapRows(whitelistResult),
};
},
async loadVpnSettings() {
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const result = await db.execute(sql`
SELECT s.key AS settingKey, s.value FROM website_settings s
WHERE s.key IN ('vpn_block_enabled', 'vpn_provider', 'vpn_block_message')
ORDER BY s.key ASC
`);
return resultRows<{ settingKey: string; value: string }>(result).map(
(row) => ({
key: row.settingKey,
value: row.value,
}),
);
},
async loadWordFilter(input) {
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const pattern = `%${input.search}%`;
const where = input.search ? sql`WHERE word LIKE ${pattern}` : sql``;
const [rowsResult, countResult] = await Promise.all([
db.execute(sql`
SELECT id, word FROM website_wordfilter ${where}
ORDER BY ${input.sort === "word" ? sql`word` : sql`id`} ${
input.order === "desc" ? sql`DESC` : sql`ASC`
}, id ASC
LIMIT ${input.offset + input.pageSize}
`),
db.execute(
sql`SELECT COUNT(*) AS total FROM website_wordfilter ${where}`,
),
]);
const rows = resultRows<{ id: bigint | number; word: string }>(
rowsResult,
).map((row) => ({
id: Number(row.id),
word: row.word,
replacement: null,
}));
return {
rows,
total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0),
};
},
};
export const peopleModerationQuery = createPeopleModerationQuery(
peopleModerationAdapters,
);
@@ -0,0 +1,148 @@
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";
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",
]);
});
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",
};
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("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" },
],
]);
const sql = (strings: TemplateStringsArray, ...values: unknown[]) => ({
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",
});
expect(result.rows.map((row) => row.key)).toEqual([
"203.0.113.1",
"203.0.113.2",
]);
expect(result.total).toBe(3);
});
});
@@ -0,0 +1,492 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCapabilityContext } from "../../../foundation/contracts";
import { createPeopleCommunityQuery } from "./community";
import { createPeopleModerationQuery } from "./moderation";
import { createPeopleStaffQuery } from "./staff";
import { createPeopleSupportQuery } from "./support";
import { createPeopleUsersQuery } from "./users";
function context(granted: readonly string[]): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
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 rawUser = (id: number, username: string) => ({
id,
username,
rank: 5,
online: "1",
mail: `${username.toLowerCase()}@example.test`,
ipCurrent: `203.0.113.${id}`,
bannedUntil: null,
});
describe("People users query", () => {
it("normalizes list input, sorts stable ties, and projects PII for admin users view", async () => {
const loadUsers = vi.fn(async () => ({
rows: [rawUser(3, "alice"), rawUser(1, "Alice"), rawUser(2, "alice")],
total: 3,
}));
const query = createPeopleUsersQuery({
loadUsers,
loadUser: async () => null,
loadMultiAccounts: async () => ({ rows: [], total: 0 }),
loadSanctions: async () => [],
});
const result = await query.run(context([PERMS.USERS_VIEW]), {
routeId: "people.users.list",
list: {
pageSize: 5_000,
offset: -9,
search: ` ali\u0000${"x".repeat(200)} `,
sort: "username",
order: "asc",
},
});
expect(loadUsers).toHaveBeenCalledWith(
expect.objectContaining({
pageSize: 100,
offset: 0,
search: `alix${"x".repeat(124)}`,
sort: "username",
}),
{ includeMail: true, includeIp: true },
);
expect(result).toMatchObject({
ok: true,
data: {
kind: "users",
page: {
items: [
{
id: 1,
mail: "[email protected]",
ipCurrent: "203.0.113.1",
href: "/ase/people/users/1",
},
{ id: 2 },
{ id: 3 },
],
total: 3,
pageSize: 100,
offset: 0,
},
},
});
});
it("returns a base-only projection to mod users and fails closed for neither permission", async () => {
const loadUsers = vi.fn(async () => ({
rows: [rawUser(1, "Alice")],
total: 1,
}));
const query = createPeopleUsersQuery({
loadUsers,
loadUser: async () => null,
loadMultiAccounts: async () => ({ rows: [], total: 0 }),
loadSanctions: async () => [],
});
const moderator = await query.run(context([PERMS.MOD_USERS_VIEW]), {
routeId: "people.users.list",
list: {},
});
expect(loadUsers).toHaveBeenLastCalledWith(expect.anything(), {
includeMail: false,
includeIp: false,
});
expect(moderator).toMatchObject({
ok: true,
data: {
page: { items: [{ mail: null, ipCurrent: null }] },
},
});
loadUsers.mockClear();
const forbidden = await query.run(context([]), {
routeId: "people.users.list",
list: {},
});
expect(forbidden).toMatchObject({
ok: false,
error: { code: "FORBIDDEN" },
});
expect(loadUsers).not.toHaveBeenCalled();
});
it("maps missing users, invalid ids, and adapter failures to stable errors", async () => {
const loadUser = vi.fn(async () => null);
const query = createPeopleUsersQuery({
loadUsers: async () => {
throw new Error("count unavailable");
},
loadUser,
loadMultiAccounts: async () => ({ rows: [], total: 0 }),
loadSanctions: async () => [],
});
const invalid = await query.run(context([PERMS.USERS_VIEW]), {
routeId: "people.users.detail",
id: -1,
});
expect(invalid).toMatchObject({
ok: false,
error: { code: "VALIDATION", fieldErrors: { id: ["invalid"] } },
});
expect(loadUser).not.toHaveBeenCalled();
const missing = await query.run(context([PERMS.USERS_VIEW]), {
routeId: "people.users.detail",
id: 404,
});
expect(missing).toMatchObject({
ok: false,
error: { code: "NOT_FOUND" },
});
const unavailable = await query.run(context([PERMS.USERS_VIEW]), {
routeId: "people.users.list",
list: {},
});
expect(unavailable).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
});
it("returns JSON-safe user details with sanctions and canonical links", async () => {
const query = createPeopleUsersQuery({
loadUsers: async () => ({ rows: [], total: 0 }),
loadUser: async () => ({
...rawUser(9, "Nine"),
motto: "Hello",
look: "hd-180-1",
accountCreated: 1_700_000_000,
lastLogin: new Date("2026-08-29T09:00:00.000Z"),
}),
loadMultiAccounts: async () => ({ rows: [], total: 0 }),
loadSanctions: async () => [
{
id: 8,
kind: "account",
reason: "test",
createdAt: "2026-08-20T00:00:00.000Z",
expiresAt: null,
active: true,
},
],
});
const result = await query.run(context([PERMS.USERS_VIEW]), {
routeId: "people.users.detail",
id: 9,
});
expect(result).toMatchObject({
ok: true,
data: {
kind: "user",
user: {
id: 9,
href: "/ase/people/users/9",
accountCreated: "2023-11-14T22:13:20.000Z",
lastLogin: "2026-08-29T09:00:00.000Z",
sanctions: [{ id: 8 }],
},
},
});
expect(() => JSON.stringify(result)).not.toThrow();
});
});
describe("People community and staff queries", () => {
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 () => ({
rows: [
{
id: 2,
name: "Builders",
description: "Build",
ownerId: 4,
ownerUsername: "Owner",
memberCount: 3,
createdAt: "2026-08-01T00:00:00.000Z",
href: "/ase/people/community/guilds/2" as const,
},
],
total: 1,
}));
const loadGuild = vi.fn(async (id: number) =>
id === 2
? {
id: 2,
name: "Builders",
description: "Build",
ownerId: 4,
ownerUsername: "Owner",
memberCount: 3,
createdAt: "2026-08-01T00:00:00.000Z",
href: "/ase/people/community/guilds/2" as const,
roomId: 7,
threadCount: 1,
members: [],
}
: null,
);
const query = createPeopleCommunityQuery({
loadOnline,
loadGuilds,
loadGuild,
});
expect(
await query.run(context([PERMS.USERS_VIEW]), {
routeId: "people.community.online",
list: {},
}),
).toMatchObject({ ok: true, data: { kind: "online" } });
expect(
await query.run(context([PERMS.USERS_VIEW]), {
routeId: "people.community.guilds",
list: {},
}),
).toMatchObject({
ok: true,
data: { kind: "guilds", page: { items: [{ id: 2 }] } },
});
expect(
await query.run(context([PERMS.USERS_VIEW]), {
routeId: "people.community.guild-detail",
id: 404,
}),
).toMatchObject({ ok: false, error: { code: "NOT_FOUND" } });
});
it("loads applications, teams, and moderation-team data with serializable DTOs", async () => {
const query = createPeopleStaffQuery({
loadApplications: async () => ({
rows: [
{
id: 11,
userId: 5,
username: "Applicant",
rankId: 3,
content: "Why me",
createdAt: "2026-08-29T00:00:00.000Z",
},
],
total: 1,
}),
loadTeams: async () => ({ rows: [], total: 0 }),
loadModerationTeam: async () => ({ rows: [], total: 0 }),
});
const applications = await query.run(context([PERMS.USERS_VIEW]), {
routeId: "people.staff.applications",
list: {},
});
const teams = await query.run(context([PERMS.USERS_VIEW]), {
routeId: "people.staff.teams",
list: {},
});
const moderationTeam = await query.run(context([PERMS.MOD_TEAM_VIEW]), {
routeId: "people.staff.moderation-team",
list: {},
});
expect(applications).toMatchObject({
ok: true,
data: { kind: "applications", page: { items: [{ id: 11 }] } },
});
expect(teams).toMatchObject({ ok: true, data: { kind: "teams" } });
expect(moderationTeam).toMatchObject({
ok: true,
data: { kind: "moderation-team" },
});
expect(() => JSON.stringify(applications)).not.toThrow();
});
});
describe("People support query", () => {
it("returns the four-source queue snapshot and fails closed when a count fails", async () => {
const loadQueue = vi
.fn()
.mockResolvedValueOnce({
tickets: 2,
helpTickets: 3,
cfh: 4,
activeBans: 5,
})
.mockRejectedValueOnce(new Error("count failed"));
const query = createPeopleSupportQuery({
loadQueue,
loadTickets: async () => ({ rows: [], total: 0 }),
loadTicket: async () => null,
loadTemplates: async () => ({ rows: [], total: 0 }),
loadHelpTickets: async () => ({ rows: [], total: 0 }),
loadHelpTicket: async () => null,
});
expect(
await query.run(context([PERMS.TICKETS_VIEW]), {
routeId: "people.support.queue",
}),
).toMatchObject({
ok: true,
data: {
kind: "queue",
queue: { tickets: 2, helpTickets: 3, cfh: 4, activeBans: 5 },
},
});
expect(
await query.run(context([PERMS.TICKETS_VIEW]), {
routeId: "people.support.queue",
}),
).toMatchObject({
ok: false,
error: { code: "DEPENDENCY_UNAVAILABLE" },
});
});
it("covers ticket desk/templates/detail and help-ticket list/detail reads", async () => {
const query = createPeopleSupportQuery({
loadQueue: async () => ({
tickets: 0,
helpTickets: 0,
cfh: 0,
activeBans: 0,
}),
loadTickets: async () => ({ rows: [], total: 0 }),
loadTicket: async () => null,
loadTemplates: async () => ({ rows: [], total: 0 }),
loadHelpTickets: async () => ({ rows: [], total: 0 }),
loadHelpTicket: async () => null,
});
for (const routeId of [
"people.support.tickets",
"people.support.ticket-desk",
"people.support.help-tickets",
] as const) {
expect(
await query.run(context([PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW]), {
routeId,
list: {},
}),
).toMatchObject({ ok: true });
}
expect(
await query.run(context([PERMS.TICKETS_EDIT]), {
routeId: "people.support.ticket-templates",
list: {},
}),
).toMatchObject({ ok: true, data: { kind: "ticket-templates" } });
for (const input of [
{ routeId: "people.support.ticket-detail" as const, id: 91 },
{ routeId: "people.support.help-ticket-detail" as const, id: 92 },
]) {
expect(
await query.run(context([PERMS.TICKETS_VIEW]), input),
).toMatchObject({ ok: false, error: { code: "NOT_FOUND" } });
}
});
});
describe("People moderation query", () => {
it("covers overview, CFH, bans, IP, VPN, and word-filter read workflows", async () => {
const query = createPeopleModerationQuery({
loadOverview: async () => ({
tickets: 1,
helpTickets: 2,
cfh: 3,
activeBans: 4,
staffOnline: 5,
recentActions: 6,
}),
loadCfh: async () => ({ rows: [], total: 0 }),
loadCfhDetail: async () => null,
loadBans: async () => ({ rows: [], total: 0 }),
loadIpRules: async () => ({ blacklist: [], whitelist: [] }),
loadVpnSettings: async () => [],
loadWordFilter: async () => ({ rows: [], total: 0 }),
});
expect(
await query.run(context([PERMS.MODERATION_VIEW]), {
routeId: "people.moderation.overview",
}),
).toMatchObject({
ok: true,
data: { kind: "overview", snapshot: { cfh: 3, activeBans: 4 } },
});
expect(
await query.run(context([PERMS.MOD_CFH_VIEW]), {
routeId: "people.moderation.cfh",
list: {},
}),
).toMatchObject({ ok: true, data: { kind: "cfh" } });
expect(
await query.run(context([PERMS.MOD_CFH_VIEW]), {
routeId: "people.moderation.cfh-detail",
id: 77,
}),
).toMatchObject({ ok: false, error: { code: "NOT_FOUND" } });
expect(
await query.run(context([PERMS.MOD_BANS_VIEW]), {
routeId: "people.moderation.bans",
list: {},
}),
).toMatchObject({ ok: true, data: { kind: "bans" } });
for (const input of [
{ routeId: "people.moderation.ip" as const },
{ routeId: "people.moderation.vpn" as const },
]) {
expect(
await query.run(context([PERMS.SETTINGS_VIEW]), input),
).toMatchObject({ ok: true });
}
expect(
await query.run(context([PERMS.WORDFILTER_VIEW]), {
routeId: "people.moderation.word-filter",
list: {},
}),
).toMatchObject({ ok: true, data: { kind: "word-filter" } });
});
it("maps adapter exceptions to dependency unavailable without partial data", async () => {
const down = async () => {
throw new Error("database unavailable");
};
const query = createPeopleModerationQuery({
loadOverview: down,
loadCfh: down,
loadCfhDetail: down,
loadBans: down,
loadIpRules: down,
loadVpnSettings: down,
loadWordFilter: down,
});
expect(
await query.run(context([PERMS.MOD_BANS_VIEW]), {
routeId: "people.moderation.bans",
list: {},
}),
).toMatchObject({
ok: false,
error: {
code: "DEPENDENCY_UNAVAILABLE",
messageKey: "errors.housekeeping.dependencyUnavailable",
},
});
});
});
@@ -0,0 +1,281 @@
import "server-only";
import { PERMS } from "@/lib/permission-slugs";
import { authorizeHousekeeping } from "../../../foundation/authorization";
import {
anyCapability,
fail,
type HousekeepingQuery,
ok,
} from "../../../foundation/contracts";
import {
createPeoplePage,
type ListInput,
normalizePeopleListInput,
type Page,
type PeopleModerationTeamMember,
type PeopleStaffApplication,
type PeopleTeam,
peopleUserHref,
toPeopleIsoDate,
} from "../models";
interface StaffRows<T> {
readonly rows: readonly T[];
readonly total: number;
}
export interface PeopleStaffAdapters {
loadApplications(
input: ReturnType<typeof normalizePeopleListInput>,
): Promise<StaffRows<PeopleStaffApplication>>;
loadTeams(
input: ReturnType<typeof normalizePeopleListInput>,
): Promise<StaffRows<PeopleTeam>>;
loadModerationTeam(
input: ReturnType<typeof normalizePeopleListInput>,
): Promise<StaffRows<PeopleModerationTeamMember>>;
}
export type PeopleStaffQueryInput =
| { readonly routeId: "people.staff.applications"; readonly list: ListInput }
| { readonly routeId: "people.staff.teams"; readonly list: ListInput }
| {
readonly routeId: "people.staff.moderation-team";
readonly list: ListInput;
};
export type PeopleStaffQueryData =
| {
readonly kind: "applications";
readonly page: Page<PeopleStaffApplication>;
}
| { readonly kind: "teams"; readonly page: Page<PeopleTeam> }
| {
readonly kind: "moderation-team";
readonly page: Page<PeopleModerationTeamMember>;
};
export function createPeopleStaffQuery(
adapters: PeopleStaffAdapters,
): HousekeepingQuery<PeopleStaffQueryInput, PeopleStaffQueryData> {
return {
id: "people.staff.query",
owner: "people",
capability: anyCapability(
PERMS.USERS_VIEW,
PERMS.MODERATION_VIEW,
PERMS.MOD_TEAM_VIEW,
),
async run(context, input) {
const requirement =
input.routeId === "people.staff.moderation-team"
? anyCapability(PERMS.MODERATION_VIEW, PERMS.MOD_TEAM_VIEW)
: anyCapability(PERMS.USERS_VIEW);
const authorization = authorizeHousekeeping(context, requirement);
if (!authorization.ok) return authorization;
const correlationId = authorization.correlationId;
const list = normalizePeopleListInput(
input.list,
["id", "name", "username", "createdAt"],
"id",
);
try {
if (input.routeId === "people.staff.applications") {
const result = await adapters.loadApplications(list);
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,
),
},
correlationId,
);
}
if (input.routeId === "people.staff.teams") {
const result = await adapters.loadTeams(list);
return ok(
{
kind: "teams" as const,
page: createPeoplePage(result.rows, result.total, list, (row) =>
list.sort === "name" ? row.name : row.id,
),
},
correlationId,
);
}
const result = await adapters.loadModerationTeam(list);
return ok(
{
kind: "moderation-team" as const,
page: createPeoplePage(result.rows, result.total, list, (row) =>
list.sort === "username" ? row.username : row.id,
),
},
correlationId,
);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
correlationId,
);
}
},
};
}
function resultRows<T>(result: unknown): T[] {
if (!Array.isArray(result)) return [];
return Array.isArray(result[0]) ? (result[0] as T[]) : [];
}
export const peopleStaffAdapters: PeopleStaffAdapters = {
async loadApplications(input) {
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const pattern = `%${input.search}%`;
const where = input.search
? sql`WHERE u.username LIKE ${pattern} OR a.content LIKE ${pattern}`
: sql``;
const [rowsResult, countResult] = await Promise.all([
db.execute(sql`
SELECT a.id, a.user_id AS userId, u.username, a.rank_id AS rankId,
a.content, a.created_at AS createdAt
FROM website_staff_applications a
LEFT JOIN users u ON u.id = a.user_id
${where}
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}
`),
db.execute(sql`
SELECT COUNT(*) AS total
FROM website_staff_applications a
LEFT JOIN users u ON u.id = a.user_id
${where}
`),
]);
const rows = resultRows<{
id: bigint | number;
userId: number;
username: string | null;
rankId: number;
content: string;
createdAt: Date | string | null;
}>(rowsResult).map((row) => ({
id: Number(row.id),
userId: Number(row.userId),
username: row.username,
rankId: Number(row.rankId),
content: row.content,
createdAt: toPeopleIsoDate(row.createdAt),
}));
return {
rows,
total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0),
};
},
async loadTeams(input) {
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const pattern = `%${input.search}%`;
const where = input.search
? sql`WHERE rank_name LIKE ${pattern} OR job_description LIKE ${pattern}`
: sql``;
const [rowsResult, countResult] = await Promise.all([
db.execute(sql`
SELECT id, rank_name AS name, hidden_rank AS hidden,
badge, job_description AS jobDescription
FROM website_teams
${where}
ORDER BY ${input.sort === "name" ? sql`rank_name` : sql`id`} ${
input.order === "desc" ? sql`DESC` : sql`ASC`
}, id ASC
LIMIT ${input.offset + input.pageSize}
`),
db.execute(sql`SELECT COUNT(*) AS total FROM website_teams ${where}`),
]);
const rows = resultRows<{
id: bigint | number;
name: string;
hidden: boolean | number;
badge: string | null;
jobDescription: string | null;
}>(rowsResult).map((row) => ({
id: Number(row.id),
name: row.name,
rank: Number(row.id),
hidden: Boolean(row.hidden),
badge: row.badge,
jobDescription: row.jobDescription,
}));
return {
rows,
total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0),
};
},
async loadModerationTeam(input) {
const [{ sql }, { db }, { getMinStaffRank }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
import("@/lib/admin/min-staff-rank"),
]);
const minStaffRank = await getMinStaffRank();
const pattern = `%${input.search}%`;
const search = input.search ? sql`AND u.username LIKE ${pattern}` : sql``;
const [rowsResult, countResult] = await Promise.all([
db.execute(sql`
SELECT u.id, u.username, u.rank,
(SELECT COUNT(*) FROM support_tickets c WHERE c.mod_id = u.id) AS openCfh,
(SELECT COUNT(*) FROM website_tickets t WHERE t.assignee_id = u.id) AS openTickets,
(SELECT COUNT(*) FROM admin_audit_log a WHERE a.user_id = u.id AND a.action LIKE 'mod_%') AS actionCount
FROM users u
WHERE u.rank >= ${minStaffRank} ${search}
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}
`),
db.execute(sql`
SELECT COUNT(*) AS total FROM users u
WHERE u.rank >= ${minStaffRank} ${search}
`),
]);
const rows = resultRows<{
id: number;
username: string;
rank: number;
openCfh: number;
openTickets: number;
actionCount: number;
}>(rowsResult).map((row) => ({
id: Number(row.id),
username: row.username,
rank: Number(row.rank),
openCfh: Number(row.openCfh),
openTickets: Number(row.openTickets),
actionCount: Number(row.actionCount),
href: peopleUserHref(Number(row.id)),
}));
return {
rows,
total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0),
};
},
};
export const peopleStaffQuery = createPeopleStaffQuery(peopleStaffAdapters);
@@ -0,0 +1,505 @@
import "server-only";
import { PERMS } from "@/lib/permission-slugs";
import { authorizeHousekeeping } from "../../../foundation/authorization";
import {
anyCapability,
fail,
type HousekeepingQuery,
ok,
} from "../../../foundation/contracts";
import {
createPeoplePage,
type ListInput,
normalizePeopleListInput,
type Page,
type PeopleHelpTicketDetail,
type PeopleHelpTicketSummary,
type PeopleQueueSnapshot,
type PeopleTicketDetail,
type PeopleTicketSummary,
type PeopleTicketTemplate,
peopleHelpTicketHref,
peopleTicketHref,
toPeopleIsoDate,
} from "../models";
interface SupportRows<T> {
readonly rows: readonly T[];
readonly total: number;
}
export interface PeopleSupportAdapters {
loadQueue(): Promise<PeopleQueueSnapshot>;
loadTickets(
input: ReturnType<typeof normalizePeopleListInput>,
): Promise<SupportRows<PeopleTicketSummary>>;
loadTicket(id: number): Promise<PeopleTicketDetail | null>;
loadTemplates(
input: ReturnType<typeof normalizePeopleListInput>,
): Promise<SupportRows<PeopleTicketTemplate>>;
loadHelpTickets(
input: ReturnType<typeof normalizePeopleListInput>,
): Promise<SupportRows<PeopleHelpTicketSummary>>;
loadHelpTicket(id: number): Promise<PeopleHelpTicketDetail | null>;
}
export type PeopleSupportQueryInput =
| { readonly routeId: "people.support.queue" }
| { readonly routeId: "people.support.tickets"; readonly list: ListInput }
| { readonly routeId: "people.support.ticket-desk"; readonly list: ListInput }
| {
readonly routeId: "people.support.ticket-templates";
readonly list: ListInput;
}
| { readonly routeId: "people.support.ticket-detail"; readonly id: number }
| {
readonly routeId: "people.support.help-tickets";
readonly list: ListInput;
}
| {
readonly routeId: "people.support.help-ticket-detail";
readonly id: number;
};
export type PeopleSupportQueryData =
| { readonly kind: "queue"; readonly queue: PeopleQueueSnapshot }
| { readonly kind: "tickets"; readonly page: Page<PeopleTicketSummary> }
| {
readonly kind: "ticket-templates";
readonly page: Page<PeopleTicketTemplate>;
}
| { readonly kind: "ticket"; readonly ticket: PeopleTicketDetail }
| {
readonly kind: "help-tickets";
readonly page: Page<PeopleHelpTicketSummary>;
}
| {
readonly kind: "help-ticket";
readonly ticket: PeopleHelpTicketDetail;
};
const broadCapability = anyCapability(
PERMS.TICKETS_VIEW,
PERMS.MOD_TICKETS_VIEW,
PERMS.TICKETS_EDIT,
);
function unavailable(correlationId: string) {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
correlationId,
);
}
function invalidId(id: number, correlationId: string) {
return !Number.isSafeInteger(id) || id <= 0
? fail("VALIDATION", "errors.housekeeping.validation", correlationId, {
id: ["invalid"],
})
: null;
}
export function createPeopleSupportQuery(
adapters: PeopleSupportAdapters,
): HousekeepingQuery<PeopleSupportQueryInput, PeopleSupportQueryData> {
return {
id: "people.support.query",
owner: "people",
capability: broadCapability,
async run(context, input) {
const requirement =
input.routeId === "people.support.ticket-templates"
? anyCapability(PERMS.TICKETS_EDIT)
: anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW);
const authorization = authorizeHousekeeping(context, requirement);
if (!authorization.ok) return authorization;
const correlationId = authorization.correlationId;
try {
if (input.routeId === "people.support.queue") {
return ok(
{ kind: "queue" as const, queue: await adapters.loadQueue() },
correlationId,
);
}
if (
input.routeId === "people.support.ticket-detail" ||
input.routeId === "people.support.help-ticket-detail"
) {
const invalid = invalidId(input.id, correlationId);
if (invalid !== null) return invalid;
const ticket =
input.routeId === "people.support.ticket-detail"
? await adapters.loadTicket(input.id)
: await adapters.loadHelpTicket(input.id);
if (ticket === null) {
return fail(
"NOT_FOUND",
"errors.housekeeping.notFound",
correlationId,
);
}
return input.routeId === "people.support.ticket-detail"
? ok(
{
kind: "ticket" as const,
ticket: ticket as PeopleTicketDetail,
},
correlationId,
)
: ok(
{
kind: "help-ticket" as const,
ticket: ticket as PeopleHelpTicketDetail,
},
correlationId,
);
}
const list = normalizePeopleListInput(
input.list,
["id", "subject", "title", "status", "updatedAt", "sortOrder"],
"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,
),
},
correlationId,
);
}
if (input.routeId === "people.support.help-tickets") {
const result = await adapters.loadHelpTickets(list);
return ok(
{
kind: "help-tickets" as const,
page: createPeoplePage(result.rows, result.total, list, (row) =>
list.sort === "title" ? row.title : row.id,
),
},
correlationId,
);
}
const result = await adapters.loadTickets(list);
return ok(
{
kind: "tickets" as const,
page: createPeoplePage(result.rows, result.total, list, (row) =>
list.sort === "subject" ? row.subject : row.id,
),
},
correlationId,
);
} catch {
return unavailable(correlationId);
}
},
};
}
function resultRows<T>(result: unknown): T[] {
if (!Array.isArray(result)) return [];
return Array.isArray(result[0]) ? (result[0] as T[]) : [];
}
export const peopleSupportAdapters: PeopleSupportAdapters = {
async loadQueue() {
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const result = await 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),
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)),
}));
return {
rows,
total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0),
};
},
async loadTicket(id) {
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const [ticketResult, messagesResult] = await Promise.all([
db.execute(sql`
SELECT t.id, t.subject, t.category, t.priority, t.status,
t.creator_id AS creatorId, creator.username AS creatorUsername,
t.assignee_id AS assigneeId, t.updated_at AS updatedAt
FROM website_tickets t
LEFT JOIN users creator ON creator.id = t.creator_id
WHERE t.id = ${id} LIMIT 1
`),
db.execute(sql`
SELECT m.id, m.user_id AS userId, u.username, m.message,
m.is_staff AS isStaff, m.created_at AS createdAt
FROM website_ticket_messages m
LEFT JOIN users u ON u.id = m.user_id
WHERE m.ticket_id = ${id}
ORDER BY m.created_at ASC, m.id ASC
`),
]);
const row = resultRows<{
id: number;
subject: string;
category: string;
priority: string;
status: string;
creatorId: number;
creatorUsername: string | null;
assigneeId: number | null;
updatedAt: Date | string | null;
}>(ticketResult)[0];
if (!row) return null;
return {
id: Number(row.id),
subject: row.subject,
category: row.category,
priority: row.priority,
status: row.status,
creatorId: Number(row.creatorId),
creatorUsername: row.creatorUsername,
assigneeId: row.assigneeId === null ? null : Number(row.assigneeId),
updatedAt: toPeopleIsoDate(row.updatedAt),
href: peopleTicketHref(Number(row.id)),
messages: resultRows<{
id: number;
userId: number;
username: string | null;
message: string;
isStaff: number | boolean;
createdAt: Date | string | null;
}>(messagesResult).map((message) => ({
id: Number(message.id),
userId: Number(message.userId),
username: message.username,
message: message.message,
isStaff: Boolean(message.isStaff),
createdAt: toPeopleIsoDate(message.createdAt),
})),
};
},
async loadTemplates(input) {
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const pattern = `%${input.search}%`;
const where = input.search
? sql`WHERE title LIKE ${pattern} OR category LIKE ${pattern}`
: sql``;
const [rowsResult, countResult] = await Promise.all([
db.execute(sql`
SELECT id, title, content, category, sort_order AS sortOrder
FROM website_ticket_templates ${where}
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}
`),
db.execute(sql`
SELECT COUNT(*) AS total FROM website_ticket_templates ${where}
`),
]);
const rows = resultRows<PeopleTicketTemplate>(rowsResult).map((row) => ({
id: Number(row.id),
title: row.title,
content: row.content,
category: row.category,
sortOrder: Number(row.sortOrder),
}));
return {
rows,
total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0),
};
},
async loadHelpTickets(input) {
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const pattern = `%${input.search}%`;
const where = input.search
? sql`WHERE t.title LIKE ${pattern} OR u.username LIKE ${pattern}`
: sql``;
const [rowsResult, countResult] = await Promise.all([
db.execute(sql`
SELECT t.id, t.title, t.open, t.user_id AS userId, u.username,
t.updated_at AS updatedAt
FROM website_help_center_tickets t
LEFT JOIN users u ON u.id = t.user_id
${where}
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}
`),
db.execute(sql`
SELECT COUNT(*) AS total FROM website_help_center_tickets t
LEFT JOIN users u ON u.id = t.user_id ${where}
`),
]);
const rows = resultRows<{
id: bigint | number;
title: string;
open: boolean | number;
userId: number | null;
username: string | null;
updatedAt: Date | string | null;
}>(rowsResult).map((row) => ({
id: Number(row.id),
title: row.title,
open: Boolean(row.open),
userId: row.userId === null ? null : Number(row.userId),
username: row.username,
updatedAt: toPeopleIsoDate(row.updatedAt),
href: peopleHelpTicketHref(Number(row.id)),
}));
return {
rows,
total: Number(resultRows<{ total: number }>(countResult)[0]?.total ?? 0),
};
},
async loadHelpTicket(id) {
const [{ sql }, { db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const [ticketResult, repliesResult] = await Promise.all([
db.execute(sql`
SELECT t.id, t.title, t.content, t.open, t.user_id AS userId,
u.username, t.category_id AS categoryId, c.name AS categoryName,
t.updated_at AS updatedAt
FROM website_help_center_tickets t
LEFT JOIN users u ON u.id = t.user_id
LEFT JOIN website_help_center_categories c ON c.id = t.category_id
WHERE t.id = ${id} LIMIT 1
`),
db.execute(sql`
SELECT r.id, r.user_id AS userId, u.username, r.content,
r.created_at AS createdAt
FROM website_help_center_ticket_replies r
LEFT JOIN users u ON u.id = r.user_id
WHERE r.ticket_id = ${id}
ORDER BY r.created_at ASC, r.id ASC
`),
]);
const row = resultRows<{
id: bigint | number;
title: string;
content: string;
open: boolean | number;
userId: number | null;
username: string | null;
categoryId: bigint | number | null;
categoryName: string | null;
updatedAt: Date | string | null;
}>(ticketResult)[0];
if (!row) return null;
return {
id: Number(row.id),
title: row.title,
content: row.content,
open: Boolean(row.open),
userId: row.userId === null ? null : Number(row.userId),
username: row.username,
categoryId: row.categoryId === null ? null : Number(row.categoryId),
categoryName: row.categoryName,
updatedAt: toPeopleIsoDate(row.updatedAt),
href: peopleHelpTicketHref(Number(row.id)),
replies: resultRows<{
id: bigint | number;
userId: number;
username: string | null;
content: string;
createdAt: Date | string | null;
}>(repliesResult).map((reply) => ({
id: Number(reply.id),
userId: Number(reply.userId),
username: reply.username,
content: reply.content,
createdAt: toPeopleIsoDate(reply.createdAt),
})),
};
},
};
export const peopleSupportQuery = createPeopleSupportQuery(
peopleSupportAdapters,
);
@@ -0,0 +1,424 @@
import "server-only";
import { PERMS } from "@/lib/permission-slugs";
import { authorizeHousekeeping } from "../../../foundation/authorization";
import {
anyCapability,
fail,
type HousekeepingQuery,
ok,
} from "../../../foundation/contracts";
import {
createPeoplePage,
type ListInput,
normalizePeopleListInput,
normalizePeopleUser,
type Page,
type PeopleMultiAccountCluster,
type PeopleSanctionSummary,
type PeopleUserDetail,
type PeopleUserRecord,
type PeopleUserSummary,
toPeopleIsoDate,
} from "../models";
interface PeopleUserColumns {
readonly id: unknown;
readonly username: unknown;
readonly rank: unknown;
readonly online: unknown;
readonly mail: unknown;
readonly ipCurrent: unknown;
}
export function buildPeopleUserSelection<T extends PeopleUserColumns>(
user: T,
projection: { readonly includeMail: boolean; readonly includeIp: boolean },
) {
return {
id: user.id,
username: user.username,
rank: user.rank,
online: user.online,
...(projection.includeMail ? { mail: user.mail } : {}),
...(projection.includeIp ? { ipCurrent: user.ipCurrent } : {}),
};
}
export interface PeopleUserDetailRecord extends PeopleUserRecord {
readonly motto: unknown;
readonly look: unknown;
readonly accountCreated: unknown;
readonly lastLogin: unknown;
}
export interface PeopleUsersAdapters {
loadUsers(
input: ReturnType<typeof normalizePeopleListInput>,
projection: { readonly includeMail: boolean; readonly includeIp: boolean },
): Promise<{
readonly rows: readonly PeopleUserRecord[];
readonly total: number;
}>;
loadUser(
id: number,
projection: { readonly includeMail: boolean; readonly includeIp: boolean },
): Promise<PeopleUserDetailRecord | null>;
loadMultiAccounts(
input: ReturnType<typeof normalizePeopleListInput>,
): Promise<{
readonly rows: readonly PeopleMultiAccountCluster[];
readonly total: number;
}>;
loadSanctions(userId: number): Promise<readonly PeopleSanctionSummary[]>;
}
export type PeopleUsersQueryInput =
| { readonly routeId: "people.users.list"; readonly list: ListInput }
| { readonly routeId: "people.users.edit"; readonly id: number }
| { readonly routeId: "people.users.detail"; readonly id: number }
| {
readonly routeId: "people.users.multi-accounts";
readonly list: ListInput;
};
export type PeopleUsersQueryData =
| { readonly kind: "users"; readonly page: Page<PeopleUserSummary> }
| { readonly kind: "user"; readonly user: PeopleUserDetail }
| {
readonly kind: "multi-accounts";
readonly page: Page<PeopleMultiAccountCluster>;
};
const broadCapability = anyCapability(
PERMS.USERS_VIEW,
PERMS.MOD_USERS_VIEW,
PERMS.USERS_EDIT,
);
function unavailable(correlationId: string) {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
correlationId,
);
}
function validId(id: number): boolean {
return Number.isSafeInteger(id) && id > 0;
}
export function createPeopleUsersQuery(
adapters: PeopleUsersAdapters,
): HousekeepingQuery<PeopleUsersQueryInput, PeopleUsersQueryData> {
return {
id: "people.users.query",
owner: "people",
capability: broadCapability,
async run(context, input) {
const requirement =
input.routeId === "people.users.edit"
? anyCapability(PERMS.USERS_EDIT)
: input.routeId === "people.users.multi-accounts"
? anyCapability(PERMS.USERS_VIEW)
: anyCapability(PERMS.USERS_VIEW, PERMS.MOD_USERS_VIEW);
const authorization = authorizeHousekeeping(context, requirement);
if (!authorization.ok) return authorization;
const correlationId = authorization.correlationId;
const projection = {
includeMail: context.has(PERMS.USERS_VIEW),
includeIp: context.has(PERMS.USERS_VIEW),
};
if (input.routeId === "people.users.list") {
const list = normalizePeopleListInput(
input.list,
["id", "username", "rank"],
"id",
);
try {
const result = await adapters.loadUsers(list, projection);
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),
},
correlationId,
);
} catch {
return unavailable(correlationId);
}
}
if (input.routeId === "people.users.multi-accounts") {
const list = normalizePeopleListInput(input.list, ["id"], "id");
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>,
},
correlationId,
);
} catch {
return unavailable(correlationId);
}
}
if (!validId(input.id)) {
return fail(
"VALIDATION",
"errors.housekeeping.validation",
correlationId,
{ id: ["invalid"] },
);
}
try {
const row = await adapters.loadUser(input.id, projection);
if (row === null) {
return fail(
"NOT_FOUND",
"errors.housekeeping.notFound",
correlationId,
);
}
const sanctions = await adapters.loadSanctions(input.id);
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,
},
},
correlationId,
);
} catch {
return unavailable(correlationId);
}
},
};
}
function resultRows<T>(result: unknown): T[] {
if (!Array.isArray(result)) return [];
const rows = result[0];
return Array.isArray(rows) ? (rows as T[]) : [];
}
export const peopleUsersAdapters: PeopleUsersAdapters = {
async loadUsers(input, projection) {
const [{ and, asc, count, desc, eq, like, or }, { Ban, db, User }] =
await Promise.all([import("drizzle-orm"), import("@/lib/db")]);
const searchConditions = input.search
? [
like(User.username, `%${input.search}%`),
...(projection.includeMail
? [like(User.mail, `%${input.search}%`)]
: []),
...(projection.includeIp
? [like(User.ipCurrent, `%${input.search}%`)]
: []),
]
: [];
const numericSearch = Number(input.search);
if (
input.search &&
Number.isSafeInteger(numericSearch) &&
numericSearch > 0
) {
searchConditions.push(eq(User.id, numericSearch));
}
const where =
searchConditions.length > 0 ? and(or(...searchConditions)) : undefined;
const sortColumn =
input.sort === "username"
? User.username
: input.sort === "rank"
? User.rank
: User.id;
const direction = input.order === "desc" ? desc : asc;
const selection = buildPeopleUserSelection(User, projection) as {
id: typeof User.id;
username: typeof User.username;
rank: typeof User.rank;
online: typeof User.online;
mail: typeof User.mail;
ipCurrent: typeof User.ipCurrent;
};
const [rows, totals] = await Promise.all([
db
.select(selection)
.from(User)
.where(where)
.orderBy(direction(sortColumn), asc(User.id))
.limit(input.offset + input.pageSize),
db.select({ total: count() }).from(User).where(where),
]);
const ids = (rows as unknown as PeopleUserRecord[])
.map((row) => Number(row.id))
.filter((id) => Number.isSafeInteger(id));
const activeBans =
ids.length === 0
? []
: await db
.select({ userId: Ban.userId, banExpire: Ban.banExpire })
.from(Ban)
.where(or(...ids.map((id) => eq(Ban.userId, id))));
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) {
latestBan.set(ban.userId, ban.banExpire);
}
}
return {
rows: (rows as unknown as PeopleUserRecord[]).map((row) => ({
...row,
bannedUntil: latestBan.get(Number(row.id)) ?? null,
})),
total: Number(totals[0]?.total ?? 0),
};
},
async loadUser(id, projection) {
const [{ desc, eq }, { Ban, db, User }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const [row] = await db
.select({
...(buildPeopleUserSelection(User, projection) as {
id: typeof User.id;
username: typeof User.username;
rank: typeof User.rank;
online: typeof User.online;
mail: typeof User.mail;
ipCurrent: typeof User.ipCurrent;
}),
motto: User.motto,
look: User.look,
accountCreated: User.accountCreated,
lastLogin: User.lastLogin,
})
.from(User)
.where(eq(User.id, id))
.limit(1);
if (!row) return null;
const [ban] = await db
.select({ banExpire: Ban.banExpire })
.from(Ban)
.where(eq(Ban.userId, id))
.orderBy(desc(Ban.timestamp), desc(Ban.id))
.limit(1);
return {
...(row as unknown as PeopleUserDetailRecord),
bannedUntil: ban?.banExpire ?? null,
};
},
async loadMultiAccounts(input) {
const [{ sql }, { db }] = await Promise.all([
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 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 };
},
async loadSanctions(userId) {
const [{ desc, eq }, { Ban, db }] = await Promise.all([
import("drizzle-orm"),
import("@/lib/db"),
]);
const rows = await db
.select({
id: Ban.id,
type: Ban.type,
reason: Ban.banReason,
createdAt: Ban.timestamp,
expiresAt: Ban.banExpire,
})
.from(Ban)
.where(eq(Ban.userId, userId))
.orderBy(desc(Ban.timestamp), desc(Ban.id))
.limit(100);
const now = Math.floor(Date.now() / 1000);
return rows.map((row) => ({
id: row.id,
kind: row.type,
reason: row.reason,
createdAt: toPeopleIsoDate(row.createdAt),
expiresAt: row.expiresAt === 0 ? null : toPeopleIsoDate(row.expiresAt),
active: row.expiresAt === 0 || row.expiresAt > now,
}));
},
};
export const peopleUsersQuery = createPeopleUsersQuery(peopleUsersAdapters);
@@ -0,0 +1,169 @@
import { describe, expect, it } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import { peopleMigrationEntries } from "../../migration/people";
import { PEOPLE_ROUTE_IDS, PEOPLE_ROUTES } from "./routes";
const expectedRoutes = [
[
"people.users.list",
"/ase/people/users",
[PERMS.USERS_VIEW, PERMS.MOD_USERS_VIEW],
],
["people.users.edit", "/ase/people/users/:id/edit", [PERMS.USERS_EDIT]],
[
"people.users.multi-accounts",
"/ase/people/users/multi-accounts",
[PERMS.USERS_VIEW],
],
[
"people.users.detail",
"/ase/people/users/:id",
[PERMS.USERS_VIEW, PERMS.MOD_USERS_VIEW],
],
[
"people.community.online",
"/ase/people/community/online",
[PERMS.USERS_VIEW],
],
[
"people.community.guilds",
"/ase/people/community/guilds",
[PERMS.USERS_VIEW],
],
[
"people.community.guild-detail",
"/ase/people/community/guilds/:id",
[PERMS.USERS_VIEW],
],
[
"people.staff.applications",
"/ase/people/staff/applications",
[PERMS.USERS_VIEW],
],
["people.staff.teams", "/ase/people/staff/teams", [PERMS.USERS_VIEW]],
[
"people.moderation.overview",
"/ase/people/moderation",
[
PERMS.MODERATION_VIEW,
PERMS.MOD_CFH_VIEW,
PERMS.MOD_ACTIONS,
PERMS.MODERATION_EDIT,
PERMS.MOD_BANS_VIEW,
PERMS.BANS_VIEW,
PERMS.MOD_TICKETS_VIEW,
PERMS.TICKETS_VIEW,
PERMS.MOD_TEAM_VIEW,
PERMS.MOD_USERS_VIEW,
PERMS.USERS_VIEW,
],
],
[
"people.moderation.actions",
"/ase/people/moderation/actions",
[PERMS.MODERATION_EDIT, PERMS.MOD_ACTIONS],
],
[
"people.moderation.cfh",
"/ase/people/moderation/cfh",
[PERMS.MODERATION_VIEW, PERMS.MOD_CFH_VIEW],
],
[
"people.moderation.cfh-detail",
"/ase/people/moderation/cfh/:id",
[PERMS.MODERATION_VIEW, PERMS.MOD_CFH_VIEW],
],
[
"people.staff.moderation-team",
"/ase/people/staff/moderation-team",
[PERMS.MODERATION_VIEW, PERMS.MOD_TEAM_VIEW],
],
[
"people.moderation.bans",
"/ase/people/moderation/bans",
[PERMS.BANS_VIEW, PERMS.MOD_BANS_VIEW],
],
["people.moderation.ip", "/ase/people/moderation/ip", [PERMS.SETTINGS_VIEW]],
[
"people.moderation.vpn",
"/ase/people/moderation/vpn",
[PERMS.SETTINGS_VIEW],
],
[
"people.moderation.word-filter",
"/ase/people/moderation/word-filter",
[PERMS.WORDFILTER_VIEW],
],
[
"people.support.tickets",
"/ase/people/support/tickets",
[PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW],
],
[
"people.support.ticket-desk",
"/ase/people/support/tickets/desk",
[PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW],
],
[
"people.support.ticket-templates",
"/ase/people/support/tickets/templates",
[PERMS.TICKETS_EDIT],
],
[
"people.support.ticket-detail",
"/ase/people/support/tickets/:id",
[PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW],
],
[
"people.support.help-tickets",
"/ase/people/support/help-tickets",
[PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW],
],
[
"people.support.help-ticket-detail",
"/ase/people/support/help-tickets/:id",
[PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW],
],
] as const;
describe("PEOPLE_ROUTES", () => {
it("declares the exact ordered People workflows", () => {
expect(PEOPLE_ROUTE_IDS).toEqual(expectedRoutes.map(([id]) => id));
expect(PEOPLE_ROUTES.map((route) => route.id)).toEqual(PEOPLE_ROUTE_IDS);
});
it("uses canonical People links, stable labels, and exact read capabilities", () => {
expect(
PEOPLE_ROUTES.map((route) => [
route.id,
route.href,
route.labelKey,
route.capability.mode,
route.capability.slugs,
]),
).toEqual(
expectedRoutes.map(([id, href, capabilities]) => [
id,
href,
`pages.housekeeping.routes.${id}`,
"any",
capabilities,
]),
);
});
it("covers every distinct matrix-listed People destination exactly once", () => {
const plannedTargets = [
...new Set(
peopleMigrationEntries.flatMap((entry) =>
entry.targetPath === null ? [] : [entry.targetPath],
),
),
].sort();
const routeTargets = PEOPLE_ROUTES.map((route) => route.href).sort();
expect(routeTargets).toEqual(plannedTargets);
expect(routeTargets).toHaveLength(24);
expect(new Set(routeTargets).size).toBe(routeTargets.length);
});
});
@@ -0,0 +1,159 @@
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
type CanonicalHousekeepingHref,
type HousekeepingRouteDefinition,
} from "../../foundation/contracts";
export const PEOPLE_ROUTE_IDS = [
"people.users.list",
"people.users.edit",
"people.users.multi-accounts",
"people.users.detail",
"people.community.online",
"people.community.guilds",
"people.community.guild-detail",
"people.staff.applications",
"people.staff.teams",
"people.moderation.overview",
"people.moderation.actions",
"people.moderation.cfh",
"people.moderation.cfh-detail",
"people.staff.moderation-team",
"people.moderation.bans",
"people.moderation.ip",
"people.moderation.vpn",
"people.moderation.word-filter",
"people.support.tickets",
"people.support.ticket-desk",
"people.support.ticket-templates",
"people.support.ticket-detail",
"people.support.help-tickets",
"people.support.help-ticket-detail",
] as const;
export type PeopleRouteId = (typeof PEOPLE_ROUTE_IDS)[number];
function peopleRoute(
id: PeopleRouteId,
href: CanonicalHousekeepingHref,
capabilities: readonly string[],
): HousekeepingRouteDefinition {
return {
id,
labelKey: `pages.housekeeping.routes.${id}`,
href,
capability: anyCapability(...capabilities),
};
}
export const PEOPLE_ROUTES = [
peopleRoute("people.users.list", "/ase/people/users", [
PERMS.USERS_VIEW,
PERMS.MOD_USERS_VIEW,
]),
peopleRoute("people.users.edit", "/ase/people/users/:id/edit", [
PERMS.USERS_EDIT,
]),
peopleRoute(
"people.users.multi-accounts",
"/ase/people/users/multi-accounts",
[PERMS.USERS_VIEW],
),
peopleRoute("people.users.detail", "/ase/people/users/:id", [
PERMS.USERS_VIEW,
PERMS.MOD_USERS_VIEW,
]),
peopleRoute("people.community.online", "/ase/people/community/online", [
PERMS.USERS_VIEW,
]),
peopleRoute("people.community.guilds", "/ase/people/community/guilds", [
PERMS.USERS_VIEW,
]),
peopleRoute(
"people.community.guild-detail",
"/ase/people/community/guilds/:id",
[PERMS.USERS_VIEW],
),
peopleRoute("people.staff.applications", "/ase/people/staff/applications", [
PERMS.USERS_VIEW,
]),
peopleRoute("people.staff.teams", "/ase/people/staff/teams", [
PERMS.USERS_VIEW,
]),
peopleRoute("people.moderation.overview", "/ase/people/moderation", [
PERMS.MODERATION_VIEW,
PERMS.MOD_CFH_VIEW,
PERMS.MOD_ACTIONS,
PERMS.MODERATION_EDIT,
PERMS.MOD_BANS_VIEW,
PERMS.BANS_VIEW,
PERMS.MOD_TICKETS_VIEW,
PERMS.TICKETS_VIEW,
PERMS.MOD_TEAM_VIEW,
PERMS.MOD_USERS_VIEW,
PERMS.USERS_VIEW,
]),
peopleRoute("people.moderation.actions", "/ase/people/moderation/actions", [
PERMS.MODERATION_EDIT,
PERMS.MOD_ACTIONS,
]),
peopleRoute("people.moderation.cfh", "/ase/people/moderation/cfh", [
PERMS.MODERATION_VIEW,
PERMS.MOD_CFH_VIEW,
]),
peopleRoute(
"people.moderation.cfh-detail",
"/ase/people/moderation/cfh/:id",
[PERMS.MODERATION_VIEW, PERMS.MOD_CFH_VIEW],
),
peopleRoute(
"people.staff.moderation-team",
"/ase/people/staff/moderation-team",
[PERMS.MODERATION_VIEW, PERMS.MOD_TEAM_VIEW],
),
peopleRoute("people.moderation.bans", "/ase/people/moderation/bans", [
PERMS.BANS_VIEW,
PERMS.MOD_BANS_VIEW,
]),
peopleRoute("people.moderation.ip", "/ase/people/moderation/ip", [
PERMS.SETTINGS_VIEW,
]),
peopleRoute("people.moderation.vpn", "/ase/people/moderation/vpn", [
PERMS.SETTINGS_VIEW,
]),
peopleRoute(
"people.moderation.word-filter",
"/ase/people/moderation/word-filter",
[PERMS.WORDFILTER_VIEW],
),
peopleRoute("people.support.tickets", "/ase/people/support/tickets", [
PERMS.TICKETS_VIEW,
PERMS.MOD_TICKETS_VIEW,
]),
peopleRoute(
"people.support.ticket-desk",
"/ase/people/support/tickets/desk",
[PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW],
),
peopleRoute(
"people.support.ticket-templates",
"/ase/people/support/tickets/templates",
[PERMS.TICKETS_EDIT],
),
peopleRoute(
"people.support.ticket-detail",
"/ase/people/support/tickets/:id",
[PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW],
),
peopleRoute(
"people.support.help-tickets",
"/ase/people/support/help-tickets",
[PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW],
),
peopleRoute(
"people.support.help-ticket-detail",
"/ase/people/support/help-tickets/:id",
[PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW],
),
] as const satisfies readonly HousekeepingRouteDefinition[];
@@ -59,6 +59,48 @@ const approvedSystemRuntimeImports = new Map<string, ReadonlySet<string>>([
"drizzle-orm",
]),
],
[
"src/features/housekeeping/domains/people/queries/users.ts",
new Set([
"src/features/housekeeping/domains/people/models",
"src/lib/db",
"drizzle-orm",
]),
],
[
"src/features/housekeeping/domains/people/queries/community.ts",
new Set([
"src/features/housekeeping/domains/people/models",
"src/lib/db",
"drizzle-orm",
]),
],
[
"src/features/housekeeping/domains/people/queries/staff.ts",
new Set([
"src/features/housekeeping/domains/people/models",
"src/lib/admin/min-staff-rank",
"src/lib/db",
"drizzle-orm",
]),
],
[
"src/features/housekeeping/domains/people/queries/support.ts",
new Set([
"src/features/housekeeping/domains/people/models",
"src/lib/db",
"drizzle-orm",
]),
],
[
"src/features/housekeeping/domains/people/queries/moderation.ts",
new Set([
"src/features/housekeeping/domains/people/models",
"src/lib/admin/min-staff-rank",
"src/lib/db",
"drizzle-orm",
]),
],
[
"src/features/housekeeping/domains/system/services/mutations.ts",
new Set([