thatcher 1.0.61 → 1.0.62
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 +1 -1
- package/src/lib/bulk-operations.js +91 -0
- package/src/server/server.js +59 -0
- package/src/ui/grid-view-renderer.js +84 -3
package/package.json
CHANGED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { get, update, remove } from './busybase/store.js';
|
|
2
|
+
import { validateUpdate, sanitizeData } from './validation/index.js';
|
|
3
|
+
import { requirePermission } from './auth-middleware.js';
|
|
4
|
+
import { permissionService } from '../services/permission.service.js';
|
|
5
|
+
import { executeHook } from './hook-engine.js';
|
|
6
|
+
import { logAction } from './busybase/audit.js';
|
|
7
|
+
import { now } from './id-helpers.js';
|
|
8
|
+
import { createLogger } from './logger.js';
|
|
9
|
+
|
|
10
|
+
const log = createLogger('[BulkOps]');
|
|
11
|
+
const MAX_BULK_IDS = 500;
|
|
12
|
+
|
|
13
|
+
async function bulkDeleteOne(entityName, spec, id, user) {
|
|
14
|
+
await requirePermission(user, spec, 'delete');
|
|
15
|
+
const existing = await get(entityName, id, { user });
|
|
16
|
+
if (!existing) throw new Error('Not found');
|
|
17
|
+
if (!permissionService.checkRowAccess(user, spec, existing)) throw new Error('Access denied');
|
|
18
|
+
|
|
19
|
+
let result;
|
|
20
|
+
if (spec.immutable === true && spec.immutable_strategy === 'move_to_archive') {
|
|
21
|
+
const archiveData = { archived: true, archived_at: now(), archived_by: user?.id };
|
|
22
|
+
result = await update(entityName, id, archiveData, user);
|
|
23
|
+
logAction(entityName, id, 'archive', user?.id, existing, archiveData);
|
|
24
|
+
} else if (spec.fields?.status) {
|
|
25
|
+
result = await update(entityName, id, { status: 'deleted' }, user);
|
|
26
|
+
logAction(entityName, id, 'delete', user?.id, existing, { status: 'deleted' });
|
|
27
|
+
} else {
|
|
28
|
+
result = await remove(entityName, id);
|
|
29
|
+
logAction(entityName, id, 'delete', user?.id, existing, null);
|
|
30
|
+
}
|
|
31
|
+
executeHook(`delete:${entityName}:after`, { entity: entityName, id, data: result, user }).catch(e => log.error(e.message));
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function bulkSetFieldOne(entityName, spec, id, user, field, value) {
|
|
36
|
+
await requirePermission(user, spec, 'edit');
|
|
37
|
+
const existing = await get(entityName, id, { user });
|
|
38
|
+
if (!existing) throw new Error('Not found');
|
|
39
|
+
if (!permissionService.checkRowAccess(user, spec, existing)) throw new Error('Access denied');
|
|
40
|
+
|
|
41
|
+
const rawData = { [field]: value };
|
|
42
|
+
permissionService.enforceEditPermissions(user, spec, rawData);
|
|
43
|
+
|
|
44
|
+
const errors = await validateUpdate(entityName, rawData, existing);
|
|
45
|
+
if (Object.keys(errors).length > 0) throw new Error(`Validation failed: ${JSON.stringify(errors)}`);
|
|
46
|
+
|
|
47
|
+
const sanitized = sanitizeData(entityName, rawData, spec, existing);
|
|
48
|
+
const record = await update(entityName, id, sanitized, user);
|
|
49
|
+
logAction(entityName, id, 'update', user?.id, existing, record);
|
|
50
|
+
executeHook(`update:${entityName}:after`, { entity: entityName, id, data: record, before: existing, after: record, user }).catch(e => log.error(e.message));
|
|
51
|
+
return record;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function bulkTransitionOne(entityName, id, workflowName, toState, user) {
|
|
55
|
+
const { transition } = await import('./workflow-engine.js');
|
|
56
|
+
return transition(entityName, id, workflowName, toState, user, 'bulk operation');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function runBulkOperation(entityName, spec, ids, action, user) {
|
|
60
|
+
if (!Array.isArray(ids) || !ids.length) return { ok: false, error: 'ids array required and must be non-empty' };
|
|
61
|
+
if (ids.length > MAX_BULK_IDS) return { ok: false, error: `Cannot process more than ${MAX_BULK_IDS} ids in one bulk operation` };
|
|
62
|
+
if (!action || typeof action.type !== 'string') return { ok: false, error: 'action.type required' };
|
|
63
|
+
|
|
64
|
+
const uniqueIds = [...new Set(ids)];
|
|
65
|
+
const results = [];
|
|
66
|
+
|
|
67
|
+
for (const id of uniqueIds) {
|
|
68
|
+
try {
|
|
69
|
+
if (action.type === 'delete') {
|
|
70
|
+
await bulkDeleteOne(entityName, spec, id, user);
|
|
71
|
+
results.push({ id, success: true });
|
|
72
|
+
} else if (action.type === 'set_field') {
|
|
73
|
+
if (!action.field || typeof action.field !== 'string') throw new Error('action.field required');
|
|
74
|
+
await bulkSetFieldOne(entityName, spec, id, user, action.field, action.value);
|
|
75
|
+
results.push({ id, success: true });
|
|
76
|
+
} else if (action.type === 'transition') {
|
|
77
|
+
if (!action.workflow || !action.toState) throw new Error('action.workflow and action.toState required');
|
|
78
|
+
await bulkTransitionOne(entityName, id, action.workflow, action.toState, user);
|
|
79
|
+
results.push({ id, success: true });
|
|
80
|
+
} else {
|
|
81
|
+
results.push({ id, success: false, error: `Unknown action type "${action.type}"` });
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
results.push({ id, success: false, error: error.message });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const succeeded = results.filter(r => r.success).length;
|
|
89
|
+
const failed = results.length - succeeded;
|
|
90
|
+
return { ok: true, total: results.length, succeeded, failed, results };
|
|
91
|
+
}
|
package/src/server/server.js
CHANGED
|
@@ -134,6 +134,10 @@ export function createServer(options) {
|
|
|
134
134
|
return await handleDeleteWebhook(req, res, id, thatcher, configEngine);
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
if (req.method === 'POST' && id === 'bulk' && !action) {
|
|
138
|
+
return await handleBulkOperation(req, res, entity, thatcher, configEngine);
|
|
139
|
+
}
|
|
140
|
+
|
|
137
141
|
// Check if user has custom route for this
|
|
138
142
|
const userRoutePath = path.join(process.cwd(), 'app/api', ...parts, 'route.js');
|
|
139
143
|
const routeExists = await fileExists(userRoutePath);
|
|
@@ -379,6 +383,61 @@ async function requireAuthedPartner(req, res) {
|
|
|
379
383
|
return user;
|
|
380
384
|
}
|
|
381
385
|
|
|
386
|
+
async function handleBulkOperation(req, res, entityName, thatcher, configEngineArg) {
|
|
387
|
+
const user = await resolveRequestUser(req);
|
|
388
|
+
if (!user) {
|
|
389
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
390
|
+
res.end(JSON.stringify({ error: 'Authentication required' }));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
|
|
395
|
+
if (!configEngine) {
|
|
396
|
+
const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
|
|
397
|
+
configEngine = getConfigEngineSync();
|
|
398
|
+
}
|
|
399
|
+
let spec;
|
|
400
|
+
try {
|
|
401
|
+
spec = configEngine.generateEntitySpec(entityName);
|
|
402
|
+
} catch (e) {
|
|
403
|
+
res.writeHead(404);
|
|
404
|
+
res.end(JSON.stringify({ error: `Entity "${entityName}" not found` }));
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
let body;
|
|
409
|
+
try {
|
|
410
|
+
body = await readBody(req);
|
|
411
|
+
} catch (e) {
|
|
412
|
+
res.writeHead(400);
|
|
413
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const ids = Array.isArray(body?.ids) ? body.ids : null;
|
|
417
|
+
const action = body?.action;
|
|
418
|
+
if (!ids) {
|
|
419
|
+
res.writeHead(400);
|
|
420
|
+
res.end(JSON.stringify({ error: 'ids array required' }));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
const { runBulkOperation } = await import('../lib/bulk-operations.js');
|
|
426
|
+
const result = await runBulkOperation(entityName, spec, ids, action, user);
|
|
427
|
+
if (!result.ok) {
|
|
428
|
+
res.writeHead(400);
|
|
429
|
+
res.end(JSON.stringify({ error: result.error }));
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
433
|
+
res.end(JSON.stringify(result));
|
|
434
|
+
} catch (err) {
|
|
435
|
+
apiLog.error(err.message);
|
|
436
|
+
res.writeHead(500);
|
|
437
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
382
441
|
async function handleCreateWebhook(req, res, thatcher, configEngineArg) {
|
|
383
442
|
const user = await requireAuthedPartner(req, res);
|
|
384
443
|
if (!user) return;
|
|
@@ -30,7 +30,8 @@ function gridRow(entityName, item, columns) {
|
|
|
30
30
|
: '';
|
|
31
31
|
return `<td data-col="${esc(key)}"${editableAttrs}>${rendered}</td>`;
|
|
32
32
|
}).join('');
|
|
33
|
-
|
|
33
|
+
const checkboxCell = `<td style="width:32px"><input type="checkbox" class="bulk-row-select" data-row-id="${esc(item.id)}" onclick="event.stopPropagation()"></td>`;
|
|
34
|
+
return `<tr data-row data-navigate="/${esc(entityName)}/${esc(item.id)}" style="cursor:pointer">${checkboxCell}${cells}</tr>`;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
const GRID_EDIT_SCRIPT = `(function(){
|
|
@@ -77,6 +78,63 @@ const GRID_EDIT_SCRIPT = `(function(){
|
|
|
77
78
|
},true);
|
|
78
79
|
})();`;
|
|
79
80
|
|
|
81
|
+
const BULK_OPS_SCRIPT = `(function(){
|
|
82
|
+
function selectedIds(){
|
|
83
|
+
return Array.prototype.slice.call(document.querySelectorAll('.bulk-row-select:checked')).map(function(cb){return cb.getAttribute('data-row-id')});
|
|
84
|
+
}
|
|
85
|
+
function updateToolbar(){
|
|
86
|
+
var ids=selectedIds();
|
|
87
|
+
var toolbar=document.getElementById('bulk-toolbar');
|
|
88
|
+
var label=document.getElementById('bulk-count-label');
|
|
89
|
+
if(ids.length>0){toolbar.style.display='flex';label.textContent=ids.length+' selected'}
|
|
90
|
+
else{toolbar.style.display='none'}
|
|
91
|
+
}
|
|
92
|
+
document.addEventListener('change',function(e){
|
|
93
|
+
if(e.target.classList&&e.target.classList.contains('bulk-row-select')){updateToolbar()}
|
|
94
|
+
if(e.target.id==='bulk-select-all'){
|
|
95
|
+
var checked=e.target.checked;
|
|
96
|
+
document.querySelectorAll('.bulk-row-select').forEach(function(cb){cb.checked=checked});
|
|
97
|
+
updateToolbar();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
function postBulk(entity,ids,action,onDone){
|
|
101
|
+
var status=document.getElementById('bulk-status');
|
|
102
|
+
status.textContent='Processing...';
|
|
103
|
+
fetch('/api/'+entity+'/bulk',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ids:ids,action:action})})
|
|
104
|
+
.then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
|
|
105
|
+
.then(function(res){
|
|
106
|
+
if(res.ok){
|
|
107
|
+
var failed=res.d.failed||0;
|
|
108
|
+
status.textContent=res.d.succeeded+' succeeded, '+failed+' failed';
|
|
109
|
+
if(failed===0){setTimeout(function(){location.reload()},800)}
|
|
110
|
+
}else{status.textContent='Error: '+(res.d.error||'bulk operation failed')}
|
|
111
|
+
if(onDone)onDone();
|
|
112
|
+
})
|
|
113
|
+
.catch(function(err){status.textContent='Error: '+err.message});
|
|
114
|
+
}
|
|
115
|
+
window.bulkDelete=function(entity){
|
|
116
|
+
var ids=selectedIds();
|
|
117
|
+
if(!ids.length)return;
|
|
118
|
+
if(!window.confirm('Delete '+ids.length+' items?'))return;
|
|
119
|
+
postBulk(entity,ids,{type:'delete'});
|
|
120
|
+
};
|
|
121
|
+
window.bulkSetField=function(entity){
|
|
122
|
+
var ids=selectedIds();
|
|
123
|
+
if(!ids.length)return;
|
|
124
|
+
var field=document.getElementById('bulk-set-field').value;
|
|
125
|
+
var value=document.getElementById('bulk-set-value').value;
|
|
126
|
+
if(!field)return;
|
|
127
|
+
postBulk(entity,ids,{type:'set_field',field:field,value:value});
|
|
128
|
+
};
|
|
129
|
+
window.bulkTransition=function(entity,workflow){
|
|
130
|
+
var ids=selectedIds();
|
|
131
|
+
if(!ids.length)return;
|
|
132
|
+
var toState=document.getElementById('bulk-transition-target').value;
|
|
133
|
+
if(!toState)return;
|
|
134
|
+
postBulk(entity,ids,{type:'transition',workflow:workflow,toState:toState});
|
|
135
|
+
};
|
|
136
|
+
})();`;
|
|
137
|
+
|
|
80
138
|
export function renderGridView(user, entityName, spec, records, options = {}) {
|
|
81
139
|
const label = getEntityLabel(spec, true) || entityName;
|
|
82
140
|
const columns = getColumns(spec);
|
|
@@ -105,9 +163,32 @@ export function renderGridView(user, entityName, spec, records, options = {}) {
|
|
|
105
163
|
const rows = records.map(item => gridRow(entityName, item, columns)).join('') ||
|
|
106
164
|
emptyRow(columns.length || 1, `No ${esc(label.toLowerCase())} found`);
|
|
107
165
|
|
|
166
|
+
const editableFieldOpts = columns.filter(([, f]) => isEditable(f)).map(([key, f]) =>
|
|
167
|
+
`<option value="${esc(key)}">${esc(f.label || key)}</option>`
|
|
168
|
+
).join('');
|
|
169
|
+
|
|
170
|
+
const workflowStageOpts = spec.workflowDef?.stages
|
|
171
|
+
? spec.workflowDef.stages.map(s => `<option value="${esc(s.name)}">${esc(s.label || s.name)}</option>`).join('')
|
|
172
|
+
: '';
|
|
173
|
+
const transitionButton = spec.workflow && workflowStageOpts
|
|
174
|
+
? `<select id="bulk-transition-target"><option value="">Transition to...</option>${workflowStageOpts}</select>
|
|
175
|
+
<button type="button" class="btn-ghost-clean" data-action="bulkTransition" data-args='["${esc(entityName)}","${esc(spec.workflow)}"]'>Apply</button>`
|
|
176
|
+
: '';
|
|
177
|
+
|
|
178
|
+
const bulkToolbar = `<div id="bulk-toolbar" style="display:none;align-items:center;gap:8px;padding:8px;background:var(--color-bg-secondary,#f5f5f5);border-radius:4px;margin-bottom:8px;flex-wrap:wrap">
|
|
179
|
+
<span id="bulk-count-label" style="font-size:13px;font-weight:600"></span>
|
|
180
|
+
<button type="button" class="btn-danger-clean" data-action="bulkDelete" data-args='["${esc(entityName)}"]'>Delete Selected</button>
|
|
181
|
+
<select id="bulk-set-field"><option value="">Set field...</option>${editableFieldOpts}</select>
|
|
182
|
+
<input type="text" id="bulk-set-value" placeholder="value" style="width:120px">
|
|
183
|
+
<button type="button" class="btn-ghost-clean" data-action="bulkSetField" data-args='["${esc(entityName)}"]'>Apply</button>
|
|
184
|
+
${transitionButton}
|
|
185
|
+
<span id="bulk-status" style="font-size:13px"></span>
|
|
186
|
+
</div>`;
|
|
187
|
+
|
|
108
188
|
const content = `<div class="page-header">
|
|
109
189
|
<div><h1 class="page-title">${esc(label)}</h1><p class="page-subtitle">${records.length} total ${esc(label.toLowerCase())}</p></div>
|
|
110
190
|
</div>
|
|
191
|
+
${bulkToolbar}
|
|
111
192
|
<div class="table-wrap">
|
|
112
193
|
<div class="table-toolbar">
|
|
113
194
|
<div class="table-search"><input id="search-input" type="text" placeholder="Search ${esc(label.toLowerCase())}..."></div>
|
|
@@ -115,10 +196,10 @@ export function renderGridView(user, entityName, spec, records, options = {}) {
|
|
|
115
196
|
<span class="table-count" id="row-count">${records.length} items</span>
|
|
116
197
|
</div>
|
|
117
198
|
<table class="data-table" role="grid" data-page-size="${esc(pageSize)}">
|
|
118
|
-
<thead><tr>${headerCells}</tr></thead>
|
|
199
|
+
<thead><tr><th style="width:32px"><input type="checkbox" id="bulk-select-all"></th>${headerCells}</tr></thead>
|
|
119
200
|
<tbody>${rows}</tbody>
|
|
120
201
|
</table>
|
|
121
202
|
</div>`;
|
|
122
203
|
|
|
123
|
-
return page(user, `${label} | Thatcher`, null, content, [TABLE_SCRIPT, GRID_EDIT_SCRIPT]);
|
|
204
|
+
return page(user, `${label} | Thatcher`, null, content, [TABLE_SCRIPT, GRID_EDIT_SCRIPT, BULK_OPS_SCRIPT]);
|
|
124
205
|
}
|