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:
+11
-2
@@ -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));
|
||||
|
||||
+5
-2
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user