Files
Epicnabbo-Catalogus-Updated…/src/features/housekeeping/domains/people/queries/staff.ts
T

276 lines
7.9 KiB
TypeScript

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 allowedSorts =
input.routeId === "people.staff.applications"
? ["id", "username", "createdAt"]
: input.routeId === "people.staff.teams"
? ["id", "name"]
: ["id", "username"];
const list = normalizePeopleListInput(input.list, allowedSorts, "id");
try {
if (input.routeId === "people.staff.applications") {
const result = await adapters.loadApplications(list);
return ok(
{
kind: "applications" as const,
page: createPeoplePage(result.rows, result.total, list),
},
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),
},
correlationId,
);
}
const result = await adapters.loadModerationTeam(list);
return ok(
{
kind: "moderation-team" as const,
page: createPeoplePage(result.rows, result.total, list),
},
correlationId,
);
} catch {
return fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
correlationId,
);
}
},
};
}
function resultRows<T>(result: unknown): T[] {
if (!Array.isArray(result) || !Array.isArray(result[0])) {
throw new Error("invalid People driver result");
}
return result[0] as T[];
}
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.pageSize} OFFSET ${input.offset}
`),
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: String(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.pageSize} OFFSET ${input.offset}
`),
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: String(row.id),
name: row.name,
rank: String(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.pageSize} OFFSET ${input.offset}
`),
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);