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,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);
|
||||
Reference in new issue
Block a user