Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,216 @@
/**
* `createContactsPlugin` — `PluginDescriptor` factory for
* `@suaveplan/api`. Validates options, constructs the three SurrealDB
* repositories, registers them in the host `ServiceRegistry` + Hono
* `ContextVariableMap`, applies the bundled SurrealQL schema, and
* mounts the `/contacts` + `/groups` route trees via
* `definePluginRoutes`.
*/
import type { OpenAPIHono } from "@hono/zod-openapi";
import { definePluginRoutes } from "@suaveplan/api-openapi";
import {
definePlugin,
type PluginContext,
type PluginDescriptor,
} from "@suaveplan/api-plugin";
import {
CONTACTS_API_CODES,
ContactsApiError,
} from "../error-codes/error-codes.js";
import {
SurrealContactRepository,
SurrealGroupRepository,
SurrealMembershipRepository,
} from "../repositories/index.js";
import { buildContactRoutes } from "../routes/contacts/build-routes.js";
import { buildGroupRoutes } from "../routes/groups/build-routes.js";
import { ContactsPluginOptionsSchema } from "../schemas/options.js";
import type {
ContactsPluginConfig,
ContactsPluginOptions,
} from "../types/types.js";
import { applyContactsSchema } from "./schema.js";
import "../context/context.js";
import "../services/registry-augmentation.js";
/**
* Factory — returns the typed `PluginDescriptor`.
*
* Zod-validates `options` at construction time; invalid options
* surface as `ContactsApiError(INVALID_INPUT)` with the `ZodError`
* carried as `cause`. The descriptor declares
* `dependencies: ["database", "auth"]` so the host topo-sorts after
* `@suaveplan/api-plugin-database` and `@suaveplan/api-plugin-auth`.
*/
export function createContactsPlugin(
options: ContactsPluginOptions = {}
): PluginDescriptor<ContactsPluginConfig> {
const parsed = ContactsPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new ContactsApiError(
CONTACTS_API_CODES.INVALID_INPUT,
"createContactsPlugin received invalid options",
{ cause: parsed.error, context: { statusCode: 500 } }
);
}
const resolved: ContactsPluginConfig = {
routePrefix: parsed.data.routePrefix,
maxImportBatchSize: parsed.data.maxImportBatchSize,
maxGroupDepth: parsed.data.maxGroupDepth,
enforceGroupDepth: parsed.data.enforceGroupDepth,
applySchemaOnInit: parsed.data.applySchemaOnInit,
...(parsed.data.logger !== undefined
? { logger: parsed.data.logger }
: {}),
};
return definePlugin<ContactsPluginConfig>({
name: "contacts",
version: "0.1.0",
description:
"Contacts and groups HTTP plugin for @suaveplan/api. Mounts /contacts and /groups on a Hono OpenAPIHono backed by SurrealDB.",
dependencies: ["database", "auth"],
defaults: resolved,
tags: ["contacts", "groups", "audience"],
onInit: async (ctx) => runOnInit(ctx, resolved),
});
}
export async function runOnInit(
ctx: PluginContext<ContactsPluginConfig>,
config: ContactsPluginConfig
): Promise<void> {
const app = ctx.getService("app");
if (app === undefined) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"contacts plugin requires the `app` service in ServiceRegistry",
{ context: { missing: ["app"], statusCode: 500 } }
);
}
const db = ctx.getService("db");
if (db === undefined) {
throw new ContactsApiError(
CONTACTS_API_CODES.SERVICE_NOT_AVAILABLE,
"contacts plugin requires the `db` service — load @suaveplan/api-plugin-database first",
{ context: { missing: ["db"], statusCode: 500 } }
);
}
const openAPIRegistry = ctx.getService("openAPIRegistry");
const migrationRunner = ctx.getService("migrationRunner");
const logger = config.logger ?? ctx.logger;
if (config.applySchemaOnInit) {
await applyContactsSchema({
...(migrationRunner !== undefined ? { migrationRunner } : {}),
db,
});
}
const contactRepository = new SurrealContactRepository(db);
const groupRepository = new SurrealGroupRepository(db, {
maxGroupDepth: config.maxGroupDepth,
enforceGroupDepth: config.enforceGroupDepth,
});
const membershipRepository = new SurrealMembershipRepository(db);
ctx.registerService("contactRepository", contactRepository);
ctx.registerService("groupRepository", groupRepository);
ctx.registerService("membershipRepository", membershipRepository);
// Inject the repositories + eventBus into every Hono request context.
const eventBus = ctx.eventBus;
const prefix = config.routePrefix;
const mountPattern = prefix === "/" ? "*" : `${prefix}/*`;
app.use(mountPattern, async (c, next) => {
c.set("contactRepository", contactRepository);
c.set("groupRepository", groupRepository);
c.set("membershipRepository", membershipRepository);
c.set("eventBus", eventBus);
await next();
});
registerRoutes({
app,
openAPIRegistry,
routePrefix: prefix,
maxImportBatchSize: config.maxImportBatchSize,
logger,
});
}
interface RegisterRoutesArgs {
readonly app: OpenAPIHono;
readonly openAPIRegistry: ReturnType<
PluginContext<ContactsPluginConfig>["getService"]
>;
readonly routePrefix: string;
readonly maxImportBatchSize: number;
readonly logger: {
warn: (msg: string, ctx?: Readonly<Record<string, unknown>>) => void;
};
}
function registerRoutes(args: RegisterRoutesArgs): void {
const { app, openAPIRegistry, routePrefix, maxImportBatchSize, logger } =
args;
const entries = [
...buildContactRoutes({ maxImportBatchSize }),
...buildGroupRoutes(),
];
if (openAPIRegistry !== undefined) {
const reg = openAPIRegistry as {
getRoutes: () => Array<{ path: string }>;
};
const collisionPrefix =
routePrefix === "/" ? "/contacts" : `${routePrefix}/contacts`;
const existing = reg
.getRoutes()
.filter((r) => r.path.startsWith(collisionPrefix));
if (existing.length > 0) {
throw new ContactsApiError(
CONTACTS_API_CODES.ROUTE_PREFIX_COLLISION,
`Route prefix '${routePrefix}' collides with previously registered routes`,
{
context: {
prefix: routePrefix,
existing: existing.map((r) => r.path),
statusCode: 500,
},
}
);
}
const prefixedEntries = entries.map((entry) => ({
...entry,
route: {
...entry.route,
path: `${routePrefix === "/" ? "" : routePrefix}${entry.route.path}`,
},
}));
definePluginRoutes(prefixedEntries, {
app,
registry: openAPIRegistry as Parameters<
typeof definePluginRoutes
>[1]["registry"],
pluginName: "contacts",
});
return;
}
logger.warn(
"contacts plugin: openAPIRegistry not registered — routes will not appear in /openapi.json"
);
for (const entry of entries) {
const method = entry.route.method as
| "get"
| "post"
| "put"
| "patch"
| "delete";
const path = `${routePrefix === "/" ? "" : routePrefix}${entry.route.path}`;
app[method](
path,
async (c) => (await entry.handler(c as never)) as Response
);
}
}