feat(housekeeping): model people workflows
This commit is contained in:
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([
|
||||
|
||||
Reference in new issue
Block a user