From 2db050cc099dd97b02cb6b46c49f4c58161c5ffb Mon Sep 17 00:00:00 2001 From: Marc Villain Date: Sat, 20 Jun 2026 20:49:50 +0200 Subject: [PATCH] feat(search): global spotlight search across all records, pages and actions Postgres-native global search (tsvector + pg_trgm, no external service) with a config-driven index so any table self-registers. Per-row visibility via morbac RLS. New reusable @crudy/search package for the spotlight UI; core wires it into the bar (Ctrl/Cmd K) with no hardcoded command list. Backend - 0008_search.sql: app.search_index + GIN indexes, app.searchable_sources registry, generic trigger, app.search_register/reindex_source helpers, the app.search(q,limit) RPC (websearch_to_tsquery, word_similarity, RLS), grants. - BackendModule.searchables: modules declare searchable tables; registered at boot in syncModules with identifier validation. - /search data route; org/role reindex via new RoleEventSink + search-reindex. - Core entities registered: users, orgs, roles, service connectors, emails. Frontend - @crudy/search: presentational Spotlight (dialog, hotkey, grouped results, real anchor links, in-modal shortcut hints). - command-registry is pure logic; pages/actions derive from core-routes, the settings tab registry, and module navItems/settingsTabs/crudModels/commands. - CrudModelDef yields page + create action; generic FrontendModule.commands for any developer action. Floating-sidebar top-right items render separately. Modules: dashboard, license-admin (customers, plans), email-templates, service-connectors register their searchables and CRUD models. Co-Authored-By: Claude Opus 4.8 --- src/plugin.ts | 13 +++++++++++-- src/routes/roles.ts | 7 +++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index ff097a4..02de0bf 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -23,17 +23,26 @@ export interface OrgEvent { export type OrgEventSink = (event: OrgEvent) => void; +export interface RoleEvent { + action: 'created' | 'updated' | 'deleted'; + role: { id: string; org_id?: string; name?: string; description?: string | null }; + actorId: string | null; +} + +export type RoleEventSink = (event: RoleEvent) => void; + export interface MgmtPluginOptions { db: Pool; checkLimit?: CheckLimitFn; emitOrgEvent?: OrgEventSink; + emitRoleEvent?: RoleEventSink; } export function createMgmtRoutes(opts: MgmtPluginOptions) { - const { db, checkLimit, emitOrgEvent } = opts; + const { db, checkLimit, emitOrgEvent, emitRoleEvent } = opts; return async function mgmtRoutes(app: FastifyInstance) { await app.register(createOrgRoutes(db, checkLimit, emitOrgEvent)); - await app.register(createRoleRoutes(db, checkLimit)); + await app.register(createRoleRoutes(db, checkLimit, emitRoleEvent)); await app.register(createRuleRoutes(db)); await app.register(createUserRoutes(db, checkLimit)); await app.register(createGlobalRoutes(db)); diff --git a/src/routes/roles.ts b/src/routes/roles.ts index 0972402..92f6c0f 100644 --- a/src/routes/roles.ts +++ b/src/routes/roles.ts @@ -1,12 +1,12 @@ import type { FastifyInstance } from 'fastify'; import type { Pool } from 'pg'; -import type { CheckLimitFn } from '../plugin.js'; +import type { CheckLimitFn, RoleEventSink } from '../plugin.js'; import { getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID, } from '../permissions.js'; import { roleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.js'; -export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn) { +export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn, emitRoleEvent?: RoleEventSink) { return async function roleRoutes(app: FastifyInstance) { app.get('/roles', { schema: { @@ -80,6 +80,7 @@ export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn) { [org_id, name, description ?? null], ); const org = await db.query('SELECT name FROM morbac.orgs WHERE id = $1', [org_id]); + emitRoleEvent?.({ action: 'created', role: rows[0], actorId: user.id }); return reply.code(201).send({ ...rows[0], org_name: org.rows[0]?.name }); }); @@ -125,6 +126,7 @@ export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn) { [name ?? null, description ?? null, id], ); if (!rows[0]) return reply.code(404).send({ error: 'Not found' }); + emitRoleEvent?.({ action: 'updated', role: rows[0], actorId: user.id }); return rows[0]; }); @@ -153,6 +155,7 @@ export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn) { if (!await hasPermission(db, req, reply, roleOrg.rows[0].org_id, 'delete', 'roles')) return reply; await db.query('DELETE FROM morbac.roles WHERE id = $1', [id]); + emitRoleEvent?.({ action: 'deleted', role: { id, org_id: roleOrg.rows[0].org_id }, actorId: user.id }); return reply.code(204).send(); });