Files
EpicNext-Cms/src/features/housekeeping/domains/people/search.ts
T

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);