thatcher 1.0.41 → 1.0.43
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 +27 -0
- package/package.json +2 -5
- package/src/app/api/audit/dashboard/route.js +2 -2
- package/src/app/api/audit/logs/route.js +2 -2
- package/src/app/api/audit/permissions/[id]/route.js +2 -2
- package/src/app/api/audit/permissions/route.js +3 -3
- package/src/app/api/audit/permissions/stats/route.js +2 -2
- package/src/app/api/audit/route.js +2 -3
- package/src/app/api/audit/stats/route.js +2 -2
- package/src/app/api/auth/google/callback/route.js +1 -1
- package/src/app/api/auth/google/route.js +1 -1
- package/src/app/api/auth/login/route.js +1 -1
- package/src/app/api/auth/logout/route.js +1 -1
- package/src/app/api/auth/me/route.js +1 -1
- package/src/app/api/auth/mwr-bridge/route.js +1 -1
- package/src/app/api/auth/password-reset/route.js +1 -1
- package/src/app/api/csrf-token/route.js +1 -1
- package/src/app/api/debug/[[...path]]/route.js +2 -2
- package/src/app/api/debug/config/route.js +1 -1
- package/src/app/api/debug/hooks/route.js +1 -1
- package/src/app/api/debug/plugins/route.js +1 -1
- package/src/app/api/debug/sqlite/route.js +1 -1
- package/src/app/api/debug/sync/route.js +1 -1
- package/src/app/api/debug/workflow/route.js +1 -1
- package/src/app/api/domains/[domain]/route.js +1 -2
- package/src/app/api/domains/route.js +1 -1
- package/src/app/api/email/allocate/batch/route.js +1 -1
- package/src/app/api/email/allocate/route.js +1 -1
- package/src/app/api/email/receive/route.js +1 -1
- package/src/app/api/email/send/route.js +1 -1
- package/src/app/api/email/unallocated/route.js +1 -1
- package/src/app/api/files/[id]/route.js +1 -1
- package/src/app/api/health/route.js +1 -2
- package/src/app/api/metrics/route.js +1 -2
- package/src/engine.js +3 -3
- package/src/engine.server.js +3 -3
- package/src/index.js +35 -10
- package/src/lib/action-factory.js +1 -1
- package/src/lib/api-helpers.js +1 -1
- package/src/lib/api.js +3 -4
- package/src/lib/audit-logger-enhanced.js +1 -1
- package/src/lib/auth-middleware.js +1 -1
- package/src/lib/auth-route-helpers.js +1 -1
- package/src/lib/{busybase-adapter.js → busybase/adapter.js} +3 -3
- package/src/lib/{busybase-audit-reads.js → busybase/audit-reads.js} +1 -1
- package/src/lib/{busybase-audit.js → busybase/audit.js} +2 -2
- package/src/lib/{busybase-lucia-adapter.js → busybase/lucia-adapter.js} +1 -1
- package/src/lib/{busybase-store.js → busybase/store.js} +3 -3
- package/src/lib/config-generator-engine.js +13 -1
- package/src/lib/crud-action-helpers.js +1 -1
- package/src/lib/crud-handlers.js +4 -4
- package/src/lib/email-sender.js +1 -1
- package/src/lib/errors/index.js +3 -0
- package/src/lib/{error-recovery.js → errors/recovery.js} +120 -4
- package/src/lib/{error-handler.js → errors/types.js} +1 -1
- package/src/lib/errors/wrap.js +225 -0
- package/src/lib/events-engine.js +1 -1
- package/src/lib/hot-reload/index.js +34 -0
- package/src/lib/index.js +5 -5
- package/src/lib/keyed-cache.js +112 -0
- package/src/lib/monitor.js +285 -0
- package/src/lib/next-shim.js +294 -0
- package/src/lib/observability-init.js +213 -0
- package/src/lib/perf.js +468 -0
- package/src/lib/query-cache.js +40 -46
- package/src/lib/realtime-server.js +18 -0
- package/src/lib/render-cache.js +14 -28
- package/src/lib/{request-tracing.js → request-trace.js} +57 -2
- package/src/lib/response-formatter.js +18 -0
- package/src/lib/route-helpers.js +1 -1
- package/src/lib/state-protocol.js +13 -0
- package/src/lib/state-transport-client.js +6 -0
- package/src/lib/state-transport-reconnect.js +5 -0
- package/src/lib/state-transport-server.js +8 -0
- package/src/lib/utils.js +1 -1
- package/src/lib/{validate.js → validation/entity-validators.js} +9 -8
- package/src/lib/validation/index.js +1 -1
- package/src/lib/validation/security-validators.js +1 -1
- package/src/lib/validation-middleware.js +2 -2
- package/src/lib/workflow-engine.js +23 -3
- package/src/lib/xstate-workflow-engine.js +18 -2
- package/src/services/collaborator-role.service.js +2 -2
- package/src/services/notification-engine.js +5 -5
- package/src/services/permission.service.js +1 -1
- package/src/ui/format-helpers.js +1 -4
- package/src/ui/highlight-threading-renderer.js +1 -1
- package/src/ui/page-handler-admin.js +6 -5
- package/src/ui/page-handler-helpers.js +2 -2
- package/src/ui/page-handler-reviews.js +4 -4
- package/src/ui/page-handler-rfi.js +1 -1
- package/src/ui/page-handler.js +2 -2
- package/src/ui/render-helpers.js +1 -1
- package/src/ui/renderer.js +1 -1
- package/src/ui/{review-comparison-renderer.js → review/comparison.js} +1 -1
- package/src/ui/{review-detail-renderer.js → review/detail-renderer.js} +3 -3
- package/src/ui/review/index.js +32 -0
- package/src/ui/{review-mwr-renderer.js → review/mwr.js} +1 -1
- package/src/ui/{review-renderer.js → review/renderer.js} +1 -1
- package/src/ui/{settings-renderer.js → settings/home.js} +7 -34
- package/src/ui/{settings-renderer-advanced.js → settings/review.js} +117 -56
- package/src/ui/settings/shared.js +45 -0
- package/src/ui/{settings-renderer-teams.js → settings/teams.js} +1 -2
- package/src/ui/settings/templates.js +116 -0
- package/src/lib/api-error-wrapper.js +0 -125
- package/src/lib/db-monitor.js +0 -127
- package/src/lib/error-resilience.js +0 -132
- package/src/lib/monitoring-init.js +0 -67
- package/src/lib/next-compat.js +0 -94
- package/src/lib/next-polyfills.js +0 -135
- package/src/lib/observability-bootstrap.js +0 -116
- package/src/lib/perf-monitor.js +0 -94
- package/src/lib/perf-profiler.js +0 -233
- package/src/lib/query-perf.js +0 -117
- package/src/lib/request-tracker.js +0 -43
- package/src/lib/resource-monitor.js +0 -120
- package/src/lib/validators.js +0 -67
- package/src/lib/with-error-handler.js +0 -31
- package/src/ui/settings-renderer-advanced2.js +0 -159
- /package/src/ui/{review-detail-panels.js → review/detail-panels.js} +0 -0
- /package/src/ui/{review-detail-script.js → review/detail-script.js} +0 -0
- /package/src/ui/{review-widgets.js → review/widgets.js} +0 -0
- /package/src/ui/{review-zone-nav.js → review/zone-nav.js} +0 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Content-management settings pages: review templates (list + single-template
|
|
3
|
+
* manage), checklists, and the generic entity/engagement "type list" pattern.
|
|
4
|
+
* Ported unchanged from settings-renderer-advanced.js (templates, checklists,
|
|
5
|
+
* entity-types, engagement-types) and settings-renderer-advanced2.js
|
|
6
|
+
* (renderSettingsTemplateManage, the single-template detail/section-manage page).
|
|
7
|
+
*/
|
|
8
|
+
import { TOAST_SCRIPT, settingsPage, settingsBack, inlineTable, bc, esc } from '@/ui/settings/shared.js';
|
|
9
|
+
|
|
10
|
+
const editBtn = (href) => `<a href="${href}" data-stop-propagation="true" class="btn btn-ghost btn-xs">Edit</a>`;
|
|
11
|
+
const trClick = (url) => `class="hover cursor-pointer" data-navigate="${url}"`;
|
|
12
|
+
const hdr = (title, addHref, addLabel) => `${settingsBack()}<div class="flex justify-between items-center mb-6">
|
|
13
|
+
<h1 class="text-2xl font-bold">${title}</h1>
|
|
14
|
+
<a href="${addHref}" class="btn btn-primary btn-sm">${addLabel}</a>
|
|
15
|
+
</div>`;
|
|
16
|
+
|
|
17
|
+
export function renderSettingsTemplates(user, templates = []) {
|
|
18
|
+
const rows = templates.map(t => `<tr ${trClick('/review_template/'+t.id)}>
|
|
19
|
+
<td class="text-sm font-medium">${esc(t.name||'-')}</td>
|
|
20
|
+
<td><span class="badge badge-flat-primary text-xs">${esc(t.type||'standard')}</span></td>
|
|
21
|
+
<td>${t.is_active ? '<span class="badge badge-success badge-flat-success text-xs">Active</span>' : '<span class="badge badge-flat-secondary text-xs">Inactive</span>'}</td>
|
|
22
|
+
<td>${editBtn('/review_template/'+t.id+'/edit')}</td>
|
|
23
|
+
</tr>`).join('');
|
|
24
|
+
return settingsPage(user, 'Templates - Settings', bc('Templates'), hdr('Templates', '/review_template/new', '+ Add Template') + inlineTable(['Name', 'Type', 'Status', 'Actions'], rows, 'No templates found'));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function renderSettingsChecklists(user, checklists = []) {
|
|
28
|
+
const rows = checklists.map(c => `<tr ${trClick('/checklist/'+c.id)}>
|
|
29
|
+
<td class="text-sm font-medium">${esc(c.name||'-')}</td>
|
|
30
|
+
<td class="text-sm">${esc(c.type||'-')}</td>
|
|
31
|
+
<td class="text-sm text-base-content/70">${esc(c.review_id||'-')}</td>
|
|
32
|
+
<td>${editBtn('/checklist/'+c.id+'/edit')}</td>
|
|
33
|
+
</tr>`).join('');
|
|
34
|
+
return settingsPage(user, 'Checklists - Settings', bc('Checklists'), hdr('Checklists', '/checklist/new', '+ Add Checklist') + inlineTable(['Name', 'Type', 'Review', 'Actions'], rows, 'No checklists found'));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function renderTypeList(user, items, entityKey, title) {
|
|
38
|
+
const rows = items.map(t => `<tr class="hover">
|
|
39
|
+
<td class="font-medium text-sm">${esc(t.name || '-')}</td>
|
|
40
|
+
<td class="text-xs text-base-content/70">${t.created_at ? new Date(t.created_at).toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'}) : '-'}</td>
|
|
41
|
+
<td><div class="flex gap-2">
|
|
42
|
+
<button type="button" class="btn btn-ghost btn-xs type-edit-btn" data-id="${esc(t.id)}" data-name="${esc(t.name||'')}">Edit</button>
|
|
43
|
+
<button type="button" class="btn btn-error btn-xs type-del-btn" data-id="${esc(t.id)}">Delete</button>
|
|
44
|
+
</div></td>
|
|
45
|
+
</tr>`).join('');
|
|
46
|
+
const script = `${TOAST_SCRIPT}
|
|
47
|
+
var _eid='';
|
|
48
|
+
var _typeFormTrigger=null;
|
|
49
|
+
function openAdd(){_eid='';_typeFormTrigger=document.activeElement;document.getElementById('type-name').value='';document.getElementById('type-form').style.display='block';var n=document.getElementById('type-name');if(n)n.focus()}
|
|
50
|
+
function openEdit(id,name){_eid=id;_typeFormTrigger=document.activeElement;document.getElementById('type-name').value=name;document.getElementById('type-form').style.display='block';var n=document.getElementById('type-name');if(n)n.focus()}
|
|
51
|
+
function cancelForm(){document.getElementById('type-form').style.display='none';if(_typeFormTrigger&&typeof _typeFormTrigger.focus==='function')_typeFormTrigger.focus();_typeFormTrigger=null}
|
|
52
|
+
async function saveType(){const name=document.getElementById('type-name').value.trim();if(!name){showToast('Name required','error');return}const url=_eid?'/api/${entityKey}/'+_eid:'/api/${entityKey}';const method=_eid?'PUT':'POST';try{const r=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});if(r.ok){showToast(_eid?'Updated':'Created','success');setTimeout(()=>location.reload(),400)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
|
|
53
|
+
async function delType(id){if(!(await window.gmConfirm({title:'Please confirm',message:'Delete?',danger:true,confirmLabel:'OK'})))return;try{const r=await fetch('/api/${entityKey}/'+id,{method:'DELETE'});if(r.ok){showToast('Deleted','success');setTimeout(()=>location.reload(),400)}else showToast('Failed','error')}catch(e){showToast('Error','error')}}
|
|
54
|
+
document.addEventListener('DOMContentLoaded',function(){document.querySelectorAll('.type-edit-btn').forEach(b=>b.addEventListener('click',function(){openEdit(this.dataset.id,this.dataset.name)}));document.querySelectorAll('.type-del-btn').forEach(b=>b.addEventListener('click',function(){delType(this.dataset.id)}));document.querySelectorAll('.type-add-btn').forEach(b=>b.addEventListener('click',openAdd))});`;
|
|
55
|
+
const formHtml = `<div id="type-form" class="card-clean mb-4" style="display:none"><div class="card-clean-body"><div class="flex flex-wrap gap-2 items-end"><div class="form-group" style="flex:1;min-width:0"><label class="label"><span class="label-text font-medium">Name</span></label><input id="type-name" type="text" class="input input-solid w-full" placeholder="Name"/></div><button data-action="saveType" class="btn btn-primary btn-sm">Save</button><button data-action="cancelForm" class="btn btn-ghost btn-sm">Cancel</button></div></div></div>`;
|
|
56
|
+
const content = `${settingsBack()}<div class="flex justify-between items-center mb-4"><h1 class="text-2xl font-bold">${title}</h1><button class="btn btn-primary btn-sm type-add-btn">Add</button></div>${formHtml}<div class="card-clean"><div class="card-clean-body" style="padding:0">${inlineTable(['Name','Created','Actions'],rows,'No items found.')}</div></div>`;
|
|
57
|
+
return settingsPage(user, `${title} - Settings`, bc(title), content, [script]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function renderSettingsEntityTypes(user, items = []) {
|
|
61
|
+
return renderTypeList(user, items, 'entity_type', 'Entity Types');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function renderSettingsEngagementTypes(user, items = []) {
|
|
65
|
+
return renderTypeList(user, items, 'engagement_type', 'Engagement Types');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function renderSettingsTemplateManage(user, template = {}, sections = []) {
|
|
69
|
+
const sectionRows = sections.map((s, i) => `<tr data-id="${esc(s.id)}">
|
|
70
|
+
<td><span class="inline-block w-5 h-5 rounded" style="background:${s.color || '#B0B0B0'}"></span></td>
|
|
71
|
+
<td class="text-sm font-medium">${esc(s.name || '-')}</td>
|
|
72
|
+
<td class="text-sm text-base-content/50">${s.order ?? i}</td>
|
|
73
|
+
<td><div class="flex gap-1">
|
|
74
|
+
<button data-action="editTplSection" data-args='["${esc(s.id)}","${esc((s.name||'').replace(/"/g,'"'))}","${esc(s.color||'#B0B0B0')}"]' class="btn btn-ghost btn-xs">Edit</button>
|
|
75
|
+
<button data-action="deleteTplSection" data-args='["${esc(s.id)}"]' class="btn btn-error btn-xs btn-outline">Delete</button>
|
|
76
|
+
</div></td>
|
|
77
|
+
</tr>`).join('');
|
|
78
|
+
const tplBc = [{ href: '/', label: 'Dashboard' }, { href: '/admin/settings', label: 'Settings' }, { href: '/admin/settings/templates', label: 'Templates' }, { label: template.name || 'Template' }];
|
|
79
|
+
const content = `${settingsBack()}<div class="mb-6">
|
|
80
|
+
<h1 class="text-2xl font-bold">${esc(template.name || 'Template')}</h1>
|
|
81
|
+
<p class="text-sm text-base-content/50 mt-1">Manage template sections and configuration</p>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
84
|
+
<div class="card-clean"><div class="card-clean-body">
|
|
85
|
+
<h2 class="card-title text-base mb-4">Template Info</h2>
|
|
86
|
+
<div class="form-group mb-3"><label class="label"><span class="label-text font-semibold">Name</span></label><input type="text" id="tpl-name" class="input input-solid max-w-full" value="${esc(template.name || '')}"/></div>
|
|
87
|
+
<div class="form-group mb-3"><label class="label"><span class="label-text font-semibold">Type</span></label><select id="tpl-type" class="select select-solid max-w-full"><option value="standard" ${template.type==='standard'?'selected':''}>Standard</option><option value="checklist" ${template.type==='checklist'?'selected':''}>Checklist</option><option value="audit" ${template.type==='audit'?'selected':''}>Audit</option></select></div>
|
|
88
|
+
<div class="form-group mb-4"><label class="label cursor-pointer justify-start gap-3"><input type="checkbox" id="tpl-active" class="checkbox checkbox-primary" ${template.is_active?'checked':''}/><span class="label-text">Active</span></label></div>
|
|
89
|
+
<button data-action="saveTplInfo" class="btn btn-primary btn-sm">Save Template Info</button>
|
|
90
|
+
</div></div>
|
|
91
|
+
<div class="card-clean"><div class="card-clean-body">
|
|
92
|
+
<div class="flex justify-between items-center mb-4">
|
|
93
|
+
<h2 class="card-title text-base">Sections</h2>
|
|
94
|
+
<button data-action="addTplSection" class="btn btn-primary btn-sm">+ Add Section</button>
|
|
95
|
+
</div>
|
|
96
|
+
${inlineTable(['Color', 'Name', 'Order', 'Actions'], sectionRows, 'No sections defined')}
|
|
97
|
+
</div></div>
|
|
98
|
+
</div>
|
|
99
|
+
<div id="tpl-section-form" class="hidden card-clean" style="margin-top:1rem"><div class="card-clean-body">
|
|
100
|
+
<div class="flex flex-wrap gap-4 items-end">
|
|
101
|
+
<div class="form-group flex-1 min-w-40"><label class="label"><span class="label-text font-semibold">Name</span></label><input type="text" id="tpl-sec-name" class="input input-solid max-w-full" placeholder="Section name"/></div>
|
|
102
|
+
<div class="form-group"><label class="label"><span class="label-text font-semibold">Color</span></label><input type="color" id="tpl-sec-color" value="#B0B0B0" class="input input-solid" style="height:42px;width:60px;padding:4px"/></div>
|
|
103
|
+
<div class="flex gap-2"><button data-action="saveTplSection" class="btn btn-primary btn-sm">Save</button><button data-action="cancelTplSection" class="btn btn-ghost btn-sm">Cancel</button></div>
|
|
104
|
+
</div>
|
|
105
|
+
<input type="hidden" id="tpl-sec-id" value=""/>
|
|
106
|
+
</div></div>`;
|
|
107
|
+
const script = `${TOAST_SCRIPT}
|
|
108
|
+
var tplId='${esc(template.id || '')}';
|
|
109
|
+
window.saveTplInfo=async function(){var body={name:document.getElementById('tpl-name').value,type:document.getElementById('tpl-type').value,is_active:document.getElementById('tpl-active').checked?1:0};try{var res=await fetch('/api/review_template/'+tplId,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});if(res.ok){showToast('Template updated','success')}else{showToast('Update failed','error')}}catch(e){showToast('Error','error')}};
|
|
110
|
+
window.addTplSection=function(){document.getElementById('tpl-section-form').classList.remove('hidden');document.getElementById('tpl-sec-id').value='';document.getElementById('tpl-sec-name').value=''};
|
|
111
|
+
window.editTplSection=function(id,name,color){document.getElementById('tpl-section-form').classList.remove('hidden');document.getElementById('tpl-sec-id').value=id;document.getElementById('tpl-sec-name').value=name;document.getElementById('tpl-sec-color').value=color};
|
|
112
|
+
window.cancelTplSection=function(){document.getElementById('tpl-section-form').classList.add('hidden')};
|
|
113
|
+
window.saveTplSection=async function(){var id=document.getElementById('tpl-sec-id').value;var body={name:document.getElementById('tpl-sec-name').value,color:document.getElementById('tpl-sec-color').value,review_template_id:tplId};if(!body.name){showToast('Name required','error');return}var url=id?'/api/review_template_section/'+id:'/api/review_template_section';var method=id?'PUT':'POST';try{var res=await fetch(url,{method:method,headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});if(res.ok){showToast(id?'Updated':'Created','success');setTimeout(function(){location.reload()},500)}else{showToast('Failed','error')}}catch(e){showToast('Error','error')}};
|
|
114
|
+
window.deleteTplSection=async function(id){if(!(await window.gmConfirm({title:'Delete Section',message:'Delete this section? This cannot be undone.',confirmLabel:'Delete',danger:true})))return;try{var res=await fetch('/api/review_template_section/'+id,{method:'DELETE'});if(res.ok){showToast('Deleted','success');setTimeout(function(){location.reload()},500)}else{showToast('Delete failed','error')}}catch(e){showToast('Error','error')}};`;
|
|
115
|
+
return settingsPage(user, `Manage Template - ${esc(template.name || 'Template')}`, tplBc, content, [script]);
|
|
116
|
+
}
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
import { normalizeError, createErrorLogger, retryWithBackoff } from '@/lib/error-handler';
|
|
2
|
-
import { NextResponse } from '@/lib/next-polyfills';
|
|
3
|
-
import { HTTP } from '@/config/constants';
|
|
4
|
-
|
|
5
|
-
const logger = createErrorLogger('API');
|
|
6
|
-
|
|
7
|
-
export function wrapAPIRoute(handler, options = {}) {
|
|
8
|
-
const { retry = false, timeout = 30000, logErrors = true } = options;
|
|
9
|
-
|
|
10
|
-
return async (request, context) => {
|
|
11
|
-
const startTime = Date.now();
|
|
12
|
-
const url = new URL(request.url);
|
|
13
|
-
|
|
14
|
-
try {
|
|
15
|
-
const operation = async () => {
|
|
16
|
-
const timeoutPromise = new Promise((_, reject) =>
|
|
17
|
-
setTimeout(() => reject(new Error(`Request timeout after ${timeout}ms`)), timeout)
|
|
18
|
-
);
|
|
19
|
-
|
|
20
|
-
const handlerPromise = handler(request, context);
|
|
21
|
-
|
|
22
|
-
return await Promise.race([handlerPromise, timeoutPromise]);
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
const result = retry
|
|
26
|
-
? await retryWithBackoff(operation, { maxAttempts: 3, context: { url: url.pathname } })
|
|
27
|
-
: await operation();
|
|
28
|
-
|
|
29
|
-
const duration = Date.now() - startTime;
|
|
30
|
-
|
|
31
|
-
if (duration > 1000) {
|
|
32
|
-
logger.warn('Slow request', { url: url.pathname, duration });
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return result;
|
|
36
|
-
} catch (error) {
|
|
37
|
-
const duration = Date.now() - startTime;
|
|
38
|
-
const normalized = normalizeError(error);
|
|
39
|
-
|
|
40
|
-
if (logErrors) {
|
|
41
|
-
logger.error('Request failed', {
|
|
42
|
-
url: url.pathname,
|
|
43
|
-
method: request.method,
|
|
44
|
-
error: normalized.toJSON(),
|
|
45
|
-
duration
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return NextResponse.json(
|
|
50
|
-
normalized.toJSON(),
|
|
51
|
-
{
|
|
52
|
-
status: normalized.statusCode,
|
|
53
|
-
headers: { 'Content-Type': 'application/json' }
|
|
54
|
-
}
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function wrapGETRoute(handler, options = {}) {
|
|
61
|
-
return wrapAPIRoute(handler, { ...options, retry: true });
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function wrapPOSTRoute(handler, options = {}) {
|
|
65
|
-
return wrapAPIRoute(handler, options);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function wrapPUTRoute(handler, options = {}) {
|
|
69
|
-
return wrapAPIRoute(handler, options);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function wrapDELETERoute(handler, options = {}) {
|
|
73
|
-
return wrapAPIRoute(handler, options);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function createAPIHandler(handlers) {
|
|
77
|
-
const wrapped = {};
|
|
78
|
-
|
|
79
|
-
if (handlers.GET) wrapped.GET = wrapGETRoute(handlers.GET);
|
|
80
|
-
if (handlers.POST) wrapped.POST = wrapPOSTRoute(handlers.POST);
|
|
81
|
-
if (handlers.PUT) wrapped.PUT = wrapPUTRoute(handlers.PUT);
|
|
82
|
-
if (handlers.DELETE) wrapped.DELETE = wrapDELETERoute(handlers.DELETE);
|
|
83
|
-
if (handlers.PATCH) wrapped.PATCH = wrapAPIRoute(handlers.PATCH);
|
|
84
|
-
if (handlers.HEAD) wrapped.HEAD = wrapAPIRoute(handlers.HEAD);
|
|
85
|
-
|
|
86
|
-
return wrapped;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export async function safeJSONParse(text, fallback = null) {
|
|
90
|
-
try {
|
|
91
|
-
return JSON.parse(text);
|
|
92
|
-
} catch (error) {
|
|
93
|
-
logger.warn('JSON parse failed', { error: error.message });
|
|
94
|
-
return fallback;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export async function safeReadBody(request, fallback = {}) {
|
|
99
|
-
try {
|
|
100
|
-
const text = await request.text();
|
|
101
|
-
return text ? await safeJSONParse(text, fallback) : fallback;
|
|
102
|
-
} catch (error) {
|
|
103
|
-
logger.warn('Body read failed', { error: error.message });
|
|
104
|
-
return fallback;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export function validateRequired(data, fields) {
|
|
109
|
-
const missing = [];
|
|
110
|
-
|
|
111
|
-
for (const field of fields) {
|
|
112
|
-
if (data[field] === undefined || data[field] === null || data[field] === '') {
|
|
113
|
-
missing.push(field);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (missing.length > 0) {
|
|
118
|
-
throw new Error(`Missing required fields: ${missing.join(', ')}`);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function sanitizeError(error) {
|
|
123
|
-
const safe = String(error?.message || error || 'Unknown error');
|
|
124
|
-
return safe.substring(0, 500);
|
|
125
|
-
}
|
package/src/lib/db-monitor.js
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
import { recordDatabase } from '@/lib/metrics-collector.js'
|
|
2
|
-
|
|
3
|
-
const dbStats = {
|
|
4
|
-
connections: 0,
|
|
5
|
-
activeQueries: 0,
|
|
6
|
-
locks: 0,
|
|
7
|
-
slowQueries: []
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
function wrapDatabase(db) {
|
|
11
|
-
const originalPrepare = db.prepare.bind(db)
|
|
12
|
-
|
|
13
|
-
db.prepare = function(sql) {
|
|
14
|
-
const stmt = originalPrepare(sql)
|
|
15
|
-
const originalRun = stmt.run.bind(stmt)
|
|
16
|
-
const originalGet = stmt.get.bind(stmt)
|
|
17
|
-
const originalAll = stmt.all.bind(stmt)
|
|
18
|
-
|
|
19
|
-
stmt.run = function(...args) {
|
|
20
|
-
const start = process.hrtime.bigint()
|
|
21
|
-
dbStats.activeQueries++
|
|
22
|
-
|
|
23
|
-
try {
|
|
24
|
-
const result = originalRun(...args)
|
|
25
|
-
const duration = Number(process.hrtime.bigint() - start) / 1000000
|
|
26
|
-
|
|
27
|
-
recordDatabase('run', duration, sql)
|
|
28
|
-
if (duration > 100) {
|
|
29
|
-
recordSlowQuery(sql, duration)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return result
|
|
33
|
-
} catch (err) {
|
|
34
|
-
recordDatabase('error', 0, sql)
|
|
35
|
-
throw err
|
|
36
|
-
} finally {
|
|
37
|
-
dbStats.activeQueries--
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
stmt.get = function(...args) {
|
|
42
|
-
const start = process.hrtime.bigint()
|
|
43
|
-
dbStats.activeQueries++
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
const result = originalGet(...args)
|
|
47
|
-
const duration = Number(process.hrtime.bigint() - start) / 1000000
|
|
48
|
-
|
|
49
|
-
recordDatabase('get', duration, sql)
|
|
50
|
-
if (duration > 100) {
|
|
51
|
-
recordSlowQuery(sql, duration)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
return result
|
|
55
|
-
} catch (err) {
|
|
56
|
-
recordDatabase('error', 0, sql)
|
|
57
|
-
throw err
|
|
58
|
-
} finally {
|
|
59
|
-
dbStats.activeQueries--
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
stmt.all = function(...args) {
|
|
64
|
-
const start = process.hrtime.bigint()
|
|
65
|
-
dbStats.activeQueries++
|
|
66
|
-
|
|
67
|
-
try {
|
|
68
|
-
const result = originalAll(...args)
|
|
69
|
-
const duration = Number(process.hrtime.bigint() - start) / 1000000
|
|
70
|
-
|
|
71
|
-
recordDatabase('all', duration, sql)
|
|
72
|
-
if (duration > 100) {
|
|
73
|
-
recordSlowQuery(sql, duration)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return result
|
|
77
|
-
} catch (err) {
|
|
78
|
-
recordDatabase('error', 0, sql)
|
|
79
|
-
throw err
|
|
80
|
-
} finally {
|
|
81
|
-
dbStats.activeQueries--
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return stmt
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return db
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function recordSlowQuery(sql, duration) {
|
|
92
|
-
dbStats.slowQueries.push({
|
|
93
|
-
sql: sql.substring(0, 200),
|
|
94
|
-
duration,
|
|
95
|
-
timestamp: Date.now()
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
if (dbStats.slowQueries.length > 100) {
|
|
99
|
-
dbStats.slowQueries.shift()
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function getDatabaseStats() {
|
|
104
|
-
return {
|
|
105
|
-
connections: dbStats.connections,
|
|
106
|
-
activeQueries: dbStats.activeQueries,
|
|
107
|
-
locks: dbStats.locks,
|
|
108
|
-
slowQueries: dbStats.slowQueries.slice(-10)
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function clearDatabaseStats() {
|
|
113
|
-
dbStats.slowQueries.length = 0
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export {
|
|
117
|
-
wrapDatabase,
|
|
118
|
-
getDatabaseStats,
|
|
119
|
-
clearDatabaseStats
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (typeof globalThis !== 'undefined') {
|
|
123
|
-
globalThis.__dbMonitor = {
|
|
124
|
-
getDatabaseStats,
|
|
125
|
-
clearDatabaseStats
|
|
126
|
-
}
|
|
127
|
-
}
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
import { createLogger } from './logger.js';
|
|
2
|
-
import { HTTP } from '@/config/constants';
|
|
3
|
-
|
|
4
|
-
const log = createLogger('[Resilience]');
|
|
5
|
-
import { AppError, normalizeError } from '@/lib/error-handler';
|
|
6
|
-
|
|
7
|
-
const errorState = { errors: [], circuitBreakers: new Map(), checkpoints: new Map() };
|
|
8
|
-
|
|
9
|
-
export async function retryWithBackoff(fn, options = {}) {
|
|
10
|
-
const { maxAttempts = 3, delay = 1000, backoff = 2, context = {} } = options;
|
|
11
|
-
|
|
12
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
13
|
-
try {
|
|
14
|
-
return await fn();
|
|
15
|
-
} catch (error) {
|
|
16
|
-
if (attempt === maxAttempts) {
|
|
17
|
-
const normalized = normalizeError(error);
|
|
18
|
-
log.error('final attempt failed:', { ...context, attempt, error: normalized.toJSON() });
|
|
19
|
-
throw normalized;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const waitTime = delay * Math.pow(backoff, attempt - 1);
|
|
23
|
-
log.warn(`attempt ${attempt} failed, retrying in ${waitTime}ms`, context);
|
|
24
|
-
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function createCircuitBreaker(name, options = {}) {
|
|
30
|
-
const { threshold = 5, resetTimeout = 30000 } = options;
|
|
31
|
-
|
|
32
|
-
if (!errorState.circuitBreakers.has(name)) {
|
|
33
|
-
errorState.circuitBreakers.set(name, {
|
|
34
|
-
failures: 0,
|
|
35
|
-
state: 'closed',
|
|
36
|
-
lastFailure: null,
|
|
37
|
-
nextAttempt: null,
|
|
38
|
-
threshold,
|
|
39
|
-
resetTimeout
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return errorState.circuitBreakers.get(name);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export async function withCircuitBreaker(name, fn, options = {}) {
|
|
47
|
-
const breaker = createCircuitBreaker(name, options);
|
|
48
|
-
|
|
49
|
-
if (breaker.state === 'open') {
|
|
50
|
-
const now = Date.now();
|
|
51
|
-
if (breaker.nextAttempt && now < breaker.nextAttempt) {
|
|
52
|
-
throw new AppError(`Service unavailable: ${name}`, 'CIRCUIT_OPEN', HTTP.SERVICE_UNAVAILABLE, { nextAttempt: breaker.nextAttempt });
|
|
53
|
-
}
|
|
54
|
-
breaker.state = 'half-open';
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
try {
|
|
58
|
-
const result = await fn();
|
|
59
|
-
if (breaker.state === 'half-open') {
|
|
60
|
-
}
|
|
61
|
-
breaker.failures = 0;
|
|
62
|
-
breaker.state = 'closed';
|
|
63
|
-
return result;
|
|
64
|
-
} catch (error) {
|
|
65
|
-
breaker.failures++;
|
|
66
|
-
breaker.lastFailure = Date.now();
|
|
67
|
-
|
|
68
|
-
if (breaker.failures >= breaker.threshold) {
|
|
69
|
-
breaker.state = 'open';
|
|
70
|
-
breaker.nextAttempt = Date.now() + breaker.resetTimeout;
|
|
71
|
-
log.error(`circuit ${name} opened after ${breaker.failures} failures`);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
throw error;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function checkpoint(name, state) {
|
|
79
|
-
errorState.checkpoints.set(name, {
|
|
80
|
-
state: JSON.parse(JSON.stringify(state)),
|
|
81
|
-
timestamp: Date.now()
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function restoreCheckpoint(name) {
|
|
86
|
-
const cp = errorState.checkpoints.get(name);
|
|
87
|
-
if (cp) {
|
|
88
|
-
return cp.state;
|
|
89
|
-
}
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function logRecovery(context, action) {
|
|
94
|
-
errorState.errors.push({
|
|
95
|
-
type: 'recovery',
|
|
96
|
-
context,
|
|
97
|
-
action,
|
|
98
|
-
timestamp: new Date().toISOString()
|
|
99
|
-
});
|
|
100
|
-
if (errorState.errors.length > 1000) errorState.errors.shift();
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export function getErrorStats() {
|
|
104
|
-
const recent = errorState.errors.slice(-100);
|
|
105
|
-
const byType = {};
|
|
106
|
-
|
|
107
|
-
for (const err of recent) {
|
|
108
|
-
byType[err.type || 'error'] = (byType[err.type || 'error'] || 0) + 1;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return {
|
|
112
|
-
total: errorState.errors.length,
|
|
113
|
-
recent: recent.length,
|
|
114
|
-
byType,
|
|
115
|
-
circuitBreakers: Array.from(errorState.circuitBreakers.entries()).map(([name, state]) => ({
|
|
116
|
-
name,
|
|
117
|
-
state: state.state,
|
|
118
|
-
failures: state.failures,
|
|
119
|
-
lastFailure: state.lastFailure ? new Date(state.lastFailure).toISOString() : null
|
|
120
|
-
})),
|
|
121
|
-
checkpoints: Array.from(errorState.checkpoints.keys())
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (typeof global !== 'undefined') {
|
|
126
|
-
global.errorState = errorState;
|
|
127
|
-
global.getErrorStats = getErrorStats;
|
|
128
|
-
global.retryWithBackoff = retryWithBackoff;
|
|
129
|
-
global.withCircuitBreaker = withCircuitBreaker;
|
|
130
|
-
global.checkpoint = checkpoint;
|
|
131
|
-
global.restoreCheckpoint = restoreCheckpoint;
|
|
132
|
-
}
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
import { startMonitoring as startResourceMonitoring } from '@/lib/resource-monitor.js'
|
|
2
|
-
import { checkAllThresholds, registerAlertHandler } from '@/lib/alert-manager.js'
|
|
3
|
-
import { getAllMetrics } from '@/lib/metrics-collector.js'
|
|
4
|
-
import { info, warn, error } from '@/lib/log-aggregator.js'
|
|
5
|
-
import path from 'path'
|
|
6
|
-
import { fileURLToPath } from 'url'
|
|
7
|
-
|
|
8
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
9
|
-
let alertCheckInterval = null
|
|
10
|
-
let initialized = false
|
|
11
|
-
|
|
12
|
-
function initializeMonitoring(config = {}) {
|
|
13
|
-
if (initialized) {
|
|
14
|
-
warn('Monitoring already initialized')
|
|
15
|
-
return
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const {
|
|
19
|
-
resourceInterval = 5000,
|
|
20
|
-
alertCheckInterval: alertInterval = 10000,
|
|
21
|
-
dbPath = path.join(__dirname, '../../data/app.db')
|
|
22
|
-
} = config
|
|
23
|
-
|
|
24
|
-
startResourceMonitoring(resourceInterval, dbPath)
|
|
25
|
-
info('Resource monitoring started', { interval: resourceInterval })
|
|
26
|
-
|
|
27
|
-
alertCheckInterval = setInterval(() => {
|
|
28
|
-
try {
|
|
29
|
-
const metrics = getAllMetrics()
|
|
30
|
-
checkAllThresholds(metrics)
|
|
31
|
-
} catch (err) {
|
|
32
|
-
error('Alert check failed', { error: err.message })
|
|
33
|
-
}
|
|
34
|
-
}, alertInterval)
|
|
35
|
-
|
|
36
|
-
info('Alert checking started', { interval: alertInterval })
|
|
37
|
-
|
|
38
|
-
registerAlertHandler((alert) => {
|
|
39
|
-
if (alert.severity === 'critical') {
|
|
40
|
-
error(`CRITICAL ALERT: ${alert.message}`, alert.metadata)
|
|
41
|
-
} else if (alert.severity === 'warning') {
|
|
42
|
-
warn(`WARNING: ${alert.message}`, alert.metadata)
|
|
43
|
-
}
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
info('Alert handlers registered')
|
|
47
|
-
|
|
48
|
-
initialized = true
|
|
49
|
-
info('Monitoring system initialized')
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async function shutdownMonitoring() {
|
|
53
|
-
if (!initialized) return
|
|
54
|
-
|
|
55
|
-
if (alertCheckInterval) {
|
|
56
|
-
clearInterval(alertCheckInterval)
|
|
57
|
-
alertCheckInterval = null
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const { stopMonitoring } = await import('@/lib/resource-monitor.js')
|
|
61
|
-
stopMonitoring()
|
|
62
|
-
|
|
63
|
-
initialized = false
|
|
64
|
-
info('Monitoring system shutdown')
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export { initializeMonitoring, shutdownMonitoring }
|
package/src/lib/next-compat.js
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
const MAX_BODY_SIZE = 10 * 1024 * 1024;
|
|
2
|
-
|
|
3
|
-
// Wrap node's raw (plain-object, lowercased) headers so both the Fetch-style
|
|
4
|
-
// `headers.get('x')` API and direct `headers['x']` index access work. Node
|
|
5
|
-
// already lowercases header names; `.get()` lowercases its argument to match.
|
|
6
|
-
function wrapHeaders(raw) {
|
|
7
|
-
const h = raw || {};
|
|
8
|
-
return new Proxy(h, {
|
|
9
|
-
get(target, prop) {
|
|
10
|
-
if (prop === 'get') return (name) => target[String(name).toLowerCase()] ?? null;
|
|
11
|
-
if (prop === 'has') return (name) => String(name).toLowerCase() in target;
|
|
12
|
-
return target[prop];
|
|
13
|
-
},
|
|
14
|
-
});
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export class NextRequest {
|
|
18
|
-
constructor(req, body, url) {
|
|
19
|
-
this.method = req.method;
|
|
20
|
-
this.headers = wrapHeaders(req.headers);
|
|
21
|
-
this.url = url;
|
|
22
|
-
this.body = body;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
async json() {
|
|
26
|
-
return this.body;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async text() {
|
|
30
|
-
return typeof this.body === 'string' ? this.body : JSON.stringify(this.body);
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export class NextResponse {
|
|
35
|
-
constructor(body, init = {}) {
|
|
36
|
-
this.body = body;
|
|
37
|
-
this.status = init.status || 200;
|
|
38
|
-
this.headers = new Map(Object.entries(init.headers || {}));
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async json() {
|
|
42
|
-
return this.body;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
static json(body, init = {}) {
|
|
46
|
-
return new NextResponse(body, init);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export async function readBody(req) {
|
|
51
|
-
return new Promise((resolve, reject) => {
|
|
52
|
-
let data = '';
|
|
53
|
-
let size = 0;
|
|
54
|
-
req.on('data', (chunk) => {
|
|
55
|
-
size += chunk.length;
|
|
56
|
-
if (size > MAX_BODY_SIZE) {
|
|
57
|
-
req.destroy();
|
|
58
|
-
reject(new Error('Request body too large'));
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
data += chunk;
|
|
62
|
-
});
|
|
63
|
-
req.on('end', () => {
|
|
64
|
-
try {
|
|
65
|
-
resolve(data ? JSON.parse(data) : {});
|
|
66
|
-
} catch {
|
|
67
|
-
resolve(data);
|
|
68
|
-
}
|
|
69
|
-
});
|
|
70
|
-
req.on('error', (err) => reject(err));
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const HEADER_MAP = {
|
|
75
|
-
'content-type': 'Content-Type',
|
|
76
|
-
'content-length': 'Content-Length',
|
|
77
|
-
'set-cookie': 'Set-Cookie',
|
|
78
|
-
'cache-control': 'Cache-Control',
|
|
79
|
-
'expires': 'Expires',
|
|
80
|
-
'etag': 'ETag',
|
|
81
|
-
'last-modified': 'Last-Modified',
|
|
82
|
-
'location': 'Location',
|
|
83
|
-
'date': 'Date',
|
|
84
|
-
'connection': 'Connection',
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
export function normalizeHeaderName(key) {
|
|
88
|
-
return HEADER_MAP[key.toLowerCase()] || key;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export function registerGlobals() {
|
|
92
|
-
globalThis.NextRequest = NextRequest;
|
|
93
|
-
globalThis.NextResponse = NextResponse;
|
|
94
|
-
}
|