/** * `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 { 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({ 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, config: ContactsPluginConfig ): Promise { 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["getService"] >; readonly routePrefix: string; readonly maxImportBatchSize: number; readonly logger: { warn: (msg: string, ctx?: Readonly>) => 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 ); } }