186 lines
5.1 KiB
TypeScript
186 lines
5.1 KiB
TypeScript
import "server-only";
|
|
|
|
import { PERMS } from "@/lib/permission-slugs";
|
|
import { authorizeHousekeeping } from "../../foundation/authorization";
|
|
import { satisfiesCapability } from "../../foundation/capability-context";
|
|
import {
|
|
anyCapability,
|
|
type CapabilityRequirement,
|
|
fail,
|
|
type HousekeepingCapabilityContext,
|
|
type HousekeepingSearchProvider,
|
|
ok,
|
|
} from "../../foundation/contracts";
|
|
import { peopleCommunityQuery } from "./queries/community";
|
|
import { peopleSupportQuery } from "./queries/support";
|
|
import { peopleUsersQuery } from "./queries/users";
|
|
|
|
export const PEOPLE_SEARCH_PROVIDER_IDS = [
|
|
"people.users",
|
|
"people.guilds",
|
|
"people.tickets",
|
|
] as const;
|
|
|
|
export interface PeopleSearchCandidate {
|
|
readonly id: string;
|
|
readonly title: string;
|
|
readonly description?: string;
|
|
readonly href: string;
|
|
readonly capability: CapabilityRequirement;
|
|
}
|
|
|
|
export interface PeopleSearchAdapters {
|
|
users(
|
|
context: HousekeepingCapabilityContext,
|
|
term: string,
|
|
limit: number,
|
|
): Promise<readonly PeopleSearchCandidate[]>;
|
|
guilds(
|
|
context: HousekeepingCapabilityContext,
|
|
term: string,
|
|
limit: number,
|
|
): Promise<readonly PeopleSearchCandidate[]>;
|
|
tickets(
|
|
context: HousekeepingCapabilityContext,
|
|
term: string,
|
|
limit: number,
|
|
): Promise<readonly PeopleSearchCandidate[]>;
|
|
}
|
|
|
|
function safeHref(href: string): href is `/ase/${string}` {
|
|
return (
|
|
href.startsWith("/ase/") &&
|
|
!href.includes("\\") &&
|
|
!Array.from(href).some((character) => {
|
|
const code = character.codePointAt(0) ?? 0;
|
|
return code < 32 || code === 127;
|
|
})
|
|
);
|
|
}
|
|
|
|
function boundedLimit(limit: number): number {
|
|
return Number.isFinite(limit) ? Math.min(25, Math.max(1, Math.trunc(limit))) : 25;
|
|
}
|
|
|
|
function createProvider(
|
|
id: (typeof PEOPLE_SEARCH_PROVIDER_IDS)[number],
|
|
capability: CapabilityRequirement,
|
|
load: PeopleSearchAdapters[keyof PeopleSearchAdapters],
|
|
): HousekeepingSearchProvider {
|
|
return {
|
|
id,
|
|
owner: "people",
|
|
capability,
|
|
async search(context, input) {
|
|
const authorization = authorizeHousekeeping(context, capability);
|
|
if (!authorization.ok) return authorization;
|
|
const limit = boundedLimit(input.limit);
|
|
const term = input.term.normalize("NFC").trim().slice(0, 128);
|
|
try {
|
|
const candidates = await load(context, term, limit);
|
|
return ok(
|
|
candidates
|
|
.filter(
|
|
(item) =>
|
|
safeHref(item.href) &&
|
|
satisfiesCapability(context, item.capability),
|
|
)
|
|
.slice(0, limit)
|
|
.map((item) => ({
|
|
...item,
|
|
domain: "people" as const,
|
|
type: "entity" as const,
|
|
href: item.href as `/ase/${string}`,
|
|
})),
|
|
authorization.correlationId,
|
|
);
|
|
} catch {
|
|
return fail(
|
|
"DEPENDENCY_UNAVAILABLE",
|
|
"errors.housekeeping.dependencyUnavailable",
|
|
authorization.correlationId,
|
|
);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createPeopleSearchProviders(
|
|
adapters: PeopleSearchAdapters,
|
|
): readonly HousekeepingSearchProvider[] {
|
|
return [
|
|
createProvider(
|
|
"people.users",
|
|
anyCapability(PERMS.USERS_VIEW, PERMS.MOD_USERS_VIEW),
|
|
adapters.users,
|
|
),
|
|
createProvider(
|
|
"people.guilds",
|
|
anyCapability(PERMS.USERS_VIEW),
|
|
adapters.guilds,
|
|
),
|
|
createProvider(
|
|
"people.tickets",
|
|
anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW),
|
|
adapters.tickets,
|
|
),
|
|
];
|
|
}
|
|
|
|
const productionAdapters: PeopleSearchAdapters = {
|
|
async users(context, term, limit) {
|
|
const result = await peopleUsersQuery.run(context, {
|
|
routeId: "people.users.list",
|
|
list: { search: term, pageSize: limit, offset: 0, sort: "username" },
|
|
});
|
|
if (!result.ok || result.data.kind !== "users") {
|
|
if (!result.ok) throw new Error(result.error.code);
|
|
return [];
|
|
}
|
|
return result.data.page.items.map((user) => ({
|
|
id: `user-${user.id}`,
|
|
title: user.username,
|
|
description: `User #${user.id}`,
|
|
href: user.href,
|
|
capability: anyCapability(PERMS.USERS_VIEW, PERMS.MOD_USERS_VIEW),
|
|
}));
|
|
},
|
|
async guilds(context, term, limit) {
|
|
const result = await peopleCommunityQuery.run(context, {
|
|
routeId: "people.community.guilds",
|
|
list: { search: term, pageSize: limit, offset: 0, sort: "name" },
|
|
});
|
|
if (!result.ok || result.data.kind !== "guilds") {
|
|
if (!result.ok) throw new Error(result.error.code);
|
|
return [];
|
|
}
|
|
return result.data.page.items.map((guild) => ({
|
|
id: `guild-${guild.id}`,
|
|
title: guild.name,
|
|
description: `${guild.memberCount} members`,
|
|
href: guild.href,
|
|
capability: anyCapability(PERMS.USERS_VIEW),
|
|
}));
|
|
},
|
|
async tickets(context, term, limit) {
|
|
const result = await peopleSupportQuery.run(context, {
|
|
routeId: "people.support.tickets",
|
|
list: { search: term, pageSize: limit, offset: 0, sort: "id" },
|
|
});
|
|
if (!result.ok || result.data.kind !== "tickets") {
|
|
if (!result.ok) throw new Error(result.error.code);
|
|
return [];
|
|
}
|
|
return result.data.page.items.map((ticket) => ({
|
|
id: `${ticket.source}-ticket-${ticket.id}`,
|
|
title: ticket.subject,
|
|
description: `${ticket.status} · ${ticket.creatorUsername ?? "unknown"}`,
|
|
href: ticket.href,
|
|
capability: anyCapability(PERMS.TICKETS_VIEW, PERMS.MOD_TICKETS_VIEW),
|
|
}));
|
|
},
|
|
};
|
|
|
|
export const PEOPLE_SEARCH_PROVIDERS =
|
|
createPeopleSearchProviders(productionAdapters);
|