276 lines
7.9 KiB
TypeScript
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);
|