thatcher 1.0.54 → 1.0.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.54",
3
+ "version": "1.0.56",
4
4
  "description": "A config-driven application framework for building data-intensive web apps without code.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -19,6 +19,22 @@ export function getSpec(name, configEngine) {
19
19
  }
20
20
  }
21
21
 
22
+ export function getAllEntityNames(configEngine) {
23
+ if (!configEngine) {
24
+ const g = globalThis.__thatcherConfigEngine;
25
+ if (g) configEngine = g;
26
+ }
27
+ if (!configEngine) return [];
28
+ try {
29
+ return configEngine.getAllEntities().filter(name => {
30
+ const spec = getSpec(name, configEngine);
31
+ return spec && !spec.embedded && !spec.system_entity;
32
+ });
33
+ } catch {
34
+ return [];
35
+ }
36
+ }
37
+
22
38
  export function getNavItems(configEngine) {
23
39
  try {
24
40
  const allEntities = configEngine.getAllEntities();
@@ -162,12 +162,13 @@ export async function list(entity, where = {}, options = {}) {
162
162
  rows = applyVisibility(spec, rows, where, options);
163
163
 
164
164
  // Row-access scoping: when a caller passes options.user AND the entity declares
165
- // rowAccess, restrict the rows to what that user may see (their assigned cases,
166
- // their team, etc.). Opt-in -- no user means the read is unchanged, so internal
167
- // and admin callers are unaffected. This makes a config row_access spec actually
168
- // enforced on the read path (previously list() took no user, so the spec was
169
- // inert and a scoped enquiry would leak every row).
170
- if (options.user && (spec.rowAccess || spec.row_access)) {
165
+ // rowAccess OR an organization_id field (multi-tenancy), restrict the rows to
166
+ // what that user may see (their assigned cases, their team, their organization).
167
+ // Opt-in -- no user means the read is unchanged, so internal and admin callers
168
+ // are unaffected. This makes a config row_access spec actually enforced on the
169
+ // read path (previously list() took no user, so the spec was inert and a scoped
170
+ // enquiry would leak every row).
171
+ if (options.user && (spec.rowAccess || spec.row_access || spec.fields?.organization_id)) {
171
172
  const { permissionService } = await import('../services/permission.service.js');
172
173
  rows = permissionService.filterRecords(options.user, spec, rows);
173
174
  }
@@ -203,7 +204,7 @@ export async function count(entity, where = {}, options = {}) {
203
204
  const tbl = tableName(entity);
204
205
  let rows = unwrap(await applyWhere(client().from(tbl).select('*'), where), 'count');
205
206
  rows = applyVisibility(spec, rows, where, options);
206
- if (options.user && (spec.rowAccess || spec.row_access)) {
207
+ if (options.user && (spec.rowAccess || spec.row_access || spec.fields?.organization_id)) {
207
208
  const { permissionService } = await import('../services/permission.service.js');
208
209
  rows = permissionService.filterRecords(options.user, spec, rows);
209
210
  }
@@ -217,10 +218,15 @@ export async function listWithPagination(entity, where = {}, page = 1, pageSize
217
218
  return { items, pagination: { page: finalPage, pageSize, total, totalPages: Math.ceil(total / pageSize) } };
218
219
  }
219
220
 
220
- export async function get(entity, id) {
221
+ export async function get(entity, id, options = {}) {
221
222
  const tbl = tableName(entity);
222
223
  const row = unwrap(await client().from(tbl).select('*').eq('id', id).maybeSingle(), 'get');
223
224
  if (!row) return null;
225
+ const spec = specOf(entity);
226
+ if (options.user && (spec.rowAccess || spec.row_access || spec.fields?.organization_id)) {
227
+ const { permissionService } = await import('../services/permission.service.js');
228
+ if (!permissionService.checkRowAccess(options.user, spec, row)) return null;
229
+ }
224
230
  const [withDisplay] = await attachRefDisplays(entity, [row]);
225
231
  return withDisplay;
226
232
  }
@@ -242,6 +248,9 @@ export async function create(entity, data, user) {
242
248
  updated_at: 0,
243
249
  status: data.status || RECORD_STATUS.ACTIVE,
244
250
  };
251
+ if (spec.fields?.organization_id && !record.organization_id && user?.organization_id) {
252
+ record.organization_id = user.organization_id;
253
+ }
245
254
  for (const [key, field] of Object.entries(spec.fields || {})) {
246
255
  if (field.auto === 'uuid' && !record[key]) record[key] = genId();
247
256
  if (field.auto === 'timestamp' && !record[key]) record[key] = now();
@@ -14,15 +14,46 @@
14
14
  // central infrastructure, not a candidate for deprecation.
15
15
  import { LRUCache, deepFreeze, deepClone, recursiveResolve } from './config-helpers.js';
16
16
 
17
+ const ORGANIZATION_ENTITY_DEFAULT = {
18
+ label: 'Organization',
19
+ label_plural: 'Organizations',
20
+ system_entity: true,
21
+ fields: {
22
+ name: { type: 'text', required: true, label: 'Name' },
23
+ },
24
+ };
25
+
26
+ const USER_ORGANIZATION_ENTITY_DEFAULT = {
27
+ label: 'Organization Membership',
28
+ label_plural: 'Organization Memberships',
29
+ system_entity: true,
30
+ fields: {
31
+ user_id: { type: 'ref', ref: 'user', required: true },
32
+ organization_id: { type: 'ref', ref: 'organization', required: true },
33
+ },
34
+ };
35
+
36
+ function withMultiTenancyDefaults(masterConfig) {
37
+ if (!masterConfig?.system?.multi_tenancy?.enabled) return masterConfig;
38
+ const entities = { ...(masterConfig.entities || {}) };
39
+ if (!entities.organization) entities.organization = ORGANIZATION_ENTITY_DEFAULT;
40
+ if (!entities.user_organization) entities.user_organization = USER_ORGANIZATION_ENTITY_DEFAULT;
41
+ return { ...masterConfig, entities };
42
+ }
43
+
17
44
  export class ConfigGeneratorEngine {
18
45
  constructor(masterConfig) {
19
46
  if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
20
- this.masterConfig = deepFreeze(masterConfig);
47
+ this.masterConfig = deepFreeze(withMultiTenancyDefaults(masterConfig));
21
48
  this.specCache = new LRUCache(100);
22
49
  this.debugMode = false;
23
50
  this._plugins = new Map();
24
51
  }
25
52
 
53
+ isMultiTenancyEnabled() {
54
+ return this.masterConfig?.system?.multi_tenancy?.enabled === true;
55
+ }
56
+
26
57
  registerPlugin(entityName, plugin = {}) {
27
58
  if (!entityName || typeof entityName !== 'string') {
28
59
  throw new Error('[ConfigGeneratorEngine] registerPlugin: entityName required');
@@ -315,6 +346,9 @@ export class ConfigGeneratorEngine {
315
346
  updated_at: { type: 'timestamp', readonly: true },
316
347
  status: { type: 'text' },
317
348
  };
349
+ if (this.isMultiTenancyEnabled() && !spec.embedded && !spec.system_entity) {
350
+ SYSTEM_FIELDS.organization_id = { type: 'ref', ref: 'organization', required: true, readonly: true, hidden: true };
351
+ }
318
352
  for (const [key, field] of Object.entries(SYSTEM_FIELDS)) {
319
353
  if (!(key in allFields)) allFields[key] = field;
320
354
  }
@@ -106,6 +106,14 @@ export function createServer(options) {
106
106
  return await handleCsvImport(req, res, entity, thatcher, configEngine);
107
107
  }
108
108
 
109
+ if (req.method === 'POST' && entity === 'organization' && id === 'switch' && !action) {
110
+ return await handleSwitchOrganization(req, res, thatcher, configEngine);
111
+ }
112
+
113
+ if (req.method === 'GET' && entity === 'organization' && id === 'memberships' && !action) {
114
+ return await handleListMemberships(req, res, thatcher, configEngine);
115
+ }
116
+
109
117
  // Check if user has custom route for this
110
118
  const userRoutePath = path.join(process.cwd(), 'app/api', ...parts, 'route.js');
111
119
  const routeExists = await fileExists(userRoutePath);
@@ -333,6 +341,95 @@ async function handleGenericCrud(req, res, entity, id, action, thatcher, configE
333
341
  }
334
342
  }
335
343
 
344
+ async function handleListMemberships(req, res, thatcher, configEngineArg) {
345
+ const user = await resolveRequestUser(req);
346
+ if (!user) {
347
+ res.writeHead(401, { 'Content-Type': 'application/json' });
348
+ res.end(JSON.stringify({ error: 'Authentication required' }));
349
+ return;
350
+ }
351
+
352
+ let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
353
+ if (!configEngine) {
354
+ const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
355
+ configEngine = getConfigEngineSync();
356
+ }
357
+ if (!configEngine.isMultiTenancyEnabled?.()) {
358
+ res.writeHead(404);
359
+ res.end(JSON.stringify({ error: 'Multi-tenancy is not enabled' }));
360
+ return;
361
+ }
362
+
363
+ try {
364
+ const { list, get } = await import('../lib/busybase/store.js');
365
+ const memberships = await list('user_organization', { user_id: user.id });
366
+ const orgs = [];
367
+ for (const m of memberships) {
368
+ const org = await get('organization', m.organization_id);
369
+ if (org) orgs.push({ id: org.id, name: org.name });
370
+ }
371
+ res.writeHead(200, { 'Content-Type': 'application/json' });
372
+ res.end(JSON.stringify({ organizations: orgs, active: user.organization_id || null }));
373
+ } catch (err) {
374
+ apiLog.error(err.message);
375
+ res.writeHead(500);
376
+ res.end(JSON.stringify({ error: err.message }));
377
+ }
378
+ }
379
+
380
+ async function handleSwitchOrganization(req, res, thatcher, configEngineArg) {
381
+ const user = await resolveRequestUser(req);
382
+ if (!user) {
383
+ res.writeHead(401, { 'Content-Type': 'application/json' });
384
+ res.end(JSON.stringify({ error: 'Authentication required' }));
385
+ return;
386
+ }
387
+
388
+ let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
389
+ if (!configEngine) {
390
+ const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
391
+ configEngine = getConfigEngineSync();
392
+ }
393
+ if (!configEngine.isMultiTenancyEnabled?.()) {
394
+ res.writeHead(404);
395
+ res.end(JSON.stringify({ error: 'Multi-tenancy is not enabled' }));
396
+ return;
397
+ }
398
+
399
+ let body;
400
+ try {
401
+ body = await readBody(req);
402
+ } catch (e) {
403
+ res.writeHead(400);
404
+ res.end(JSON.stringify({ error: e.message }));
405
+ return;
406
+ }
407
+ const targetOrgId = typeof body === 'object' ? body?.organization_id : null;
408
+ if (!targetOrgId) {
409
+ res.writeHead(400);
410
+ res.end(JSON.stringify({ error: 'organization_id required' }));
411
+ return;
412
+ }
413
+
414
+ try {
415
+ const { list, update } = await import('../lib/busybase/store.js');
416
+ const memberships = await list('user_organization', { user_id: user.id });
417
+ const isMember = memberships.some(m => m.organization_id === targetOrgId);
418
+ if (!isMember) {
419
+ res.writeHead(403);
420
+ res.end(JSON.stringify({ error: 'Not a member of that organization' }));
421
+ return;
422
+ }
423
+ const updated = await update('user', user.id, { organization_id: targetOrgId });
424
+ res.writeHead(200, { 'Content-Type': 'application/json' });
425
+ res.end(JSON.stringify({ ok: true, organization_id: updated.organization_id }));
426
+ } catch (err) {
427
+ apiLog.error(err.message);
428
+ res.writeHead(500);
429
+ res.end(JSON.stringify({ error: err.message }));
430
+ }
431
+ }
432
+
336
433
  async function handleCsvImport(req, res, entity, thatcher, configEngineArg) {
337
434
  const user = await resolveRequestUser(req);
338
435
  if (!user) {
@@ -26,8 +26,17 @@ class PermissionService {
26
26
  return Array.isArray(allowed) && allowed.includes(user.role);
27
27
  }
28
28
 
29
+ checkOrganizationAccess(user, spec, record) {
30
+ if (!user) return false;
31
+ if (!spec.fields?.organization_id) return true;
32
+ if (record.organization_id == null) return true;
33
+ return record.organization_id === user.organization_id;
34
+ }
35
+
29
36
  checkRowAccess(user, spec, record) {
30
37
  if (!user) return false;
38
+ if (!this.checkOrganizationAccess(user, spec, record)) return false;
39
+
31
40
  const rowAccess = spec.rowAccess || spec.row_access;
32
41
  if (!rowAccess) return true;
33
42
 
@@ -12,14 +12,18 @@ function resultCard(item, entityType) {
12
12
  return `<div class="card-clean" style="margin-bottom:8px;cursor:pointer" data-navigate="/${entityType}/${item.id}"><div class="card-clean-body" style="padding:0.75rem"><div class="flex items-start justify-between"><div class="flex-1"><div class="flex items-center gap-2 mb-1"><span class="badge badge-sm bg-gray-100 text-gray-600">${typeLabel}</span>${sts}</div><div class="font-medium">${esc(title)}</div>${subtitle ? `<div class="text-xs text-gray-500 mt-0.5">${esc(subtitle)}</div>` : ''}</div><div class="text-xs text-gray-400">${date}</div></div></div></div>`;
13
13
  }
14
14
 
15
- function filterPanel(teams, stages) {
15
+ function entityLabel(name) {
16
+ return name.charAt(0).toUpperCase() + name.slice(1).replace(/_/g, ' ');
17
+ }
18
+
19
+ function filterPanel(teams, entityNames) {
16
20
  const teamOpts = teams.map(t => `<option value="${esc(t.id)}">${esc(t.name)}</option>`).join('');
17
- const stageOpts = stages.map(s => `<option value="${s}">${s.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}</option>`).join('');
18
- return `<div class="card-clean" style="margin-bottom:1.5rem"><div class="card-clean-body"><div class="grid grid-cols-1 md:grid-cols-4 gap-3"><div><label class="text-xs font-medium text-gray-600 block mb-1" for="search-query">Search</label><input type="text" id="search-query" class="input input-bordered input-sm w-full" placeholder="Search across all entities..."/></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-entity">Entity Type</label><select id="filter-entity" class="select select-bordered select-sm w-full"><option value="">All Types</option><option value="engagement">Engagements</option><option value="client">Clients</option><option value="rfi">RFIs</option><option value="review">Reviews</option><option value="user">Users</option></select></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-status">Status</label><select id="filter-status" class="select select-bordered select-sm w-full"><option value="">All Statuses</option><option value="active">Active</option><option value="pending">Pending</option><option value="completed">Completed</option><option value="archived">Archived</option></select></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-stage">Stage</label><select id="filter-stage" class="select select-bordered select-sm w-full"><option value="">All Stages</option>${stageOpts}</select></div></div><div class="grid grid-cols-1 md:grid-cols-4 gap-3 mt-3"><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-team">Team</label><select id="filter-team" class="select select-bordered select-sm w-full"><option value="">All Teams</option>${teamOpts}</select></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-from">Date From</label><input type="date" id="filter-from" class="input input-bordered input-sm w-full"/></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-to">Date To</label><input type="date" id="filter-to" class="input input-bordered input-sm w-full"/></div><div class="flex items-end"><button class="btn btn-primary btn-sm w-full" data-action="doSearch">Search</button></div></div></div></div>`;
21
+ const entityOpts = entityNames.map(e => `<option value="${esc(e)}">${esc(entityLabel(e))}</option>`).join('');
22
+ return `<div class="card-clean" style="margin-bottom:1.5rem"><div class="card-clean-body"><div class="grid grid-cols-1 md:grid-cols-4 gap-3"><div><label class="text-xs font-medium text-gray-600 block mb-1" for="search-query">Search</label><input type="text" id="search-query" class="input input-bordered input-sm w-full" placeholder="Search across all entities..."/></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-entity">Entity Type</label><select id="filter-entity" class="select select-bordered select-sm w-full"><option value="">All Types</option>${entityOpts}</select></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-status">Status</label><select id="filter-status" class="select select-bordered select-sm w-full"><option value="">All Statuses</option><option value="active">Active</option><option value="pending">Pending</option><option value="completed">Completed</option><option value="archived">Archived</option></select></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-team">Team</label><select id="filter-team" class="select select-bordered select-sm w-full"><option value="">All Teams</option>${teamOpts}</select></div></div><div class="grid grid-cols-1 md:grid-cols-4 gap-3 mt-3"><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-from">Date From</label><input type="date" id="filter-from" class="input input-bordered input-sm w-full"/></div><div><label class="text-xs font-medium text-gray-600 block mb-1" for="filter-to">Date To</label><input type="date" id="filter-to" class="input input-bordered input-sm w-full"/></div><div class="flex items-end"><button class="btn btn-primary btn-sm w-full" data-action="doSearch">Search</button></div></div></div></div>`;
19
23
  }
20
24
 
21
25
  export function renderAdvancedSearch(user, results = {}, options = {}) {
22
- const { teams = [], stages = [] } = options;
26
+ const { teams = [], entityNames = [] } = options;
23
27
  const allResults = [];
24
28
  for (const [entityType, items] of Object.entries(results)) {
25
29
  (items || []).forEach(item => allResults.push({ ...item, _type: entityType }));
@@ -33,11 +37,11 @@ export function renderAdvancedSearch(user, results = {}, options = {}) {
33
37
 
34
38
  const resultCards = allResults.length > 0
35
39
  ? allResults.map(r => resultCard(r, r._type)).join('')
36
- : emptyState('Enter a search query to find engagements, clients, RFIs, and reviews', 'search');
40
+ : emptyState('Enter a search query to find records across every entity', 'search');
37
41
 
38
- const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">Advanced Search</h1></div>${filterPanel(teams, stages)}<div class="flex items-center gap-2 mb-4"><span class="text-sm text-gray-500">${totalCount} result${totalCount !== 1 ? 's' : ''}</span>${countBadges}</div><div id="search-results">${resultCards}</div>`;
42
+ const content = `<div class="flex justify-between items-center mb-6"><h1 class="text-2xl font-bold">Advanced Search</h1></div>${filterPanel(teams, entityNames)}<div class="flex items-center gap-2 mb-4"><span class="text-sm text-gray-500">${totalCount} result${totalCount !== 1 ? 's' : ''}</span>${countBadges}</div><div id="search-results">${resultCards}</div>`;
39
43
 
40
- const searchScript = `window.doSearch=async function(){const q=document.getElementById('search-query')?.value||'';const entity=document.getElementById('filter-entity')?.value||'';const status=document.getElementById('filter-status')?.value||'';const stage=document.getElementById('filter-stage')?.value||'';const team=document.getElementById('filter-team')?.value||'';const from=document.getElementById('filter-from')?.value||'';const to=document.getElementById('filter-to')?.value||'';const params=new URLSearchParams();if(q)params.set('q',q);if(entity)params.set('entity',entity);if(status)params.set('status',status);if(stage)params.set('stage',stage);if(team)params.set('team',team);if(from)params.set('from',from);if(to)params.set('to',to);window.location='/search?'+params.toString()};document.getElementById('search-query')?.addEventListener('keydown',function(e){if(e.key==='Enter')doSearch()})`;
44
+ const searchScript = `window.doSearch=async function(){const q=document.getElementById('search-query')?.value||'';const entity=document.getElementById('filter-entity')?.value||'';const status=document.getElementById('filter-status')?.value||'';const team=document.getElementById('filter-team')?.value||'';const from=document.getElementById('filter-from')?.value||'';const to=document.getElementById('filter-to')?.value||'';const params=new URLSearchParams();if(q)params.set('q',q);if(entity)params.set('entity',entity);if(status)params.set('status',status);if(team)params.set('team',team);if(from)params.set('from',from);if(to)params.set('to',to);window.location='/search?'+params.toString()};document.getElementById('search-query')?.addEventListener('keydown',function(e){if(e.key==='Enter')doSearch()})`;
41
45
 
42
46
  return page(user, 'Search | Thatcher', [{ href: '/', label: 'Dashboard' }, { label: 'Search' }], content, [searchScript]);
43
47
  }
package/src/ui/layout.js CHANGED
@@ -140,6 +140,7 @@ export function nav(user, pathname = '') {
140
140
  <div class="user-dropdown-email">${esc(user?.email || '')}</div>
141
141
  <div class="user-dropdown-role" style="text-transform:capitalize">${esc(user?.role || '')}</div>
142
142
  </div>
143
+ <div id="org-switcher" style="display:none"></div>
143
144
  <a href="/api/auth/logout" class="user-dropdown-item">
144
145
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
145
146
  Sign out
@@ -208,11 +209,13 @@ schedule()})();
208
209
 
209
210
  const NOTIF_SCRIPT = `(function(){function loadNotifCount(){fetch('/api/notifications?unread=1',{credentials:'same-origin'}).then(function(r){return r.json()}).then(function(d){var n=(d.data||[]).length;var el=document.getElementById('notif-count');if(!el)return;if(n>0){el.textContent=n>99?'99+':n;el.style.display='block'}else{el.style.display='none'}}).catch(function(){})}loadNotifCount();setInterval(loadNotifCount,60000)})();`;
210
211
 
212
+ const ORG_SWITCHER_SCRIPT = `(function(){var loaded=false;function loadMemberships(){if(loaded)return;var el=document.getElementById('org-switcher');if(!el)return;fetch('/api/organization/memberships',{credentials:'same-origin'}).then(function(r){if(!r.ok)return null;return r.json()}).then(function(d){if(!d||!d.organizations||d.organizations.length<2){return}loaded=true;var wrap=document.createElement('div');wrap.className='user-dropdown-item';wrap.style.cursor='default';var select=document.createElement('select');select.id='org-switcher-select';select.style.width='100%';d.organizations.forEach(function(o){var opt=document.createElement('option');opt.value=o.id;opt.textContent=o.name;if(o.id===d.active)opt.selected=true;select.appendChild(opt)});wrap.appendChild(select);el.innerHTML='';el.appendChild(wrap);el.style.display='block';select.addEventListener('change',function(e){fetch('/api/organization/switch',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify({organization_id:e.target.value})}).then(function(r){if(r.ok)location.reload()})})}).catch(function(){})}document.getElementById('user-avatar')&&document.getElementById('user-avatar').addEventListener('click',loadMemberships)})();`;
213
+
211
214
  export function page(user, title, bc, content, scripts = []) {
212
215
  const authData = user ? JSON.stringify({ id: user.id, name: user.name, email: user.email, role: user.role }) : 'null'
213
216
  const authScript = `window.__AUTH__=${authData};`
214
217
  const body = `<div class="min-h-screen">${nav(user)}<main id="main-content" ${role.main} class="page-shell">${breadcrumb(bc)}${content}</main></div>`
215
- return generateHtml(title, body, [authScript, NOTIF_SCRIPT, ...scripts])
218
+ return generateHtml(title, body, [authScript, NOTIF_SCRIPT, ORG_SWITCHER_SCRIPT, ...scripts])
216
219
  }
217
220
 
218
221
  export function fullPage(user, title, content, scripts = []) {
@@ -1,6 +1,6 @@
1
1
  import { getUser, setCurrentRequest } from '@/engine.server.js';
2
2
  import { hasGoogleAuth } from '@/config/env.js';
3
- import { getSpec } from '@/config/spec-helpers.js';
3
+ import { getSpec, getAllEntityNames } from '@/config/spec-helpers.js';
4
4
  import { list, get } from '@/lib/busybase/store.js';
5
5
  import { renderLogin, renderDashboard, renderAccessDenied, renderPasswordReset, renderPasswordResetConfirm, REDIRECT } from '@/ui/renderer.js';
6
6
  import { renderClientDashboard, renderClientList } from '@/ui/client-renderer.js';
@@ -63,11 +63,13 @@ async function handleSearch(user, req) {
63
63
  const url = reqUrl(req);
64
64
  const q = url.searchParams.get('q') || '', entityFilter = url.searchParams.get('entity') || '', statusFilter = url.searchParams.get('status') || '';
65
65
  let teams = []; try { teams = await list('team', {}); } catch {}
66
+ const allEntityNames = getAllEntityNames().filter(eName => canList(user, eName));
67
+ const searchableEntities = entityFilter ? allEntityNames.filter(e => e === entityFilter) : allEntityNames;
66
68
  const results = {};
67
- for (const eName of (entityFilter ? [entityFilter] : ['engagement', 'client', 'rfi', 'review'])) {
68
- try { let items = await list(eName, {}); if (q) items = items.filter(i => JSON.stringify(i).toLowerCase().includes(q.toLowerCase())); if (statusFilter) items = items.filter(i => i.status === statusFilter); const spec = getSpec(eName); if (spec) items = resolveRefFields(items, spec); results[eName] = items.slice(0, 50); } catch {}
69
+ for (const eName of searchableEntities) {
70
+ try { let items = await list(eName, {}, { user }); if (q) items = items.filter(i => JSON.stringify(i).toLowerCase().includes(q.toLowerCase())); if (statusFilter) items = items.filter(i => i.status === statusFilter); const spec = getSpec(eName); if (spec) items = resolveRefFields(items, spec); results[eName] = items.slice(0, 50); } catch {}
69
71
  }
70
- return renderAdvancedSearch(user, results, { teams, stages: ['info_gathering', 'commencement', 'team_execution', 'partner_review', 'finalization', 'closeout'] });
72
+ return renderAdvancedSearch(user, results, { teams, entityNames: allEntityNames });
71
73
  }
72
74
  async function handleGenericEntityView(user, entityName, id) {
73
75
  const spec = getSpec(entityName); if (!spec) return null;
@@ -79,7 +81,7 @@ async function handleGenericEntityView(user, entityName, id) {
79
81
  return lazyEntityForm(entityName, null, resolvedSpec, user, true, await getRefOptions(resolvedSpec));
80
82
  }
81
83
  if (!canView(user, entityName)) return renderAccessDenied(user, entityName, 'view');
82
- const item = await get(entityName, id); if (!item) return null;
84
+ const item = await get(entityName, id, { user }); if (!item) return null;
83
85
  if (item.team_id && user.team_id && item.team_id !== user.team_id && !isPartner(user)) return renderAccessDenied(user, entityName, 'view');
84
86
  if (isClientUser(user) && user.client_id && item.client_id && item.client_id !== user.client_id) return renderAccessDenied(user, entityName, 'view');
85
87
  const [resolvedItem] = resolveRefFields([item], spec);
@@ -221,7 +223,7 @@ export async function handlePage(pathname, req, res) {
221
223
  const spec = getSpec(entityName); if (!spec) return null;
222
224
  if (isClientUser(user) && !canClientAccessEntity(user, entityName)) return renderAccessDenied(user, entityName, 'list');
223
225
  if (!canList(user, entityName)) return renderAccessDenied(user, entityName, 'list');
224
- let items = await list(entityName, {});
226
+ let items = await list(entityName, {}, { user });
225
227
  if (isClientUser(user) && user.client_id) items = items.filter(item => { if (item.client_id) return item.client_id === user.client_id; if (item.assigned_to) return item.assigned_to === user.id; return true; });
226
228
  items = resolveRefFields(items, spec);
227
229
  const params = reqUrl(req).searchParams;