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