thatcher 1.0.7 → 1.0.34
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/README.md +4 -16
- package/package.json +12 -18
- package/src/adapters/google-auth.js +4 -43
- package/src/adapters/google-drive.js +12 -67
- package/src/app/api/audit/dashboard/route.js +14 -11
- package/src/app/api/audit/logs/route.js +2 -2
- package/src/app/api/audit/permissions/[id]/route.js +6 -3
- package/src/app/api/audit/permissions/route.js +10 -7
- package/src/app/api/audit/permissions/stats/route.js +8 -5
- package/src/app/api/audit/route.js +4 -4
- package/src/app/api/audit/stats/route.js +1 -1
- package/src/app/api/auth/google/callback/route.js +5 -2
- package/src/app/api/auth/google/route.js +4 -1
- package/src/app/api/auth/login/route.js +5 -2
- package/src/app/api/auth/mwr-bridge/route.js +11 -21
- package/src/app/api/auth/password-reset/route.js +15 -14
- package/src/app/api/cron/trigger/route.js +4 -1
- package/src/app/api/debug/[[...path]]/route.js +6 -5
- package/src/app/api/debug/sqlite/route.js +7 -12
- package/src/app/api/debug/sync/route.js +5 -5
- package/src/app/api/domains/route.js +4 -1
- package/src/app/api/email/allocate/batch/route.js +19 -29
- package/src/app/api/email/allocate/route.js +16 -17
- package/src/app/api/email/receive/route.js +31 -42
- package/src/app/api/email/send/route.js +28 -16
- package/src/app/api/email/unallocated/route.js +10 -13
- package/src/app/api/files/[id]/route.js +4 -1
- package/src/app/api/formula/[[...path]]/route.js +0 -4
- package/src/app/api/health/route.js +10 -9
- package/src/app/api/metrics/route.js +1 -1
- package/src/cli.js +4 -4
- package/src/config/config-loader.js +1 -21
- package/src/config/constants.js +0 -35
- package/src/config/env.js +0 -30
- package/src/config/spec-helpers.js +6 -95
- package/src/engine.js +23 -0
- package/src/engine.server.js +8 -78
- package/src/index.js +42 -67
- package/src/lib/alert-manager.js +7 -3
- package/src/lib/api-helpers.js +4 -3
- package/src/lib/api.js +10 -10
- package/src/lib/audit-logger-enhanced.js +77 -83
- package/src/lib/auth-middleware.js +1 -32
- package/src/lib/auth-route-helpers.js +5 -0
- package/src/lib/busybase-adapter.js +0 -5
- package/src/lib/busybase-audit-reads.js +142 -0
- package/src/lib/busybase-audit.js +41 -0
- package/src/lib/busybase-lucia-adapter.js +72 -0
- package/src/lib/busybase-store.js +338 -0
- package/src/lib/config-field-helpers.js +0 -17
- package/src/lib/config-generator-engine.js +19 -36
- package/src/lib/config-helpers.js +0 -21
- package/src/lib/crud-action-helpers.js +2 -2
- package/src/lib/crud-factory.js +1 -16
- package/src/lib/crud-handlers.js +17 -52
- package/src/lib/csrf-protection.js +5 -2
- package/src/lib/date-utils.js +3 -127
- package/src/lib/debug-registry.js +0 -5
- package/src/lib/domain-loader.js +4 -1
- package/src/lib/email-sender.js +25 -23
- package/src/lib/error-handler.js +5 -41
- package/src/lib/error-recovery.js +10 -7
- package/src/lib/error-resilience.js +7 -5
- package/src/lib/events-engine.js +9 -6
- package/src/lib/export-sink.js +0 -5
- package/src/lib/field-iterator.js +7 -76
- package/src/lib/field-registry.js +0 -22
- package/src/lib/generic-crud-handler.js +0 -16
- package/src/lib/hook-engine.js +5 -53
- package/src/lib/hot-reload/checkpoint.js +5 -2
- package/src/lib/hot-reload/promise-container.js +7 -3
- package/src/lib/hot-reload/route-wrapper.js +4 -1
- package/src/lib/hot-reload/supervisor.js +6 -3
- package/src/lib/hyperformula-service.js +0 -5
- package/src/lib/id-helpers.js +10 -0
- package/src/lib/index.js +4 -7
- package/src/lib/log-aggregator.js +0 -1
- package/src/lib/logger.js +0 -15
- package/src/lib/metrics-collector.js +2 -46
- package/src/lib/next-compat.js +15 -1
- package/src/lib/observability-bootstrap.js +0 -5
- package/src/lib/perf-monitor.js +4 -1
- package/src/lib/perf-profiler.js +0 -5
- package/src/lib/query-string-adapter.js +5 -47
- package/src/lib/realtime-server.js +4 -26
- package/src/lib/request-tracing.js +0 -5
- package/src/lib/resource-monitor.js +4 -1
- package/src/lib/response-formatter.js +0 -38
- package/src/lib/route-helpers.js +3 -1
- package/src/lib/safe-json.js +5 -1
- package/src/lib/server-bootstrap.js +10 -9
- package/src/lib/stage-pipeline.js +2 -2
- package/src/lib/state-transport-client.js +4 -1
- package/src/lib/static-server.js +2 -2
- package/src/lib/status-helpers.js +2 -51
- package/src/lib/tracing.js +0 -5
- package/src/lib/utils.js +6 -67
- package/src/lib/validate.js +5 -88
- package/src/lib/validators.js +0 -38
- package/src/lib/with-audit-logging.js +1 -1
- package/src/lib/workflow-engine.js +10 -77
- package/src/lib/xstate-workflow-engine.js +3 -9
- package/src/plugins/index.js +5 -3
- package/src/server/server.js +26 -27
- package/src/services/collaborator-role.service.js +21 -77
- package/src/services/email-sender.js +7 -34
- package/src/services/notification-engine.js +12 -33
- package/src/services/permission.service.js +10 -73
- package/src/ui/advanced-search-renderer.js +3 -2
- package/src/ui/advanced-widgets.js +1 -1
- package/src/ui/auth-pages.js +10 -9
- package/src/ui/auth-styles.js +1 -1
- package/src/ui/client.js +1 -1
- package/src/ui/component-engine.js +1 -1
- package/src/ui/dashboard-renderer.js +5 -5
- package/src/ui/dialog-engine.js +6 -9
- package/src/ui/dialog-factory.js +2 -2
- package/src/ui/engagement-cards.js +7 -7
- package/src/ui/engagement-dialogs.js +2 -3
- package/src/ui/engagement-grid-renderer.js +20 -19
- package/src/ui/entity-renderer.js +56 -42
- package/src/ui/file-dialogs.js +25 -9
- package/src/ui/flexup-report-renderer.js +21 -18
- package/src/ui/format-helpers.js +27 -28
- package/src/ui/highlight-threading-renderer.js +9 -9
- package/src/ui/job-management-renderer.js +18 -16
- package/src/ui/layout.js +51 -25
- package/src/ui/ml-console-renderer.js +15 -16
- package/src/ui/monitoring-dashboard-client.js +9 -8
- package/src/ui/notifications-renderer.js +11 -10
- package/src/ui/page-handler-admin.js +32 -23
- package/src/ui/page-handler-helpers.js +27 -23
- package/src/ui/page-handler-reviews.js +71 -35
- package/src/ui/page-handler-rfi.js +28 -14
- package/src/ui/page-handler.js +65 -48
- package/src/ui/picker-dialogs.js +26 -19
- package/src/ui/render-helpers.js +84 -46
- package/src/ui/review-comparison-renderer.js +8 -7
- package/src/ui/review-detail-panels.js +8 -9
- package/src/ui/review-detail-renderer.js +3 -3
- package/src/ui/review-detail-script.js +13 -12
- package/src/ui/review-mwr-renderer.js +6 -5
- package/src/ui/review-renderer.js +7 -6
- package/src/ui/review-widgets.js +3 -3
- package/src/ui/rfi-detail-renderer.js +37 -20
- package/src/ui/rfi-report-renderer.js +14 -9
- package/src/ui/settings-renderer-advanced.js +21 -18
- package/src/ui/settings-renderer-advanced2.js +20 -19
- package/src/ui/settings-renderer-teams.js +4 -4
- package/src/ui/settings-renderer.js +4 -4
- package/src/ui/spacing-system.js +5 -2
- package/src/ui/standalone-login.js +2 -2
- package/src/ui/styles.css +2 -1
- package/src/ui/styles2.css +75 -5
- package/src/ui/test-page.js +4 -4
- package/src/ui/widgets.js +20 -15
- package/src/lib/audit-logger.js +0 -193
- package/src/lib/database-core.js +0 -255
- package/src/lib/database-migrations.js +0 -350
- package/src/lib/db-backup.js +0 -97
- package/src/lib/error-boundary.js +0 -134
- package/src/lib/errors.js +0 -69
- package/src/lib/health-monitor.js +0 -134
- package/src/lib/index-optimizer.js +0 -96
- package/src/lib/query-engine-write.js +0 -221
- package/src/lib/query-engine.js +0 -446
- package/src/ui/collaboration-dialogs.js +0 -31
- package/src/ui/global-tags.js +0 -144
- package/src/ui/idle-logout.js +0 -156
- package/src/ui/letter-dialogs.js +0 -37
- package/src/ui/password-reset-page.js +0 -146
- package/src/ui/perf-renderer.js +0 -71
- package/src/ui/rfi-renderer.js +0 -194
- package/src/ui/validation-ui.js +0 -147
|
@@ -3,7 +3,7 @@ import { esc, TOAST_SCRIPT, emptyRow } from '@/ui/render-helpers.js';
|
|
|
3
3
|
|
|
4
4
|
function fmtDate(ts) {
|
|
5
5
|
if (!ts) return '-';
|
|
6
|
-
return new Date(typeof ts === 'number' && ts < 2e10 ? ts * 1000 : ts).toLocaleString();
|
|
6
|
+
return new Date(typeof ts === 'number' && ts < 2e10 ? ts * 1000 : ts).toLocaleString('en-ZA');
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
function notifRow(n) {
|
|
@@ -11,14 +11,14 @@ function notifRow(n) {
|
|
|
11
11
|
const typeLabels = { review_notification: 'Review', highlight_response: 'Highlight', rfi_response: 'RFI', general: 'General' };
|
|
12
12
|
const typeLabel = typeLabels[n.type] || n.type || '-';
|
|
13
13
|
const entityLink = n.entity_type === 'review' && n.entity_id
|
|
14
|
-
? `<a href="/review/${esc(n.entity_id)}" class="text-
|
|
14
|
+
? `<a href="/review/${esc(n.entity_id)}" class="text-sm" style="text-decoration:underline;color:var(--color-primary)">View</a>`
|
|
15
15
|
: '';
|
|
16
|
-
return `<tr class="hover${isRead ? '
|
|
17
|
-
<td class="text-xs text-base-content/
|
|
16
|
+
return `<tr class="hover${isRead ? ' is-read' : ''}" data-notif-id="${esc(n.id)}" data-notif-row="true">
|
|
17
|
+
<td class="text-xs text-base-content/60 w-32">${fmtDate(n.created_at)}</td>
|
|
18
18
|
<td class="text-xs"><span class="badge badge-flat-secondary">${esc(typeLabel)}</span></td>
|
|
19
19
|
<td class="text-sm max-w-md">${esc(n.message || '-')}</td>
|
|
20
20
|
<td>${entityLink}</td>
|
|
21
|
-
<td>${isRead ? '<span class="text-xs text-base-content/
|
|
21
|
+
<td>${isRead ? '<span class="text-xs text-base-content/70">Read</span>' : `<button data-action="markRead" data-args='["${esc(n.id)}"]' class="btn btn-ghost btn-xs">Mark read</button>`}</td>
|
|
22
22
|
</tr>`;
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -28,13 +28,13 @@ export function renderNotificationsPage(user, notifs = []) {
|
|
|
28
28
|
const content = `
|
|
29
29
|
<div class="page-header">
|
|
30
30
|
<h1 class="page-title">Notifications</h1>
|
|
31
|
-
<button data-action="markAllRead" class="btn btn-
|
|
31
|
+
<button data-action="markAllRead" class="btn btn-primary-clean btn-sm">Mark All Read</button>
|
|
32
32
|
</div>
|
|
33
33
|
<div class="card-clean">
|
|
34
34
|
<div class="card-clean-body">
|
|
35
35
|
<div class="table-wrap">
|
|
36
36
|
<table class="data-table">
|
|
37
|
-
<thead><tr><th>Time</th><th>Type</th><th>Message</th><th
|
|
37
|
+
<thead><tr><th>Time</th><th>Type</th><th>Message</th><th>Link</th><th>Actions</th></tr></thead>
|
|
38
38
|
<tbody>${rows}</tbody>
|
|
39
39
|
</table>
|
|
40
40
|
</div>
|
|
@@ -43,8 +43,9 @@ export function renderNotificationsPage(user, notifs = []) {
|
|
|
43
43
|
`;
|
|
44
44
|
|
|
45
45
|
const script = `${TOAST_SCRIPT}
|
|
46
|
-
window.markRead=async function(id){try{await fetch('/api/notifications',{method:'PATCH',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({id})});
|
|
47
|
-
window.markAllRead=async function(){try{await fetch('/api/notifications',{method:'PATCH',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({})});document.querySelectorAll('[data-notif-id]').forEach(function(row){row.classList.add('
|
|
46
|
+
window.markRead=async function(id){var row=document.querySelector('[data-notif-id="'+id+'"]');var b=row&&row.querySelector('button[data-action="markRead"]');if(b){if(b.disabled)return;b.disabled=true}try{await fetch('/api/notifications',{method:'PATCH',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({id})});if(row){row.classList.add('is-read');if(b)b.replaceWith(document.createTextNode('Read'))}var count=document.getElementById('notif-count');if(count){var c=parseInt(count.textContent||'0',10)-1;if(c<=0)count.style.display='none';else count.textContent=c}}catch(e){if(b)b.disabled=false;showToast('Error','error')}};
|
|
47
|
+
window.markAllRead=async function(){var b=document.querySelector('button[data-action="markAllRead"]');if(b){if(b.disabled)return;window.loadingBtn(b,true,'Marking...')}try{await fetch('/api/notifications',{method:'PATCH',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify({})});document.querySelectorAll('[data-notif-id]').forEach(function(row){row.classList.add('is-read');var btn=row.querySelector('button');if(btn)btn.replaceWith(document.createTextNode('Read'))});var count=document.getElementById('notif-count');if(count)count.style.display='none';showToast('All marked read','success')}catch(e){if(b)window.loadingBtn(b,false);showToast('Error','error')}};
|
|
48
|
+
document.querySelectorAll('[data-notif-row]').forEach(function(row){row.addEventListener('keydown',function(e){if(e.key==='Enter'||e.key===' '){e.preventDefault();var btn=row.querySelector('button[data-action="markRead"]');if(btn)btn.click();else window.markRead(row.getAttribute('data-notif-id'))}})});`;
|
|
48
49
|
|
|
49
|
-
return page(user, 'Notifications |
|
|
50
|
+
return page(user, 'Notifications | Thatcher', null, content, [script]);
|
|
50
51
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { list, get } from '@/lib/
|
|
1
|
+
import { list, get } from '@/lib/busybase-store.js';
|
|
2
2
|
import { renderAuditDashboard, renderSystemHealth, renderAccessDenied, generateHtml, REDIRECT } from '@/ui/renderer.js';
|
|
3
3
|
import { renderSettingsHome, renderSettingsSystem, renderSettingsUsers, renderSettingsTeams, renderSettingsRfiSections } from '@/ui/settings-renderer.js';
|
|
4
4
|
import { renderSettingsTemplates, renderSettingsNotifications, renderSettingsIntegrations, renderSettingsChecklists, renderSettingsRecreation, renderSettingsEntityTypes, renderSettingsEngagementTypes } from '@/ui/settings-renderer-advanced.js';
|
|
@@ -24,97 +24,106 @@ export async function handleAdminPage(normalized, segments, user) {
|
|
|
24
24
|
if (!isPartner(user)) return renderAccessDenied(user, 'admin', 'view');
|
|
25
25
|
|
|
26
26
|
if (normalized === '/admin/settings') {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
try {
|
|
28
|
+
const config = await getSystemConfig();
|
|
29
|
+
const counts = await getSettingsCounts();
|
|
30
|
+
return renderSettingsHome(user, config, counts);
|
|
31
|
+
} catch (err) {
|
|
32
|
+
console.error('Failed to load admin settings:', err);
|
|
33
|
+
return generateHtml('Settings', '<div class="empty-state"><div class="empty-state-title">Failed to load. Refresh.</div><div class="empty-state-desc">The settings could not be loaded. Please refresh the page to try again.</div></div>', []);
|
|
34
|
+
}
|
|
30
35
|
}
|
|
31
36
|
if (normalized === '/admin/settings/system') { const config = await getSystemConfig(); return renderSettingsSystem(user, config); }
|
|
32
|
-
if (normalized === '/admin/settings/users') { return renderSettingsUsers(user, list('user', {})); }
|
|
37
|
+
if (normalized === '/admin/settings/users') { return renderSettingsUsers(user, await list('user', {})); }
|
|
33
38
|
if (normalized === '/admin/settings/teams') {
|
|
34
39
|
const { renderSettingsTeams: _rt } = await lazyRenderer('settings-renderer.js');
|
|
35
|
-
return _rt(user, list('team', {}), list('user', {}));
|
|
40
|
+
return _rt(user, await list('team', {}), await list('user', {}));
|
|
36
41
|
}
|
|
37
42
|
if (normalized === '/admin/settings/rfi-sections') {
|
|
38
|
-
let sections = []; try { sections = list('rfi_section', {}); } catch {}
|
|
43
|
+
let sections = []; try { sections = await list('rfi_section', {}); } catch {}
|
|
39
44
|
return renderSettingsRfiSections(user, sections);
|
|
40
45
|
}
|
|
41
46
|
if (normalized === '/admin/settings/rfi-templates') {
|
|
42
47
|
const { renderSettingsRfiTemplates } = await lazyRenderer('settings-renderer-rfi-templates.js');
|
|
43
|
-
let templates = []; try { templates = list('rfi_template', {}); } catch {}
|
|
48
|
+
let templates = []; try { templates = await list('rfi_template', {}); } catch {}
|
|
44
49
|
return renderSettingsRfiTemplates(user, templates);
|
|
45
50
|
}
|
|
46
51
|
if (normalized === '/admin/settings/templates') {
|
|
47
|
-
let templates = []; try { templates = list('review_template', {}); } catch {}
|
|
52
|
+
let templates = []; try { templates = await list('review_template', {}); } catch {}
|
|
48
53
|
return renderSettingsTemplates(user, templates);
|
|
49
54
|
}
|
|
50
55
|
if (normalized === '/admin/settings/notifications') { return renderSettingsNotifications(user, await getSystemConfig()); }
|
|
51
56
|
if (normalized === '/admin/settings/integrations') {
|
|
52
57
|
let intSettings = {};
|
|
53
|
-
try { const
|
|
58
|
+
try { const row = await get('system_settings', 'integration_settings'); intSettings = row?.value ? JSON.parse(row.value) : {}; } catch {}
|
|
54
59
|
return renderSettingsIntegrations(user, intSettings);
|
|
55
60
|
}
|
|
56
61
|
if (normalized === '/admin/settings/checklists') {
|
|
57
|
-
let checklists = []; try { checklists = list('checklist', {}); } catch {}
|
|
62
|
+
let checklists = []; try { checklists = await list('checklist', {}); } catch {}
|
|
58
63
|
return renderSettingsChecklists(user, checklists);
|
|
59
64
|
}
|
|
60
65
|
if (normalized === '/admin/settings/recreation') {
|
|
61
66
|
return renderSettingsRecreation(user);
|
|
62
67
|
}
|
|
63
68
|
if (normalized === '/admin/settings/entity-types') {
|
|
64
|
-
let items = []; try { items = list('entity_type', {}); } catch {}
|
|
69
|
+
let items = []; try { items = await list('entity_type', {}); } catch {}
|
|
65
70
|
return renderSettingsEntityTypes(user, items);
|
|
66
71
|
}
|
|
67
72
|
if (normalized === '/admin/settings/engagement-types') {
|
|
68
|
-
let items = []; try { items = list('engagement_type', {}); } catch {}
|
|
73
|
+
let items = []; try { items = await list('engagement_type', {}); } catch {}
|
|
69
74
|
return renderSettingsEngagementTypes(user, items);
|
|
70
75
|
}
|
|
71
76
|
if (normalized === '/admin/settings/permissions') {
|
|
72
|
-
let permissions = []; try { permissions = list('permission', {}); } catch {}
|
|
77
|
+
let permissions = []; try { permissions = await list('permission', {}); } catch {}
|
|
73
78
|
return renderSettingsMwrPermissions(user, permissions);
|
|
74
79
|
}
|
|
75
80
|
if (normalized === '/admin/settings/review') { return renderSettingsReviewSettings(user, await getSystemConfig()); }
|
|
76
81
|
if (normalized === '/admin/settings/file-review') {
|
|
77
82
|
let frSettings = {};
|
|
78
|
-
try { const
|
|
83
|
+
try { const row = await get('system_settings', 'file_review_settings'); frSettings = row?.value ? JSON.parse(row.value) : {}; } catch {}
|
|
79
84
|
return renderSettingsFileReview(user, await getSystemConfig(), frSettings);
|
|
80
85
|
}
|
|
81
86
|
if (normalized.startsWith('/admin/settings/templates/') && segments.length === 4) {
|
|
82
87
|
const templateId = segments[3];
|
|
83
88
|
let template = {}, sections = [];
|
|
84
|
-
try { template = get('review_template', templateId) || {}; sections = list('review_template_section', {}).filter(s => s.review_template_id === templateId); } catch {}
|
|
89
|
+
try { template = await get('review_template', templateId) || {}; sections = (await list('review_template_section', {})).filter(s => s.review_template_id === templateId); } catch {}
|
|
85
90
|
return renderSettingsTemplateManage(user, template, sections);
|
|
86
91
|
}
|
|
87
92
|
if (normalized === '/admin/settings/checklists/manage') {
|
|
88
93
|
let checklists = [];
|
|
89
|
-
try {
|
|
94
|
+
try {
|
|
95
|
+
const cls = await list('checklist', {});
|
|
96
|
+
const allItems = await list('checklist_item', {});
|
|
97
|
+
checklists = cls.map(c => ({ ...c, total_items: allItems.filter(i => i.checklist_id === c.id).length }));
|
|
98
|
+
} catch {}
|
|
90
99
|
return renderChecklistsManagement(user, checklists);
|
|
91
100
|
}
|
|
92
101
|
if (normalized === '/admin/build-logs') {
|
|
93
|
-
let logs = []; try { logs = list('build_log', {}); } catch {}
|
|
102
|
+
let logs = []; try { logs = await list('build_log', {}); } catch {}
|
|
94
103
|
const content = renderBuildLogsContent(logs);
|
|
95
104
|
return generateHtml('Build Logs', content, []);
|
|
96
105
|
}
|
|
97
106
|
if (normalized === '/admin/health') { return renderSystemHealth(user, await getSystemHealth()); }
|
|
98
107
|
if (normalized === '/admin/settings/users/new') {
|
|
99
108
|
const { renderSettingsUserDetail } = await lazyRenderer('settings-user-team-renderer.js');
|
|
100
|
-
return renderSettingsUserDetail(user, {}, list('team', {}));
|
|
109
|
+
return renderSettingsUserDetail(user, {}, await list('team', {}));
|
|
101
110
|
}
|
|
102
111
|
if (segments.length === 4 && segments[2] === 'users' && segments[3] !== 'new') {
|
|
103
112
|
const { renderSettingsUserDetail: _rUD } = await lazyRenderer('settings-user-team-renderer.js');
|
|
104
|
-
return _rUD(user, get('user', segments[3]) || {}, list('team', {}));
|
|
113
|
+
return _rUD(user, await get('user', segments[3]) || {}, await list('team', {}));
|
|
105
114
|
}
|
|
106
115
|
if (normalized === '/admin/settings/teams/new') {
|
|
107
116
|
const { renderSettingsTeamDetail } = await lazyRenderer('settings-user-team-renderer.js');
|
|
108
|
-
return renderSettingsTeamDetail(user, {}, list('user', {}));
|
|
117
|
+
return renderSettingsTeamDetail(user, {}, await list('user', {}));
|
|
109
118
|
}
|
|
110
119
|
if (segments.length === 4 && segments[2] === 'teams' && segments[3] !== 'new') {
|
|
111
120
|
const { renderSettingsTeamDetail: _rTD } = await lazyRenderer('settings-user-team-renderer.js');
|
|
112
|
-
return _rTD(user, get('team', segments[3]) || {}, list('user', {}));
|
|
121
|
+
return _rTD(user, await get('team', segments[3]) || {}, await list('user', {}));
|
|
113
122
|
}
|
|
114
123
|
if (normalized === '/admin/jobs') {
|
|
115
124
|
let jobs = [];
|
|
116
125
|
try { const { getConfigEngineSync } = await import('@/lib/config-generator-engine.js'); const engine = getConfigEngineSync(); const config = engine.getConfig(); jobs = (config.jobs || []).map(j => ({ ...j, status: j.enabled === false ? 'disabled' : 'scheduled' })); } catch {}
|
|
117
|
-
let logs = []; try { logs = list('job_log', {}).slice(0, 20); } catch {}
|
|
126
|
+
let logs = []; try { logs = (await list('job_log', {})).slice(0, 20); } catch {}
|
|
118
127
|
return renderJobManagement(user, jobs, logs);
|
|
119
128
|
}
|
|
120
129
|
return null;
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { list, get, count } from '@/lib/
|
|
1
|
+
import { list, get, count } from '@/lib/busybase-store.js';
|
|
2
|
+
import { createLogger } from '@/lib/logger.js';
|
|
3
|
+
|
|
4
|
+
const log = createLogger('[PageHandlerHelpers]');
|
|
2
5
|
import { getConfigEngineSync } from '@/lib/config-generator-engine.js';
|
|
3
6
|
|
|
4
7
|
export function resolveEnumOptions(spec) {
|
|
@@ -14,28 +17,28 @@ export function resolveEnumOptions(spec) {
|
|
|
14
17
|
return resolvedSpec;
|
|
15
18
|
}
|
|
16
19
|
|
|
17
|
-
export function getRefOptions(spec) {
|
|
20
|
+
export async function getRefOptions(spec) {
|
|
18
21
|
const refOptions = {};
|
|
19
22
|
for (const [fieldKey, field] of Object.entries(spec.fields || {}).filter(([k, f]) => f.type === 'ref' && f.ref)) {
|
|
20
|
-
try { refOptions[fieldKey] = list(field.ref, {}).map(r => ({ value: r.id, label: r.name || r.title || r.label || r.email || r.id })); }
|
|
23
|
+
try { refOptions[fieldKey] = (await list(field.ref, {})).map(r => ({ value: r.id, label: r.name || r.title || r.label || r.email || r.id })); }
|
|
21
24
|
catch { refOptions[fieldKey] = []; }
|
|
22
25
|
}
|
|
23
26
|
return refOptions;
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
// busybase-store's list()/get() already attach `<field>_display` for ref fields (the
|
|
30
|
+
// old LEFT JOIN equivalent), so this is now a sync pass-through: it just guarantees a
|
|
31
|
+
// `_display` key exists (falling back to the raw id) without issuing any query.
|
|
26
32
|
export function resolveRefFields(items, spec) {
|
|
27
33
|
try {
|
|
28
34
|
const refFields = Object.entries(spec.fields || {}).filter(([k, f]) => f.type === 'ref' && f.ref);
|
|
29
35
|
if (!refFields.length) return items;
|
|
30
|
-
const refCaches = {};
|
|
31
|
-
for (const [fieldKey, field] of refFields) {
|
|
32
|
-
const refIds = [...new Set(items.map(i => i[fieldKey]).filter(Boolean))];
|
|
33
|
-
if (refIds.length) try { refCaches[fieldKey] = Object.fromEntries(list(field.ref, {}).map(r => [r.id, r.name || r.title || r.label || r.id])); } catch {}
|
|
34
|
-
}
|
|
35
36
|
return items.map(item => {
|
|
36
37
|
const resolved = { ...item };
|
|
37
38
|
for (const [fieldKey] of refFields) {
|
|
38
|
-
if (item[fieldKey] &&
|
|
39
|
+
if (item[fieldKey] && resolved[`${fieldKey}_display`] === undefined) {
|
|
40
|
+
resolved[`${fieldKey}_display`] = item[fieldKey];
|
|
41
|
+
}
|
|
39
42
|
}
|
|
40
43
|
return resolved;
|
|
41
44
|
});
|
|
@@ -44,11 +47,11 @@ export function resolveRefFields(items, spec) {
|
|
|
44
47
|
|
|
45
48
|
export async function getDashboardStats(user) {
|
|
46
49
|
try {
|
|
47
|
-
const engagements = list('engagement', {});
|
|
48
|
-
const clients = list('client', {});
|
|
50
|
+
const engagements = await list('engagement', {});
|
|
51
|
+
const clients = await list('client', {});
|
|
49
52
|
const clientMap = Object.fromEntries(clients.map(c => [c.id, c.name]));
|
|
50
|
-
const rfis = list('rfi', {});
|
|
51
|
-
const reviews = list('review', {});
|
|
53
|
+
const rfis = await list('rfi', {});
|
|
54
|
+
const reviews = await list('review', {});
|
|
52
55
|
const myRfis = user ? rfis.filter(r => r.assigned_to === user.id) : [];
|
|
53
56
|
const now = Math.floor(Date.now() / 1000);
|
|
54
57
|
const overdueRfis = rfis.filter(r => r.status !== 'closed' && r.due_date && r.due_date < now).map(r => ({ ...r, daysOverdue: Math.floor((now - r.due_date) / 86400) }));
|
|
@@ -60,7 +63,7 @@ export async function getDashboardStats(user) {
|
|
|
60
63
|
export async function getSettingsCounts() {
|
|
61
64
|
const counts = {};
|
|
62
65
|
const entities = { users: 'user', teams: 'team', rfiSections: 'rfi_section', templates: 'review_template', checklists: 'checklist', recreation: 'permission_audit', rfiTemplates: 'rfi_template' };
|
|
63
|
-
for (const [key, entity] of Object.entries(entities)) { try { counts[key] = count(entity); } catch { counts[key] = 0; } }
|
|
66
|
+
for (const [key, entity] of Object.entries(entities)) { try { counts[key] = await count(entity); } catch { counts[key] = 0; } }
|
|
64
67
|
return counts;
|
|
65
68
|
}
|
|
66
69
|
|
|
@@ -75,27 +78,28 @@ export async function getSystemConfig() {
|
|
|
75
78
|
|
|
76
79
|
export async function getAuditData() {
|
|
77
80
|
try {
|
|
78
|
-
const audits = list('permission_audit', {});
|
|
81
|
+
const audits = await list('permission_audit', {});
|
|
79
82
|
const cutoff = Math.floor(Date.now() / 1000) - 30 * 86400;
|
|
80
83
|
const recent = audits.filter(a => (a.timestamp || a.created_at) > cutoff);
|
|
81
84
|
return { summary: { total_actions: recent.length, grants: recent.filter(a => a.action === 'grant').length, revokes: recent.filter(a => a.action === 'revoke').length, role_changes: recent.filter(a => a.action === 'role_change').length }, recentActivity: audits.slice(0, 50) };
|
|
82
85
|
} catch { return { summary: {}, recentActivity: [] }; }
|
|
83
86
|
}
|
|
84
87
|
|
|
85
|
-
export function getClientDashboardStats(clientId) {
|
|
88
|
+
export async function getClientDashboardStats(clientId) {
|
|
86
89
|
try {
|
|
87
|
-
const engagements = list('engagement', {
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
const
|
|
90
|
+
const engagements = await list('engagement', { client_id: clientId });
|
|
91
|
+
const engIds = new Set(engagements.map(e => e.id));
|
|
92
|
+
const rfis = (await list('rfi', {})).filter(r => engIds.has(r.engagement_id));
|
|
93
|
+
const reviews = (await list('review', {})).filter(r => engIds.has(r.engagement_id));
|
|
94
|
+
const users = await list('user', { client_id: clientId });
|
|
91
95
|
return { engagements: engagements.length, activeRfis: rfis.filter(r => r.status !== 'closed' && r.status !== 'completed').length, users: users.length, reviews: reviews.length, engagementList: engagements.slice(0, 10) };
|
|
92
|
-
} catch { return { engagements: 0, activeRfis: 0, users: 0, reviews: 0, engagementList: [] }; }
|
|
96
|
+
} catch (err) { log.error('getClientDashboardStats failed', { clientId, message: err?.message || String(err) }); return { engagements: 0, activeRfis: 0, users: 0, reviews: 0, engagementList: [] }; }
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
export async function getSystemHealth() {
|
|
96
100
|
try {
|
|
97
101
|
const entityCounts = {};
|
|
98
|
-
for (const e of ['user','client','engagement','rfi','review','team']) { try { entityCounts[e] = count(e); } catch { entityCounts[e] = 0; } }
|
|
102
|
+
for (const e of ['user','client','engagement','rfi','review','team']) { try { entityCounts[e] = await count(e); } catch { entityCounts[e] = 0; } }
|
|
99
103
|
const u = process.uptime();
|
|
100
104
|
return { database: { status: 'Connected', type: 'SQLite' }, server: { port: 3004, uptime: `${Math.floor(u/3600)}h ${Math.floor((u%3600)/60)}m` }, entities: entityCounts };
|
|
101
105
|
} catch { return {}; }
|
|
@@ -104,7 +108,7 @@ export async function getSystemHealth() {
|
|
|
104
108
|
export function renderBuildLogsContent(logs) {
|
|
105
109
|
const rows = (logs || []).map(l => {
|
|
106
110
|
const sts = l.status === 'success' ? '<span class="pill pill-success">Success</span>' : l.status === 'failed' ? '<span class="pill pill-danger">Failed</span>' : `<span class="pill pill-warning">${l.status || 'pending'}</span>`;
|
|
107
|
-
const ts = l.created_at ? new Date(l.created_at * 1000).toLocaleString() : '-';
|
|
111
|
+
const ts = l.created_at ? new Date(l.created_at * 1000).toLocaleString('en-ZA') : '-';
|
|
108
112
|
return `<tr><td style="padding:8px 12px">${l.version||'-'}</td><td style="padding:8px 12px">${sts}</td><td style="padding:8px 12px;font-size:0.78rem;color:var(--color-text-muted)">${ts}</td><td style="padding:8px 12px">${l.duration?l.duration+'s':'-'}</td><td style="padding:8px 12px;font-size:0.78rem">${l.message||'-'}</td></tr>`;
|
|
109
113
|
}).join('');
|
|
110
114
|
return `<div style="margin-bottom:24px"><h1 class="page-title">Build Logs</h1></div><div class="table-wrap" style="overflow-x:auto"><table class="data-table"><thead><tr><th>Version</th><th>Status</th><th>Time</th><th>Duration</th><th>Message</th></tr></thead><tbody>${rows||'<tr><td colspan="5" style="padding:32px;text-align:center;color:var(--color-text-muted)">No build logs</td></tr>'}</tbody></table></div>`;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { list, get } from '@/lib/
|
|
1
|
+
import { list, get } from '@/lib/busybase-store.js';
|
|
2
|
+
import { createLogger } from '@/lib/logger.js';
|
|
2
3
|
import { getSpec } from '@/config/spec-helpers.js';
|
|
3
|
-
|
|
4
|
+
|
|
5
|
+
const log = createLogger('[PageHandlerReviews]');
|
|
4
6
|
import { renderEntityList, renderAccessDenied } from '@/ui/renderer.js';
|
|
5
7
|
import { renderSectionReport, renderReviewListTabbed } from '@/ui/review-renderer.js';
|
|
6
8
|
import { renderChecklistDetails, renderChecklistsHome } from '@/ui/checklist-renderer.js';
|
|
@@ -21,14 +23,14 @@ async function lazyRenderer(name) {
|
|
|
21
23
|
return import(`file://${__dirname_rv}${name}?t=${t}`);
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
export function handleFilteredReviewList(user, filter) {
|
|
26
|
+
export async function handleFilteredReviewList(user, filter) {
|
|
25
27
|
if (!canList(user, 'review')) return renderAccessDenied(user, 'review', 'list');
|
|
26
28
|
const spec = getSpec('review'); if (!spec) return null;
|
|
27
|
-
|
|
29
|
+
const where = filter === 'archive' ? { status: 'archived' } : {};
|
|
30
|
+
let items = await list('review', where);
|
|
28
31
|
if (filter === 'active') items = items.filter(r => r.status === 'active' || r.status === 'open');
|
|
29
32
|
else if (filter === 'priority') { const pids = user.priority_reviews || []; items = items.filter(r => pids.includes(r.id)); }
|
|
30
33
|
else if (filter === 'history') items = items.filter(r => r.status === 'closed' || r.status === 'completed');
|
|
31
|
-
else if (filter === 'archive') items = items.filter(r => r.status === 'archived');
|
|
32
34
|
return renderEntityList('review', resolveRefFields(items, spec), spec, user);
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -40,25 +42,27 @@ export async function handleReviewRoutes(normalized, segments, user, req) {
|
|
|
40
42
|
|
|
41
43
|
if (normalized === '/reviews' || normalized === '/review') {
|
|
42
44
|
if (!canList(user, 'review')) return renderAccessDenied(user, 'review', 'list');
|
|
43
|
-
let reviews = []; try { reviews =
|
|
44
|
-
const engMap = {}; try { list('engagement', {}).forEach(e => { engMap[e.id] = e.name; }); } catch {}
|
|
45
|
+
let reviews = []; try { reviews = await list('review', {}, { sort: { field: 'updated_at', dir: 'DESC' } }); } catch {}
|
|
46
|
+
const engMap = {}; try { (await list('engagement', {})).forEach(e => { engMap[e.id] = e.name; }); } catch {}
|
|
45
47
|
return renderReviewListTabbed(user, reviews.map(r => ({ ...r, engagement_name: engMap[r.engagement_id] || r.engagement_name || null })));
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
if (segments[0] === 'review' && segments.length === 3 && segments[2] === 'sections') {
|
|
49
51
|
const reviewId = segments[1];
|
|
50
52
|
if (!canView(user, 'review')) return renderAccessDenied(user, 'review', 'view');
|
|
51
|
-
const review = get('review', reviewId)
|
|
53
|
+
const review = await get('review', reviewId);
|
|
52
54
|
if (!review) return null;
|
|
53
55
|
let sections = [];
|
|
54
|
-
try { sections = list('review_section', {
|
|
56
|
+
try { sections = (await list('review_section', { review_id: reviewId })); } catch (e) { log.error('Failed to fetch sections', { reviewId, message: e?.message || String(e) }); }
|
|
55
57
|
return renderSectionReport(user, review, sections);
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
if (normalized === '/reviews/analytics' || normalized === '/review/analytics') {
|
|
59
61
|
if (!canList(user, 'review')) return renderAccessDenied(user, 'review', 'list');
|
|
60
62
|
let reviews = [], highlights = [], activity = [];
|
|
61
|
-
try { reviews = list('review', {}); } catch {}
|
|
63
|
+
try { reviews = await list('review', {}); } catch {}
|
|
64
|
+
try { highlights = await list('highlight', {}); } catch {}
|
|
65
|
+
try { activity = (await list('activity_log', {})).slice(0, 50); } catch {}
|
|
62
66
|
return renderReviewAnalytics(user, { reviews, highlights, recentActivity: activity });
|
|
63
67
|
}
|
|
64
68
|
|
|
@@ -67,40 +71,41 @@ export async function handleReviewRoutes(normalized, segments, user, req) {
|
|
|
67
71
|
const url = new URL(req.url, `http://${req.headers.host||'localhost'}`);
|
|
68
72
|
const leftId = url.searchParams.get('left'), rightId = url.searchParams.get('right');
|
|
69
73
|
if (leftId && rightId) {
|
|
70
|
-
const leftReview = get('review', leftId), rightReview = get('review', rightId);
|
|
74
|
+
const leftReview = await get('review', leftId), rightReview = await get('review', rightId);
|
|
71
75
|
if (!leftReview || !rightReview) return null;
|
|
72
76
|
let leftH = [], rightH = [];
|
|
73
|
-
try {
|
|
77
|
+
try { const all = await list('highlight', {}); leftH = all.filter(h => h.review_id === leftId); rightH = all.filter(h => h.review_id === rightId); } catch {}
|
|
74
78
|
return renderReviewComparison(user, leftReview, rightReview, leftH, rightH);
|
|
75
79
|
}
|
|
76
|
-
let reviews = []; try { reviews = list('review', {}); } catch {}
|
|
80
|
+
let reviews = []; try { reviews = await list('review', {}); } catch {}
|
|
77
81
|
return renderComparisonPicker(user, reviews);
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
if (normalized === '/reviews/tenders' || normalized === '/review/tenders') {
|
|
81
85
|
if (!canList(user, 'review')) return renderAccessDenied(user, 'review', 'list');
|
|
82
86
|
let tenders = [], reviews = [];
|
|
83
|
-
try { tenders = list('tender', {}); } catch {}
|
|
87
|
+
try { tenders = await list('tender', {}); } catch {}
|
|
88
|
+
try { reviews = await list('review', {}); } catch {}
|
|
84
89
|
return renderTenderDashboard(user, tenders.map(t => { const r = reviews.find(rev => rev.id === t.review_id); return { ...t, review_name: r?.name || r?.title || '' }; }), reviews);
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
if (normalized === '/reviews/batch' || normalized === '/review/batch') {
|
|
88
93
|
if (!canEdit(user, 'review')) return renderAccessDenied(user, 'review', 'edit');
|
|
89
|
-
let reviews = []; try { reviews = list('review', {}); } catch {}
|
|
94
|
+
let reviews = []; try { reviews = await list('review', {}); } catch {}
|
|
90
95
|
const spec = getSpec('review'); if (spec) reviews = resolveRefFields(reviews, spec);
|
|
91
96
|
return renderBatchOperations(user, reviews);
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
if (segments[0] === 'review' && segments.length === 3) {
|
|
95
100
|
const reviewId = segments[1], action = segments[2];
|
|
96
|
-
const getReview = (id) => get('review', id)
|
|
101
|
+
const getReview = (id) => get('review', id);
|
|
97
102
|
if (action === 'pdf') {
|
|
98
103
|
if (!canView(user, 'review')) return renderAccessDenied(user, 'review', 'view');
|
|
99
|
-
const review = getReview(reviewId); if (!review) return null;
|
|
104
|
+
const review = await getReview(reviewId); if (!review) return null;
|
|
100
105
|
let h = [], s = [];
|
|
101
|
-
try { h = list('highlight', {}).filter(x => x.review_id === reviewId); } catch {}
|
|
102
|
-
try { s = list('review_section', {}).filter(x => x.review_id === reviewId); } catch {}
|
|
103
|
-
// Flatten bounding_rect JSON
|
|
106
|
+
try { h = (await list('highlight', {})).filter(x => x.review_id === reviewId); } catch {}
|
|
107
|
+
try { s = (await list('review_section', {})).filter(x => x.review_id === reviewId); } catch {}
|
|
108
|
+
// Flatten bounding_rect JSON -> x/y/width/height for the overlay renderer.
|
|
104
109
|
h = h.map((x) => {
|
|
105
110
|
let br = null; try { br = typeof x.bounding_rect === 'string' ? JSON.parse(x.bounding_rect) : x.bounding_rect; } catch {}
|
|
106
111
|
let rects = []; try { rects = typeof x.rects === 'string' ? JSON.parse(x.rects) : (x.rects || []); } catch {}
|
|
@@ -116,16 +121,31 @@ export async function handleReviewRoutes(normalized, segments, user, req) {
|
|
|
116
121
|
});
|
|
117
122
|
return renderPdfViewer(user, review, h, s);
|
|
118
123
|
}
|
|
119
|
-
if (action === 'editor') { if (!canEdit(user, 'review')) return renderAccessDenied(user, 'review', 'edit'); const review = getReview(reviewId); if (!review) return null; return renderPdfEditorPlaceholder(user, review); }
|
|
120
|
-
if (action === 'highlights') {
|
|
121
|
-
|
|
124
|
+
if (action === 'editor') { if (!canEdit(user, 'review')) return renderAccessDenied(user, 'review', 'edit'); const review = await getReview(reviewId); if (!review) return null; return renderPdfEditorPlaceholder(user, review); }
|
|
125
|
+
if (action === 'highlights') {
|
|
126
|
+
if (!canView(user, 'review')) return renderAccessDenied(user, 'review', 'view');
|
|
127
|
+
const review = await getReview(reviewId); if (!review) return null;
|
|
128
|
+
let h = []; try { h = (await list('highlight', {})).filter(x => x.review_id === reviewId); } catch {}
|
|
129
|
+
const rm = {}; const allResp = await list('highlight_response', {}).catch(() => []);
|
|
130
|
+
for (const x of h) rm[x.id] = allResp.filter(r => r.highlight_id === x.id);
|
|
131
|
+
return renderHighlightThreading(user, review, h, rm);
|
|
132
|
+
}
|
|
133
|
+
if (action === 'resolution') {
|
|
134
|
+
if (!canView(user, 'review')) return renderAccessDenied(user, 'review', 'view');
|
|
135
|
+
const review = await getReview(reviewId); if (!review) return null;
|
|
136
|
+
let s = []; try { s = (await list('review_section', {})).filter(x => x.review_id === reviewId); } catch {}
|
|
137
|
+
const allH = await list('highlight', {}).catch(() => []);
|
|
138
|
+
const hbs = {};
|
|
139
|
+
for (const sec of s) hbs[sec.id] = allH.filter(h => h.review_id === reviewId && h.section_id === sec.id);
|
|
140
|
+
return renderSectionResolution(user, review, s, hbs);
|
|
141
|
+
}
|
|
122
142
|
if (action === 'edit') {
|
|
123
143
|
if (!canEdit(user, 'review')) return renderAccessDenied(user, 'review', 'edit');
|
|
124
|
-
const review = get('review', reviewId)
|
|
144
|
+
const review = await get('review', reviewId);
|
|
125
145
|
if (!review) return null;
|
|
126
146
|
let engagements = [], teams = [];
|
|
127
|
-
try { engagements = list('engagement', {}); } catch {}
|
|
128
|
-
try { teams = list('team', {}); } catch {}
|
|
147
|
+
try { engagements = await list('engagement', {}); } catch {}
|
|
148
|
+
try { teams = await list('team', {}); } catch {}
|
|
129
149
|
const { renderReviewEdit } = await lazyRenderer('review-edit-renderer.js');
|
|
130
150
|
return renderReviewEdit(user, review, engagements, teams);
|
|
131
151
|
}
|
|
@@ -135,13 +155,19 @@ export async function handleReviewRoutes(normalized, segments, user, req) {
|
|
|
135
155
|
if (segments.length === 2 && segments[0] === 'review' && segments[1] !== 'new') {
|
|
136
156
|
const reviewId = segments[1];
|
|
137
157
|
if (!canView(user, 'review')) return renderAccessDenied(user, 'review', 'view');
|
|
138
|
-
const review = get('review', reviewId)
|
|
158
|
+
const review = await get('review', reviewId); if (!review) return null;
|
|
139
159
|
let highlights = [], collaborators = [], checklists = [], sections = [], tenders = [];
|
|
140
|
-
try { highlights = list('highlight', {}).filter(h => h.review_id === reviewId); } catch {}
|
|
141
|
-
try { collaborators = list('collaborator', {}).filter(c => c.review_id === reviewId); } catch {}
|
|
142
|
-
try {
|
|
143
|
-
|
|
144
|
-
|
|
160
|
+
try { highlights = (await list('highlight', {})).filter(h => h.review_id === reviewId); } catch {}
|
|
161
|
+
try { collaborators = (await list('collaborator', {})).filter(c => c.review_id === reviewId); } catch {}
|
|
162
|
+
try {
|
|
163
|
+
const allItems = await list('checklist_item', {});
|
|
164
|
+
checklists = (await list('checklist', {})).filter(c => c.review_id === reviewId).map(c => {
|
|
165
|
+
const items = allItems.filter(i => i.checklist_id === c.id);
|
|
166
|
+
return { ...c, total_items: items.length, completed_items: items.filter(i => i.completed).length };
|
|
167
|
+
});
|
|
168
|
+
} catch {}
|
|
169
|
+
try { sections = (await list('review_section', {})).filter(s => s.review_id === reviewId); } catch {}
|
|
170
|
+
try { tenders = (await list('tender', {})).filter(t => t.review_id === reviewId); } catch {}
|
|
145
171
|
const { renderReviewDetail } = await lazyRenderer('review-detail-renderer.js');
|
|
146
172
|
return renderReviewDetail(user, review, highlights, collaborators, checklists, sections, tenders);
|
|
147
173
|
}
|
|
@@ -149,15 +175,25 @@ export async function handleReviewRoutes(normalized, segments, user, req) {
|
|
|
149
175
|
if (segments[0] === 'checklist' && segments.length === 2 && segments[1] !== 'new') {
|
|
150
176
|
const checklistId = segments[1];
|
|
151
177
|
if (!canView(user, 'checklist')) return renderAccessDenied(user, 'checklist', 'view');
|
|
152
|
-
const checklist = get('checklist', checklistId); if (!checklist) return null;
|
|
153
|
-
let items = [];
|
|
178
|
+
const checklist = await get('checklist', checklistId); if (!checklist) return null;
|
|
179
|
+
let items = [];
|
|
180
|
+
try { items = (await list('checklist_item', { checklist_id: checklistId })).sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || (a.created_at ?? 0) - (b.created_at ?? 0)); } catch {}
|
|
154
181
|
return renderChecklistDetails(user, checklist, items);
|
|
155
182
|
}
|
|
156
183
|
|
|
157
184
|
if (normalized === '/checklists' || normalized === '/checklist') {
|
|
158
185
|
if (!canList(user, 'checklist')) return renderAccessDenied(user, 'checklist', 'list');
|
|
159
186
|
let checklists = [];
|
|
160
|
-
try {
|
|
187
|
+
try {
|
|
188
|
+
// Old GROUP BY checklist_id (total + done) -> client-side aggregate.
|
|
189
|
+
const allItems = await list('checklist_item', {});
|
|
190
|
+
const statsMap = {};
|
|
191
|
+
for (const it of allItems) {
|
|
192
|
+
const s = statsMap[it.checklist_id] || (statsMap[it.checklist_id] = { total: 0, done: 0 });
|
|
193
|
+
s.total++; if (it.is_done === 1 || it.is_done === true || it.completed) s.done++;
|
|
194
|
+
}
|
|
195
|
+
checklists = (await list('checklist', {})).map(c => ({ ...c, total_items: statsMap[c.id]?.total || 0, completed_items: statsMap[c.id]?.done || 0 }));
|
|
196
|
+
} catch {}
|
|
161
197
|
return renderChecklistsHome(user, checklists);
|
|
162
198
|
}
|
|
163
199
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { list, get } from '@/lib/
|
|
2
|
-
import { getDatabase } from '@/lib/database-core.js';
|
|
1
|
+
import { list, get } from '@/lib/busybase-store.js';
|
|
3
2
|
import { canView, canList, isClerk, isPartner } from '@/ui/permissions-ui.js';
|
|
4
3
|
import { renderAccessDenied } from '@/ui/renderer.js';
|
|
5
4
|
import { renderRfiList } from '@/ui/rfi-list-renderer.js';
|
|
@@ -9,31 +8,46 @@ const lazyR = (name) => import(`file://${__dirname_rfi}${name}?t=${globalThis.__
|
|
|
9
8
|
|
|
10
9
|
export async function handleRfiDetail(user, rfiId) {
|
|
11
10
|
if (!canView(user, 'rfi')) return renderAccessDenied(user, 'rfi', 'view');
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
const rfi = await get('rfi', rfiId); if (!rfi) return null;
|
|
12
|
+
let questions = [];
|
|
13
|
+
try {
|
|
14
|
+
questions = (await list('rfi_question', { rfi_id: rfiId }));
|
|
15
|
+
// Old correlated subquery (response_count per question) -> client-side aggregate.
|
|
16
|
+
const responses = await list('rfi_response', {});
|
|
17
|
+
const counts = {};
|
|
18
|
+
for (const r of responses) counts[r.question_id] = (counts[r.question_id] || 0) + 1;
|
|
19
|
+
questions = questions.map(q => ({ ...q, response_count: counts[q.id] || 0 }));
|
|
20
|
+
} catch {}
|
|
21
|
+
let sections = []; try {
|
|
22
|
+
const [bySectionId, byEngId] = await Promise.all([
|
|
23
|
+
list('rfi_section', { rfi_id: rfiId }),
|
|
24
|
+
list('rfi_section', { engagement_id: rfi.engagement_id }),
|
|
25
|
+
]);
|
|
26
|
+
const seen = new Set(); sections = [...bySectionId, ...byEngId].filter(s => seen.has(s.id) ? false : seen.add(s.id));
|
|
27
|
+
} catch {}
|
|
28
|
+
let engagement = null; try { if (rfi.engagement_id) engagement = await get('engagement', rfi.engagement_id); } catch {}
|
|
17
29
|
const { renderRfiDetail } = await lazyR('rfi-detail-renderer.js');
|
|
18
30
|
return renderRfiDetail(user, rfi, questions, sections, engagement);
|
|
19
31
|
}
|
|
20
32
|
|
|
21
33
|
export async function handleRfiList(user) {
|
|
22
34
|
if (!canList(user, 'rfi')) return renderAccessDenied(user, 'rfi', 'list');
|
|
23
|
-
let rfis = []; try { rfis =
|
|
24
|
-
let engagements = []; try { engagements = list('engagement', {}); } catch {}
|
|
35
|
+
let rfis = []; try { rfis = await list('rfi', {}, { sort: { field: 'created_at', dir: 'ASC' } }); } catch {}
|
|
36
|
+
let engagements = []; try { engagements = await list('engagement', {}); } catch {}
|
|
25
37
|
if (isClerk(user)) rfis = rfis.filter(r => engagements.filter(e => e.assigned_to === user.id || e.team_id === user.team_id).some(e => e.id === r.engagement_id));
|
|
26
38
|
return renderRfiList(user, rfis.map((r, i) => ({ ...r, display_name: 'RFI #' + (i + 1) })), engagements);
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
export async function handleRfiReport(user, rfiId, res) {
|
|
30
|
-
const db = getDatabase();
|
|
31
42
|
let rfi = null, questions = [], responses = [], engagement = null;
|
|
32
|
-
try { rfi = get('rfi', rfiId)
|
|
43
|
+
try { rfi = await get('rfi', rfiId); } catch {}
|
|
33
44
|
if (!rfi) return null;
|
|
34
|
-
try { questions =
|
|
35
|
-
try {
|
|
36
|
-
|
|
45
|
+
try { questions = await list('rfi_question', { rfi_id: rfiId }, { sort: { field: 'created_at', dir: 'ASC' } }); } catch {}
|
|
46
|
+
try {
|
|
47
|
+
const qIds = new Set(questions.map(q => q.id));
|
|
48
|
+
if (qIds.size) responses = (await list('rfi_response', {})).filter(r => qIds.has(r.question_id));
|
|
49
|
+
} catch {}
|
|
50
|
+
try { engagement = rfi.engagement_id ? await get('engagement', rfi.engagement_id) : null; } catch {}
|
|
37
51
|
const { renderRfiReport } = await lazyR('rfi-report-renderer.js');
|
|
38
52
|
const html = renderRfiReport(user, rfi, engagement, questions, responses);
|
|
39
53
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|