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 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 20:49:50 +02:00
parent 3bdb396ba2
commit 2db050cc09
2 changed files with 16 additions and 4 deletions
+11 -2
View File
@@ -23,17 +23,26 @@ export interface OrgEvent {
export type OrgEventSink = (event: OrgEvent) => void; 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 { export interface MgmtPluginOptions {
db: Pool; db: Pool;
checkLimit?: CheckLimitFn; checkLimit?: CheckLimitFn;
emitOrgEvent?: OrgEventSink; emitOrgEvent?: OrgEventSink;
emitRoleEvent?: RoleEventSink;
} }
export function createMgmtRoutes(opts: MgmtPluginOptions) { export function createMgmtRoutes(opts: MgmtPluginOptions) {
const { db, checkLimit, emitOrgEvent } = opts; const { db, checkLimit, emitOrgEvent, emitRoleEvent } = opts;
return async function mgmtRoutes(app: FastifyInstance) { return async function mgmtRoutes(app: FastifyInstance) {
await app.register(createOrgRoutes(db, checkLimit, emitOrgEvent)); 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(createRuleRoutes(db));
await app.register(createUserRoutes(db, checkLimit)); await app.register(createUserRoutes(db, checkLimit));
await app.register(createGlobalRoutes(db)); await app.register(createGlobalRoutes(db));
+5 -2
View File
@@ -1,12 +1,12 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import type { Pool } from 'pg'; import type { Pool } from 'pg';
import type { CheckLimitFn } from '../plugin.js'; import type { CheckLimitFn, RoleEventSink } from '../plugin.js';
import { import {
getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID, getUser, getOrgScope, orgScopeExpr, hasPermission, SYSTEM_ORG_ID,
} from '../permissions.js'; } from '../permissions.js';
import { roleResponse, notFound, forbidden, unauthorized, badRequest } from '../schemas.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) { return async function roleRoutes(app: FastifyInstance) {
app.get('/roles', { app.get('/roles', {
schema: { schema: {
@@ -80,6 +80,7 @@ export function createRoleRoutes(db: Pool, checkLimit?: CheckLimitFn) {
[org_id, name, description ?? null], [org_id, name, description ?? null],
); );
const org = await db.query('SELECT name FROM morbac.orgs WHERE id = $1', [org_id]); 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 }); 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], [name ?? null, description ?? null, id],
); );
if (!rows[0]) return reply.code(404).send({ error: 'Not found' }); if (!rows[0]) return reply.code(404).send({ error: 'Not found' });
emitRoleEvent?.({ action: 'updated', role: rows[0], actorId: user.id });
return rows[0]; 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; 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]); 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(); return reply.code(204).send();
}); });