thatcher 1.0.63 → 1.0.65
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/config/spec-helpers.js +4 -2
- package/src/lib/config-generator-engine.js +18 -0
- package/src/lib/crud-handlers.js +4 -0
- package/src/lib/field-registry.js +8 -1
- package/src/lib/scheduler-engine.js +69 -0
- package/src/lib/validation/entity-validators.js +23 -1
- package/src/server/server.js +290 -0
- package/src/ui/entity-renderer.js +27 -3
- package/src/ui/page-handler-admin.js +9 -0
- package/src/ui/scheduled-job-renderer.js +92 -0
package/package.json
CHANGED
|
@@ -94,10 +94,12 @@ export function getInitialState(spec) {
|
|
|
94
94
|
state[key] = field.default;
|
|
95
95
|
} else if (field.type === 'bool') {
|
|
96
96
|
state[key] = false;
|
|
97
|
-
} else if (field.type === 'int' || field.type === 'decimal') {
|
|
97
|
+
} else if (field.type === 'int' || field.type === 'decimal' || field.type === 'currency') {
|
|
98
98
|
state[key] = 0;
|
|
99
|
-
} else if (field.type === 'json') {
|
|
99
|
+
} else if (field.type === 'json' || field.type === 'multiselect') {
|
|
100
100
|
state[key] = [];
|
|
101
|
+
} else if (field.type === 'file' || field.type === 'attachment') {
|
|
102
|
+
state[key] = null;
|
|
101
103
|
} else if (field.type === 'date' || field.type === 'timestamp') {
|
|
102
104
|
state[key] = null;
|
|
103
105
|
} else {
|
|
@@ -79,12 +79,30 @@ const ENTITY_TEMPLATE_ENTITY_DEFAULT = {
|
|
|
79
79
|
},
|
|
80
80
|
};
|
|
81
81
|
|
|
82
|
+
const SCHEDULED_JOB_ENTITY_DEFAULT = {
|
|
83
|
+
label: 'Scheduled Job',
|
|
84
|
+
label_plural: 'Scheduled Jobs',
|
|
85
|
+
system_entity: true,
|
|
86
|
+
fields: {
|
|
87
|
+
name: { type: 'text', required: true, label: 'Name' },
|
|
88
|
+
entity: { type: 'text', required: true, label: 'Entity' },
|
|
89
|
+
action: { type: 'json', required: true, label: 'Action' },
|
|
90
|
+
filter: { type: 'json', label: 'Filter' },
|
|
91
|
+
interval_minutes: { type: 'int', required: true, label: 'Interval (minutes)' },
|
|
92
|
+
last_run_at: { type: 'int', label: 'Last Run At' },
|
|
93
|
+
next_run_at: { type: 'int', label: 'Next Run At' },
|
|
94
|
+
enabled: { type: 'bool', default: true, label: 'Enabled' },
|
|
95
|
+
owner_id: { type: 'ref', ref: 'user', required: true, label: 'Owner' },
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
82
99
|
function withWebhookDefaults(masterConfig) {
|
|
83
100
|
const entities = { ...(masterConfig.entities || {}) };
|
|
84
101
|
let changed = false;
|
|
85
102
|
if (!entities.webhook) { entities.webhook = WEBHOOK_ENTITY_DEFAULT; changed = true; }
|
|
86
103
|
if (!entities.webhook_delivery) { entities.webhook_delivery = WEBHOOK_DELIVERY_ENTITY_DEFAULT; changed = true; }
|
|
87
104
|
if (!entities.entity_template) { entities.entity_template = ENTITY_TEMPLATE_ENTITY_DEFAULT; changed = true; }
|
|
105
|
+
if (!entities.scheduled_job) { entities.scheduled_job = SCHEDULED_JOB_ENTITY_DEFAULT; changed = true; }
|
|
88
106
|
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
89
107
|
}
|
|
90
108
|
|
package/src/lib/crud-handlers.js
CHANGED
|
@@ -215,10 +215,14 @@ function coerceFieldValue(value, type) {
|
|
|
215
215
|
switch (type) {
|
|
216
216
|
case 'int':
|
|
217
217
|
case 'decimal':
|
|
218
|
+
case 'currency':
|
|
218
219
|
return Number(value);
|
|
219
220
|
case 'bool':
|
|
220
221
|
return Boolean(value);
|
|
221
222
|
case 'json':
|
|
223
|
+
case 'multiselect':
|
|
224
|
+
case 'file':
|
|
225
|
+
case 'attachment':
|
|
222
226
|
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
223
227
|
case 'date':
|
|
224
228
|
case 'timestamp':
|
|
@@ -4,11 +4,15 @@ export function coerceFieldValue(value, type) {
|
|
|
4
4
|
switch (type) {
|
|
5
5
|
case 'int':
|
|
6
6
|
case 'decimal':
|
|
7
|
+
case 'currency':
|
|
7
8
|
return Number(value);
|
|
8
9
|
case 'bool':
|
|
9
10
|
case 'boolean':
|
|
10
11
|
return value === true || value === 'true' || value === 1;
|
|
11
12
|
case 'json':
|
|
13
|
+
case 'multiselect':
|
|
14
|
+
case 'file':
|
|
15
|
+
case 'attachment':
|
|
12
16
|
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
13
17
|
case 'date':
|
|
14
18
|
case 'timestamp': {
|
|
@@ -31,10 +35,13 @@ export function deserializeField(value, type) {
|
|
|
31
35
|
|
|
32
36
|
switch (type) {
|
|
33
37
|
case 'json':
|
|
38
|
+
case 'multiselect':
|
|
39
|
+
case 'file':
|
|
40
|
+
case 'attachment':
|
|
34
41
|
try {
|
|
35
42
|
return typeof value === 'string' ? JSON.parse(value) : value;
|
|
36
43
|
} catch {
|
|
37
|
-
return {};
|
|
44
|
+
return type === 'multiselect' ? [] : {};
|
|
38
45
|
}
|
|
39
46
|
case 'bool':
|
|
40
47
|
return Boolean(value);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { list, update, getBy } from './busybase/store.js';
|
|
2
|
+
import { runBulkOperation } from './bulk-operations.js';
|
|
3
|
+
import { getConfigEngineSync } from './config-generator-engine.js';
|
|
4
|
+
import { now } from './id-helpers.js';
|
|
5
|
+
import { createLogger } from './logger.js';
|
|
6
|
+
|
|
7
|
+
const log = createLogger('[Scheduler]');
|
|
8
|
+
const CHECK_INTERVAL_MS = 60 * 1000;
|
|
9
|
+
let intervalHandle = null;
|
|
10
|
+
|
|
11
|
+
export async function runDueJobs() {
|
|
12
|
+
const nowTs = now();
|
|
13
|
+
const dueJobs = await list('scheduled_job', { enabled: true });
|
|
14
|
+
const results = [];
|
|
15
|
+
for (const job of dueJobs) {
|
|
16
|
+
if (!job.enabled) continue;
|
|
17
|
+
if (job.next_run_at && job.next_run_at > nowTs) continue;
|
|
18
|
+
results.push(await runOneJob(job, nowTs));
|
|
19
|
+
}
|
|
20
|
+
return results;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function runOneJob(job, nowTs) {
|
|
24
|
+
try {
|
|
25
|
+
const owner = await getBy('users', 'id', job.owner_id);
|
|
26
|
+
if (!owner) throw new Error(`Owner ${job.owner_id} not found`);
|
|
27
|
+
|
|
28
|
+
const configEngine = getConfigEngineSync();
|
|
29
|
+
const spec = configEngine.generateEntitySpec(job.entity);
|
|
30
|
+
|
|
31
|
+
const filter = job.filter ? (typeof job.filter === 'string' ? JSON.parse(job.filter) : job.filter) : {};
|
|
32
|
+
const action = typeof job.action === 'string' ? JSON.parse(job.action) : job.action;
|
|
33
|
+
|
|
34
|
+
// list() is org/row-access scoped by passing {user: owner} -- the same
|
|
35
|
+
// scoping crud-handlers.js's read path enforces for an interactive
|
|
36
|
+
// request, so a job cannot reach records outside its owner's org just
|
|
37
|
+
// because it runs unattended.
|
|
38
|
+
const targets = await list(job.entity, filter, { user: owner });
|
|
39
|
+
const ids = targets.map(r => r.id);
|
|
40
|
+
|
|
41
|
+
let result = { ok: true, total: 0, succeeded: 0, failed: 0, results: [] };
|
|
42
|
+
if (ids.length) {
|
|
43
|
+
result = await runBulkOperation(job.entity, spec, ids, action, owner);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const nextRunAt = nowTs + job.interval_minutes * 60;
|
|
47
|
+
await update('scheduled_job', job.id, { last_run_at: nowTs, next_run_at: nextRunAt });
|
|
48
|
+
log.info(`Job "${job.name}" ran: ${result.succeeded}/${result.total} succeeded`);
|
|
49
|
+
return { job_id: job.id, ok: true, ...result };
|
|
50
|
+
} catch (err) {
|
|
51
|
+
log.error(`Job "${job.name}" failed: ${err.message}`);
|
|
52
|
+
const nextRunAt = nowTs + job.interval_minutes * 60;
|
|
53
|
+
await update('scheduled_job', job.id, { last_run_at: nowTs, next_run_at: nextRunAt }).catch(() => {});
|
|
54
|
+
return { job_id: job.id, ok: false, error: err.message };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function startScheduler() {
|
|
59
|
+
if (intervalHandle) return intervalHandle;
|
|
60
|
+
intervalHandle = setInterval(() => {
|
|
61
|
+
runDueJobs().catch(e => log.error(e.message));
|
|
62
|
+
}, CHECK_INTERVAL_MS);
|
|
63
|
+
if (intervalHandle.unref) intervalHandle.unref();
|
|
64
|
+
return intervalHandle;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function stopScheduler() {
|
|
68
|
+
if (intervalHandle) { clearInterval(intervalHandle); intervalHandle = null; }
|
|
69
|
+
}
|
|
@@ -45,6 +45,18 @@ export async function validateField(fieldDef, value, options = {}) {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// Multi-select: value validated as array by validateType above; membership checked here (needs entityName for option-list resolution, same as enum)
|
|
49
|
+
if (fieldDef.type === 'multiselect' && fieldDef.options) {
|
|
50
|
+
const allowed = resolveEnumOptions(fieldDef, entityName);
|
|
51
|
+
const arr = Array.isArray(value) ? value : (typeof value === 'string' ? JSON.parse(value) : []);
|
|
52
|
+
if (allowed.length > 0 && arr.some(v => !allowed.includes(v))) {
|
|
53
|
+
return {
|
|
54
|
+
valid: false,
|
|
55
|
+
error: `Invalid value(s) for '${fieldName}'. Expected values from: ${allowed.join(', ')}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
// Reference validation
|
|
49
61
|
if (fieldDef.type === 'ref' && fieldDef.ref) {
|
|
50
62
|
if (existingValue !== undefined && value === existingValue) {
|
|
@@ -72,14 +84,24 @@ function validateType(fieldDef, value, fieldName) {
|
|
|
72
84
|
|
|
73
85
|
if (type === 'string' || type === 'text') {
|
|
74
86
|
if (typeof value !== 'string') return `Field '${fieldName}' must be a string`;
|
|
75
|
-
} else if (type === 'number' || type === 'int' || type === 'decimal') {
|
|
87
|
+
} else if (type === 'number' || type === 'int' || type === 'decimal' || type === 'currency') {
|
|
76
88
|
if (typeof value !== 'number' || isNaN(value)) return `Field '${fieldName}' must be a number`;
|
|
89
|
+
if (type === 'currency' && !Number.isInteger(value)) return `Field '${fieldName}' must be an integer number of cents`;
|
|
90
|
+
if (fieldDef.step && (value % fieldDef.step !== 0)) return `Field '${fieldName}' must be a multiple of ${fieldDef.step}`;
|
|
77
91
|
if (min !== undefined && value < min) return `Field '${fieldName}' must be at least ${min}`;
|
|
78
92
|
if (max !== undefined && value > max) return `Field '${fieldName}' must be at most ${max}`;
|
|
79
93
|
} else if (type === 'boolean' || type === 'bool') {
|
|
80
94
|
if (typeof value !== 'boolean') return `Field '${fieldName}' must be a boolean`;
|
|
81
95
|
} else if (type === 'timestamp' || type === 'date') {
|
|
82
96
|
if (isNaN(Number(value))) return `Field '${fieldName}' must be a valid timestamp`;
|
|
97
|
+
} else if (type === 'multiselect') {
|
|
98
|
+
const arr = Array.isArray(value) ? value : (typeof value === 'string' ? (() => { try { return JSON.parse(value); } catch { return null; } })() : null);
|
|
99
|
+
if (!Array.isArray(arr)) return `Field '${fieldName}' must be an array`;
|
|
100
|
+
} else if (type === 'file' || type === 'attachment') {
|
|
101
|
+
const f = typeof value === 'string' ? (() => { try { return JSON.parse(value); } catch { return null; } })() : value;
|
|
102
|
+
if (!f || typeof f !== 'object' || typeof f.stored_name !== 'string' || typeof f.url !== 'string') {
|
|
103
|
+
return `Field '${fieldName}' must be file metadata with stored_name and url`;
|
|
104
|
+
}
|
|
83
105
|
} else if (type === 'json') {
|
|
84
106
|
if (typeof value === 'string') {
|
|
85
107
|
try { JSON.parse(value); } catch { return `Field '${fieldName}' must be valid JSON`; }
|
package/src/server/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import http from 'http';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import fs from 'fs';
|
|
4
|
+
import crypto from 'crypto';
|
|
4
5
|
import { fileURLToPath } from 'url';
|
|
5
6
|
import { createLogger } from '../lib/logger.js';
|
|
6
7
|
import { getLucia } from '../engine.server.js';
|
|
@@ -72,6 +73,8 @@ export function createServer(options) {
|
|
|
72
73
|
});
|
|
73
74
|
};
|
|
74
75
|
|
|
76
|
+
import('../lib/scheduler-engine.js').then(({ startScheduler }) => startScheduler()).catch(e => log.error(e.message));
|
|
77
|
+
|
|
75
78
|
const server = http.createServer(async (req, res) => {
|
|
76
79
|
globalThis.__debug__.activeRequests.count++;
|
|
77
80
|
|
|
@@ -146,6 +149,22 @@ export function createServer(options) {
|
|
|
146
149
|
return await handleDeleteEntityTemplate(req, res, id, thatcher, configEngine);
|
|
147
150
|
}
|
|
148
151
|
|
|
152
|
+
if (req.method === 'POST' && entity === 'upload' && !id) {
|
|
153
|
+
return await handleFileUpload(req, res, thatcher, configEngine);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (req.method === 'POST' && entity === 'scheduled_job' && id === 'create' && !action) {
|
|
157
|
+
return await handleCreateScheduledJob(req, res, thatcher, configEngine);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (req.method === 'POST' && entity === 'scheduled_job' && id && action === 'update') {
|
|
161
|
+
return await handleUpdateScheduledJob(req, res, id, thatcher, configEngine);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (req.method === 'POST' && entity === 'scheduled_job' && id && action === 'delete') {
|
|
165
|
+
return await handleDeleteScheduledJob(req, res, id, thatcher, configEngine);
|
|
166
|
+
}
|
|
167
|
+
|
|
149
168
|
// Check if user has custom route for this
|
|
150
169
|
const userRoutePath = path.join(process.cwd(), 'app/api', ...parts, 'route.js');
|
|
151
170
|
const routeExists = await fileExists(userRoutePath);
|
|
@@ -206,6 +225,25 @@ async function serveStaticFile(pathname, req, res) {
|
|
|
206
225
|
// Could serve SPA
|
|
207
226
|
return false;
|
|
208
227
|
}
|
|
228
|
+
if (pathname.startsWith('/uploads/')) {
|
|
229
|
+
// basename strips any traversal the URL decoder let through; uploaded
|
|
230
|
+
// files are served read-only, by their sanitized stored name only.
|
|
231
|
+
const name = path.basename(pathname.slice('/uploads/'.length));
|
|
232
|
+
const filePath = path.join(process.cwd(), 'uploads', name);
|
|
233
|
+
try {
|
|
234
|
+
if (await fileExists(filePath) && path.dirname(filePath) === path.join(process.cwd(), 'uploads')) {
|
|
235
|
+
const content = await fs.promises.readFile(filePath);
|
|
236
|
+
res.setHeader('Content-Type', 'application/octet-stream');
|
|
237
|
+
res.setHeader('Content-Disposition', `attachment; filename="${name}"`);
|
|
238
|
+
res.writeHead(200);
|
|
239
|
+
res.end(content);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
} catch (err) {
|
|
243
|
+
if (err.code !== 'ENOENT') staticLog.error(`${filePath} ${err.code}`, { message: err.message });
|
|
244
|
+
}
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
209
247
|
// Try to serve from public/ or static/
|
|
210
248
|
const filePath = path.join(process.cwd(), 'public', pathname);
|
|
211
249
|
try {
|
|
@@ -517,6 +555,145 @@ async function handleDeleteEntityTemplate(req, res, templateId, thatcher, config
|
|
|
517
555
|
}
|
|
518
556
|
}
|
|
519
557
|
|
|
558
|
+
async function handleCreateScheduledJob(req, res, thatcher, configEngineArg) {
|
|
559
|
+
const user = await requireAuthedPartner(req, res);
|
|
560
|
+
if (!user) return;
|
|
561
|
+
|
|
562
|
+
let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
|
|
563
|
+
if (!configEngine) {
|
|
564
|
+
const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
|
|
565
|
+
configEngine = getConfigEngineSync();
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
let body;
|
|
569
|
+
try {
|
|
570
|
+
body = await readBody(req);
|
|
571
|
+
} catch (e) {
|
|
572
|
+
res.writeHead(400);
|
|
573
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const { name, entity, action, filter, interval_minutes } = body || {};
|
|
577
|
+
if (!entity || typeof entity !== 'string') {
|
|
578
|
+
res.writeHead(400);
|
|
579
|
+
res.end(JSON.stringify({ error: 'entity required' }));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
try {
|
|
583
|
+
configEngine.generateEntitySpec(entity);
|
|
584
|
+
} catch {
|
|
585
|
+
res.writeHead(400);
|
|
586
|
+
res.end(JSON.stringify({ error: `Unknown entity "${entity}"` }));
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
590
|
+
res.writeHead(400);
|
|
591
|
+
res.end(JSON.stringify({ error: 'name required' }));
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (!action || typeof action !== 'object' || typeof action.type !== 'string') {
|
|
595
|
+
res.writeHead(400);
|
|
596
|
+
res.end(JSON.stringify({ error: 'action.type required' }));
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const intervalMinutes = Number(interval_minutes);
|
|
600
|
+
if (!Number.isFinite(intervalMinutes) || intervalMinutes <= 0) {
|
|
601
|
+
res.writeHead(400);
|
|
602
|
+
res.end(JSON.stringify({ error: 'interval_minutes must be a positive number' }));
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
try {
|
|
607
|
+
const { create } = await import('../lib/busybase/store.js');
|
|
608
|
+
const { now } = await import('../lib/id-helpers.js');
|
|
609
|
+
const nowTs = now();
|
|
610
|
+
const record = await create('scheduled_job', {
|
|
611
|
+
name: name.trim(),
|
|
612
|
+
entity,
|
|
613
|
+
action,
|
|
614
|
+
filter: filter || {},
|
|
615
|
+
interval_minutes: intervalMinutes,
|
|
616
|
+
last_run_at: null,
|
|
617
|
+
next_run_at: nowTs,
|
|
618
|
+
enabled: true,
|
|
619
|
+
owner_id: user.id,
|
|
620
|
+
}, user);
|
|
621
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
622
|
+
res.end(JSON.stringify({ ok: true, id: record.id }));
|
|
623
|
+
} catch (err) {
|
|
624
|
+
apiLog.error(err.message);
|
|
625
|
+
res.writeHead(500);
|
|
626
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function handleUpdateScheduledJob(req, res, jobId, thatcher, configEngineArg) {
|
|
631
|
+
const user = await requireAuthedPartner(req, res);
|
|
632
|
+
if (!user) return;
|
|
633
|
+
|
|
634
|
+
let body;
|
|
635
|
+
try {
|
|
636
|
+
body = await readBody(req);
|
|
637
|
+
} catch (e) {
|
|
638
|
+
res.writeHead(400);
|
|
639
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
try {
|
|
644
|
+
const { update, get } = await import('../lib/busybase/store.js');
|
|
645
|
+
const existing = await get('scheduled_job', jobId);
|
|
646
|
+
if (!existing) {
|
|
647
|
+
res.writeHead(404);
|
|
648
|
+
res.end(JSON.stringify({ error: 'Job not found' }));
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const patch = {};
|
|
652
|
+
if (typeof body?.enabled === 'boolean') patch.enabled = body.enabled;
|
|
653
|
+
if (typeof body?.name === 'string' && body.name.trim()) patch.name = body.name.trim();
|
|
654
|
+
if (body?.interval_minutes !== undefined) {
|
|
655
|
+
const intervalMinutes = Number(body.interval_minutes);
|
|
656
|
+
if (!Number.isFinite(intervalMinutes) || intervalMinutes <= 0) {
|
|
657
|
+
res.writeHead(400);
|
|
658
|
+
res.end(JSON.stringify({ error: 'interval_minutes must be a positive number' }));
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
patch.interval_minutes = intervalMinutes;
|
|
662
|
+
}
|
|
663
|
+
if (body?.filter !== undefined) patch.filter = body.filter;
|
|
664
|
+
if (body?.action !== undefined) patch.action = body.action;
|
|
665
|
+
const record = await update('scheduled_job', jobId, patch);
|
|
666
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
667
|
+
res.end(JSON.stringify({ ok: true, data: record }));
|
|
668
|
+
} catch (err) {
|
|
669
|
+
apiLog.error(err.message);
|
|
670
|
+
res.writeHead(500);
|
|
671
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async function handleDeleteScheduledJob(req, res, jobId, thatcher, configEngineArg) {
|
|
676
|
+
const user = await requireAuthedPartner(req, res);
|
|
677
|
+
if (!user) return;
|
|
678
|
+
|
|
679
|
+
try {
|
|
680
|
+
const { remove, get } = await import('../lib/busybase/store.js');
|
|
681
|
+
const existing = await get('scheduled_job', jobId);
|
|
682
|
+
if (!existing) {
|
|
683
|
+
res.writeHead(404);
|
|
684
|
+
res.end(JSON.stringify({ error: 'Job not found' }));
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
await remove('scheduled_job', jobId);
|
|
688
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
689
|
+
res.end(JSON.stringify({ ok: true }));
|
|
690
|
+
} catch (err) {
|
|
691
|
+
apiLog.error(err.message);
|
|
692
|
+
res.writeHead(500);
|
|
693
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
520
697
|
async function handleCreateWebhook(req, res, thatcher, configEngineArg) {
|
|
521
698
|
const user = await requireAuthedPartner(req, res);
|
|
522
699
|
if (!user) return;
|
|
@@ -935,6 +1112,119 @@ async function handleCsvImport(req, res, entity, thatcher, configEngineArg) {
|
|
|
935
1112
|
}
|
|
936
1113
|
}
|
|
937
1114
|
|
|
1115
|
+
const UPLOAD_ALLOWED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'application/pdf', 'text/plain', 'text/csv', 'application/json']);
|
|
1116
|
+
const UPLOAD_MAX_SIZE = 10 * 1024 * 1024;
|
|
1117
|
+
const UPLOAD_DIR = path.join(process.cwd(), 'uploads');
|
|
1118
|
+
|
|
1119
|
+
// Sanitize to a flat, extension-preserving, path-traversal-safe name: strip
|
|
1120
|
+
// any directory component, keep only [a-zA-Z0-9._-], cap length. The stored
|
|
1121
|
+
// filename is never the client-supplied one directly -- a random prefix
|
|
1122
|
+
// prevents overwrite/collision and the sanitization prevents "../../etc" or
|
|
1123
|
+
// null-byte tricks reaching fs.writeFile.
|
|
1124
|
+
function sanitizeUploadFilename(name) {
|
|
1125
|
+
const base = path.basename(String(name || 'file')).replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 128) || 'file';
|
|
1126
|
+
const prefix = crypto.randomBytes(8).toString('hex');
|
|
1127
|
+
return `${prefix}_${base}`;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
async function readMultipartFile(req) {
|
|
1131
|
+
const ct = req.headers['content-type'] || '';
|
|
1132
|
+
const boundaryMatch = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/i);
|
|
1133
|
+
if (!boundaryMatch) throw new Error('Multipart boundary not found');
|
|
1134
|
+
const boundary = '--' + (boundaryMatch[1] || boundaryMatch[2]).trim();
|
|
1135
|
+
|
|
1136
|
+
const chunks = [];
|
|
1137
|
+
let size = 0;
|
|
1138
|
+
await new Promise((resolve, reject) => {
|
|
1139
|
+
const timeout = setTimeout(() => { req.destroy(); reject(new Error('Request timeout')); }, 30000);
|
|
1140
|
+
req.on('data', chunk => {
|
|
1141
|
+
size += chunk.length;
|
|
1142
|
+
if (size > UPLOAD_MAX_SIZE) { clearTimeout(timeout); req.destroy(); reject(new Error('File too large')); return; }
|
|
1143
|
+
chunks.push(chunk);
|
|
1144
|
+
});
|
|
1145
|
+
req.on('end', () => { clearTimeout(timeout); resolve(); });
|
|
1146
|
+
req.on('error', (err) => { clearTimeout(timeout); reject(err); });
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
const buf = Buffer.concat(chunks);
|
|
1150
|
+
const boundaryBuf = Buffer.from(boundary);
|
|
1151
|
+
const parts = [];
|
|
1152
|
+
let start = buf.indexOf(boundaryBuf);
|
|
1153
|
+
while (start !== -1) {
|
|
1154
|
+
const next = buf.indexOf(boundaryBuf, start + boundaryBuf.length);
|
|
1155
|
+
if (next === -1) break;
|
|
1156
|
+
parts.push(buf.slice(start + boundaryBuf.length, next));
|
|
1157
|
+
start = next;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
for (const part of parts) {
|
|
1161
|
+
const headerEnd = part.indexOf('\r\n\r\n');
|
|
1162
|
+
if (headerEnd === -1) continue;
|
|
1163
|
+
const headerText = part.slice(0, headerEnd).toString('utf-8');
|
|
1164
|
+
if (!/name="file"/i.test(headerText)) continue;
|
|
1165
|
+
const filenameMatch = headerText.match(/filename="([^"]*)"/i);
|
|
1166
|
+
if (!filenameMatch || !filenameMatch[1]) continue;
|
|
1167
|
+
const typeMatch = headerText.match(/Content-Type:\s*([^\r\n]+)/i);
|
|
1168
|
+
const contentType = (typeMatch ? typeMatch[1] : 'application/octet-stream').trim();
|
|
1169
|
+
let body = part.slice(headerEnd + 4);
|
|
1170
|
+
if (body.slice(-2).toString() === '\r\n') body = body.slice(0, -2);
|
|
1171
|
+
return { filename: filenameMatch[1], contentType, buffer: body };
|
|
1172
|
+
}
|
|
1173
|
+
throw new Error('No file field found in upload');
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
async function handleFileUpload(req, res, thatcher, configEngineArg) {
|
|
1177
|
+
const user = await resolveRequestUser(req);
|
|
1178
|
+
if (!user) {
|
|
1179
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
1180
|
+
res.end(JSON.stringify({ error: 'Authentication required' }));
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
let file;
|
|
1185
|
+
try {
|
|
1186
|
+
file = await readMultipartFile(req);
|
|
1187
|
+
} catch (e) {
|
|
1188
|
+
res.writeHead(400);
|
|
1189
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
if (!UPLOAD_ALLOWED_TYPES.has(file.contentType)) {
|
|
1194
|
+
res.writeHead(400);
|
|
1195
|
+
res.end(JSON.stringify({ error: `Unsupported file type: ${file.contentType}` }));
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
if (file.buffer.length === 0) {
|
|
1199
|
+
res.writeHead(400);
|
|
1200
|
+
res.end(JSON.stringify({ error: 'Empty file' }));
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
try {
|
|
1205
|
+
await fs.promises.mkdir(UPLOAD_DIR, { recursive: true });
|
|
1206
|
+
const storedName = sanitizeUploadFilename(file.filename);
|
|
1207
|
+
const destPath = path.join(UPLOAD_DIR, storedName);
|
|
1208
|
+
// Belt-and-suspenders: confirm the resolved path is still inside UPLOAD_DIR
|
|
1209
|
+
// even though sanitizeUploadFilename already strips traversal sequences.
|
|
1210
|
+
if (path.dirname(destPath) !== UPLOAD_DIR) throw new Error('Invalid upload path');
|
|
1211
|
+
await fs.promises.writeFile(destPath, file.buffer);
|
|
1212
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
1213
|
+
res.end(JSON.stringify({
|
|
1214
|
+
filename: file.filename,
|
|
1215
|
+
stored_name: storedName,
|
|
1216
|
+
content_type: file.contentType,
|
|
1217
|
+
size: file.buffer.length,
|
|
1218
|
+
url: `/uploads/${storedName}`,
|
|
1219
|
+
uploaded_by: user.id,
|
|
1220
|
+
}));
|
|
1221
|
+
} catch (err) {
|
|
1222
|
+
apiLog.error(err.message);
|
|
1223
|
+
res.writeHead(500);
|
|
1224
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
938
1228
|
async function readBody(req) {
|
|
939
1229
|
return new Promise((resolve, reject) => {
|
|
940
1230
|
let data = '';
|
|
@@ -63,7 +63,7 @@ function roleLabel(r) {
|
|
|
63
63
|
return KNOWN_ROLE_LABELS[key] || (key.length > 8 ? 'Staff' : (key.charAt(0).toUpperCase() + key.slice(1)))
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
function formatFieldValue(k, v, entityName) {
|
|
66
|
+
function formatFieldValue(k, v, entityName, f) {
|
|
67
67
|
if (entityName === 'user' && k === 'role') return `<span class="pill pill-neutral">${roleLabel(v)}</span>`
|
|
68
68
|
if (entityName === 'user' && k === 'status') {
|
|
69
69
|
const cls = v === 'active' ? 'pill-success' : v === 'deleted' ? 'pill-danger' : 'pill-neutral'
|
|
@@ -71,6 +71,15 @@ function formatFieldValue(k, v, entityName) {
|
|
|
71
71
|
}
|
|
72
72
|
if (entityName === 'user' && k === 'email' && v) { const e = esc(v); return `<a href="mailto:${e}" class="text-primary hover:underline">${e}</a>` }
|
|
73
73
|
if (k === 'photo_url' && v && v.startsWith('http')) return `<img src="${esc(v)}" style="width:2.5rem;height:2.5rem;border-radius:50%;object-fit:cover" alt="avatar" onerror="this.style.display='none'"/>`
|
|
74
|
+
if (f?.type === 'currency' && typeof v === 'number') return esc((f.currency_symbol || '$') + (v / 100).toFixed(2))
|
|
75
|
+
if (f?.type === 'multiselect') {
|
|
76
|
+
const arr = Array.isArray(v) ? v : (typeof v === 'string' && v ? (() => { try { return JSON.parse(v) } catch { return [] } })() : [])
|
|
77
|
+
return arr.length ? arr.map(x => `<span class="pill pill-neutral" style="margin-right:4px">${esc(x)}</span>`).join('') : '-'
|
|
78
|
+
}
|
|
79
|
+
if (f?.type === 'file' || f?.type === 'attachment') {
|
|
80
|
+
const meta = typeof v === 'object' && v ? v : (typeof v === 'string' && v ? (() => { try { return JSON.parse(v) } catch { return null } })() : null)
|
|
81
|
+
return meta?.url ? `<a href="${esc(meta.url)}" class="text-primary hover:underline" target="_blank" rel="noopener">${esc(meta.filename || 'Download')}</a>` : '-'
|
|
82
|
+
}
|
|
74
83
|
return fmtVal(v, k)
|
|
75
84
|
}
|
|
76
85
|
|
|
@@ -85,7 +94,7 @@ export function renderEntityDetail(entityName, item, spec, user) {
|
|
|
85
94
|
const fieldRows = visibleFields.map(([k, f]) =>
|
|
86
95
|
`<div class="detail-row">
|
|
87
96
|
<span class="detail-row-label">${esc(f.label || k)}</span>
|
|
88
|
-
<span class="detail-row-value">${formatFieldValue(k, item[k], entityName)}</span>
|
|
97
|
+
<span class="detail-row-value">${formatFieldValue(k, item[k], entityName, f)}</span>
|
|
89
98
|
</div>`
|
|
90
99
|
).join('')
|
|
91
100
|
|
|
@@ -159,6 +168,21 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
|
|
|
159
168
|
const opts = (Array.isArray(f.options) ? f.options : []).map(o => { const ov = typeof o === 'string' ? o : o.value; const ol = typeof o === 'string' ? o : o.label; return `<option value="${esc(ov)}" ${val===ov?'selected':''}>${esc(ol)}</option>` }).join('')
|
|
160
169
|
return `<div class="form-field">${lbl(k,f,f.required)}<select id="field-${k}" name="${k}" class="form-input" ${req}><option value="">Select ${esc(f.label||k)}...</option>${opts}</select></div>`
|
|
161
170
|
}
|
|
171
|
+
if (f.type === 'multiselect' && f.options) {
|
|
172
|
+
const selected = new Set(Array.isArray(val) ? val : (typeof val === 'string' && val ? (() => { try { return JSON.parse(val) } catch { return [] } })() : []))
|
|
173
|
+
const opts = (Array.isArray(f.options) ? f.options : []).map(o => { const ov = typeof o === 'string' ? o : o.value; const ol = typeof o === 'string' ? o : o.label; return `<label style="display:flex;align-items:center;gap:6px;margin:2px 0"><input type="checkbox" name="${k}[]" value="${esc(ov)}" class="checkbox checkbox-primary" ${selected.has(ov)?'checked':''}/><span>${esc(ol)}</span></label>` }).join('')
|
|
174
|
+
return `<div class="form-field full">${lbl(k,f,f.required)}<div id="field-${k}" data-multiselect="${k}">${opts}</div></div>`
|
|
175
|
+
}
|
|
176
|
+
if (f.type === 'currency') {
|
|
177
|
+
const symbol = esc(f.currency_symbol || '$')
|
|
178
|
+
const decimalVal = typeof val === 'number' ? (val / 100).toFixed(2) : val
|
|
179
|
+
return `<div class="form-field">${lbl(k,f,f.required)}<div style="display:flex;align-items:center;gap:6px"><span>${symbol}</span><input type="number" step="0.01" id="field-${k}" name="${k}" value="${esc(decimalVal)}" class="form-input" data-currency="${k}" ${req} placeholder="0.00"/></div></div>`
|
|
180
|
+
}
|
|
181
|
+
if (f.type === 'file' || f.type === 'attachment') {
|
|
182
|
+
const existing = val && typeof val === 'object' ? val : (typeof val === 'string' && val ? (() => { try { return JSON.parse(val) } catch { return null } })() : null)
|
|
183
|
+
const existingNote = existing?.filename ? `<div style="font-size:0.8rem;color:var(--color-text-muted)" data-existing-file="${k}">Current: ${esc(existing.filename)}</div>` : ''
|
|
184
|
+
return `<div class="form-field">${lbl(k,f,f.required)}<input type="file" id="field-${k}" data-attachment="${k}" class="form-input" ${existing ? '' : req}/>${existingNote}<input type="hidden" id="field-${k}-value" name="${k}" value="${existing ? esc(JSON.stringify(existing)) : ''}"/></div>`
|
|
185
|
+
}
|
|
162
186
|
return `<div class="form-field">${lbl(k,f,f.required)}<input type="${type}" id="field-${k}" name="${k}" value="${esc(val)}" class="form-input" ${req} ${placeholder}/></div>`
|
|
163
187
|
}).join('\n')
|
|
164
188
|
|
|
@@ -176,7 +200,7 @@ export function renderEntityForm(entityName, item, spec, user, isNew = false, re
|
|
|
176
200
|
<div class="form-section"><form id="entity-form" class="form-grid" aria-label="${isNew ? 'Create' : 'Edit'} ${esc(label)}">${templatePicker}${formFields}${pwField}
|
|
177
201
|
<div class="form-actions" style="grid-column:1/-1"><button type="submit" id="submit-btn" class="btn-primary-clean"><span class="btn-text">Save</span><span class="btn-loading-text" style="display:none">Saving...</span></button>
|
|
178
202
|
<a href="/${entityName}${isNew ? '' : '/' + item?.id}" class="btn-ghost-clean">Cancel</a></div></form></div></div>`
|
|
179
|
-
const script = `${TOAST_SCRIPT}const form=document.getElementById('entity-form');const sb=document.getElementById('submit-btn');form.addEventListener('submit',async(e)=>{e.preventDefault();sb.classList.add('btn-loading');sb.querySelector('.btn-text').style.display='none';sb.querySelector('.btn-loading-text').style.display='inline';sb.disabled=true;const fd=new FormData(form);const data={};for(const[k,v]of fd.entries())data[k]=v
|
|
203
|
+
const script = `${TOAST_SCRIPT}const form=document.getElementById('entity-form');const sb=document.getElementById('submit-btn');form.addEventListener('submit',async(e)=>{e.preventDefault();sb.classList.add('btn-loading');sb.querySelector('.btn-text').style.display='none';sb.querySelector('.btn-loading-text').style.display='inline';sb.disabled=true;try{const fileInputs=[...form.querySelectorAll('input[type=file][data-attachment]')];for(const fi of fileInputs){const f=fi.files&&fi.files[0];if(!f)continue;const uf=new FormData();uf.append('file',f);const ures=await fetch('/api/upload',{method:'POST',body:uf});const ud=await ures.json();if(!ures.ok)throw new Error(ud.error||'File upload failed');const hidden=document.getElementById('field-'+fi.dataset.attachment+'-value');hidden.value=JSON.stringify(ud)}const fd=new FormData(form);const data={};for(const[k,v]of fd.entries()){if(k.endsWith('[]'))continue;data[k]=v}form.querySelectorAll('input[type=checkbox]:not([name$="[]"])').forEach(cb=>{data[cb.name]=cb.checked});form.querySelectorAll('[data-multiselect]').forEach(ms=>{const name=ms.dataset.multiselect;data[name]=[...ms.querySelectorAll('input[type=checkbox]:checked')].map(cb=>cb.value)});form.querySelectorAll('input[type=number]:not([data-currency])').forEach(inp=>{if(inp.name&&data[inp.name]!==undefined&&data[inp.name]!=='')data[inp.name]=Number(data[inp.name])});form.querySelectorAll('input[data-currency]').forEach(inp=>{const name=inp.dataset.currency;if(inp.value!=='')data[name]=Math.round(Number(inp.value)*100)});const url=${isNew}?'/api/${entityName}':'/api/${entityName}/${item?.id}';const method=${isNew}?'POST':'PUT';const res=await fetch(url,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const result=await res.json();if(res.ok){showToast('${isNew?'Created':'Updated'} successfully!','success');const ed=result.data||result;setTimeout(()=>{window.location='/${entityName}/'+(ed.id||'${item?.id}')},500)}else{showToast(result.message||result.error||'Save failed','error');sb.classList.remove('btn-loading');sb.querySelector('.btn-text').style.display='inline';sb.querySelector('.btn-loading-text').style.display='none';sb.disabled=false}}catch(err){showToast('Error: '+err.message,'error');sb.classList.remove('btn-loading');sb.querySelector('.btn-text').style.display='inline';sb.querySelector('.btn-loading-text').style.display='none';sb.disabled=false}})`
|
|
180
204
|
return page(user, `${isNew ? 'Create' : 'Edit'} ${label}`, bc, content, [script])
|
|
181
205
|
}
|
|
182
206
|
|
|
@@ -10,6 +10,7 @@ import { renderWorkflowList, renderWorkflowEditor } from '@/ui/workflow-builder-
|
|
|
10
10
|
import { renderRolesList, renderTemplateList, renderPermissionMatrix } from '@/ui/rbac-renderer.js';
|
|
11
11
|
import { renderWebhookList, renderWebhookDetail } from '@/ui/webhook-renderer.js';
|
|
12
12
|
import { renderTemplateList as renderEntityTemplateList } from '@/ui/template-renderer.js';
|
|
13
|
+
import { renderScheduledJobList } from '@/ui/scheduled-job-renderer.js';
|
|
13
14
|
import { isPartner, isManager } from '@/ui/permissions-ui.js';
|
|
14
15
|
import { getSystemConfig, getSettingsCounts, getAuditData, getSystemHealth, renderBuildLogsContent } from '@/ui/page-handler-helpers.js';
|
|
15
16
|
import { fileURLToPath } from 'url';
|
|
@@ -191,5 +192,13 @@ export async function handleAdminPage(normalized, segments, user) {
|
|
|
191
192
|
}
|
|
192
193
|
return renderEntityTemplateList(user, entityNames, templatesByEntity);
|
|
193
194
|
}
|
|
195
|
+
if (normalized === '/admin/scheduled-jobs') {
|
|
196
|
+
if (!isPartner(user)) return renderAccessDenied(user, 'admin', 'view');
|
|
197
|
+
const { getConfigEngineSync } = await import('@/lib/config-generator-engine.js');
|
|
198
|
+
const engine = getConfigEngineSync();
|
|
199
|
+
const entityNames = engine.getAllEntities().filter(e => e !== 'scheduled_job' && e !== 'entity_template' && e !== 'webhook' && e !== 'webhook_delivery' && e !== 'user_organization');
|
|
200
|
+
let jobs = []; try { jobs = await list('scheduled_job', {}); } catch {}
|
|
201
|
+
return renderScheduledJobList(user, jobs, entityNames);
|
|
202
|
+
}
|
|
194
203
|
return null;
|
|
195
204
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { page } from '@/ui/layout.js';
|
|
2
|
+
import { esc } from '@/ui/render-helpers.js';
|
|
3
|
+
|
|
4
|
+
function fmtTs(ts) {
|
|
5
|
+
if (!ts) return '-';
|
|
6
|
+
return new Date(ts * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function renderScheduledJobList(user, jobs, entityNames) {
|
|
10
|
+
const rows = jobs.map(j => {
|
|
11
|
+
const statusPill = j.enabled ? `<span class="pill pill-success">Enabled</span>` : `<span class="pill pill-neutral">Disabled</span>`;
|
|
12
|
+
const toggleLabel = j.enabled ? 'Disable' : 'Enable';
|
|
13
|
+
return `<tr>
|
|
14
|
+
<td>${esc(j.name)}</td>
|
|
15
|
+
<td>${esc(j.entity)}</td>
|
|
16
|
+
<td>${esc(String(j.interval_minutes))} min</td>
|
|
17
|
+
<td>${statusPill}</td>
|
|
18
|
+
<td>${esc(fmtTs(j.last_run_at))}</td>
|
|
19
|
+
<td>${esc(fmtTs(j.next_run_at))}</td>
|
|
20
|
+
<td style="display:flex;gap:8px">
|
|
21
|
+
<button type="button" class="btn-ghost-clean" data-action="toggleJob" data-args='["${esc(j.id)}", ${j.enabled ? 'false' : 'true'}]'>${toggleLabel}</button>
|
|
22
|
+
<button type="button" class="btn-ghost-clean" data-action="deleteJob" data-args='["${esc(j.id)}"]'>Delete</button>
|
|
23
|
+
</td>
|
|
24
|
+
</tr>`;
|
|
25
|
+
}).join('') || '<tr><td colspan="7">No scheduled jobs</td></tr>';
|
|
26
|
+
|
|
27
|
+
const entityOpts = entityNames.map(e => `<option value="${esc(e)}">${esc(e)}</option>`).join('');
|
|
28
|
+
|
|
29
|
+
const content = `<div class="page-header"><h1 class="page-title">Scheduled Jobs</h1></div>
|
|
30
|
+
<div class="card-clean" style="margin-bottom:16px"><div class="card-clean-body">
|
|
31
|
+
<table class="data-table"><thead><tr><th>Name</th><th>Entity</th><th>Interval</th><th>Status</th><th>Last Run</th><th>Next Run</th><th></th></tr></thead><tbody>${rows}</tbody></table>
|
|
32
|
+
</div></div>
|
|
33
|
+
<div class="card-clean"><div class="card-clean-body">
|
|
34
|
+
<h3 style="margin-bottom:8px">New Scheduled Job</h3>
|
|
35
|
+
<div style="display:flex;flex-direction:column;gap:8px;max-width:480px">
|
|
36
|
+
<input type="text" id="new-job-name" placeholder="Job name" class="form-input">
|
|
37
|
+
<select id="new-job-entity" class="form-input"><option value="">Select entity...</option>${entityOpts}</select>
|
|
38
|
+
<select id="new-job-action-type" class="form-input">
|
|
39
|
+
<option value="delete">Delete matching records</option>
|
|
40
|
+
<option value="set_field">Set field on matching records</option>
|
|
41
|
+
</select>
|
|
42
|
+
<input type="text" id="new-job-field" placeholder="Field name (for set_field)" class="form-input">
|
|
43
|
+
<input type="text" id="new-job-value" placeholder="Value (for set_field)" class="form-input">
|
|
44
|
+
<input type="text" id="new-job-filter" placeholder='Filter JSON, e.g. {"status":"pending"}' class="form-input">
|
|
45
|
+
<input type="number" id="new-job-interval" placeholder="Interval (minutes)" class="form-input" min="1">
|
|
46
|
+
<button type="button" class="btn-primary-clean" data-action="createJob">Create Job</button>
|
|
47
|
+
</div>
|
|
48
|
+
</div></div>
|
|
49
|
+
<span id="job-status" style="font-size:13px"></span>`;
|
|
50
|
+
|
|
51
|
+
const script = `(function(){
|
|
52
|
+
window.createJob=function(){
|
|
53
|
+
var status=document.getElementById('job-status');
|
|
54
|
+
var name=(document.getElementById('new-job-name').value||'').trim();
|
|
55
|
+
var entity=document.getElementById('new-job-entity').value;
|
|
56
|
+
var actionType=document.getElementById('new-job-action-type').value;
|
|
57
|
+
var field=(document.getElementById('new-job-field').value||'').trim();
|
|
58
|
+
var value=document.getElementById('new-job-value').value;
|
|
59
|
+
var filterRaw=(document.getElementById('new-job-filter').value||'').trim();
|
|
60
|
+
var interval=Number(document.getElementById('new-job-interval').value);
|
|
61
|
+
if(!name){status.textContent='Name required';return}
|
|
62
|
+
if(!entity){status.textContent='Entity required';return}
|
|
63
|
+
if(!interval||interval<=0){status.textContent='Interval must be a positive number';return}
|
|
64
|
+
var filter={};
|
|
65
|
+
if(filterRaw){try{filter=JSON.parse(filterRaw)}catch(e){status.textContent='Filter must be valid JSON';return}}
|
|
66
|
+
var action=actionType==='set_field'?{type:'set_field',field:field,value:value}:{type:'delete'};
|
|
67
|
+
status.textContent='Creating...';
|
|
68
|
+
fetch('/api/scheduled_job/create',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:name,entity:entity,action:action,filter:filter,interval_minutes:interval})})
|
|
69
|
+
.then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
|
|
70
|
+
.then(function(res){if(res.ok){location.reload()}else{status.textContent='Error: '+(res.d.error||'create failed')}})
|
|
71
|
+
.catch(function(err){status.textContent='Error: '+err.message});
|
|
72
|
+
};
|
|
73
|
+
window.toggleJob=function(id,enabled){
|
|
74
|
+
var status=document.getElementById('job-status');
|
|
75
|
+
status.textContent='Updating...';
|
|
76
|
+
fetch('/api/scheduled_job/'+id+'/update',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:enabled})})
|
|
77
|
+
.then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
|
|
78
|
+
.then(function(res){if(res.ok){location.reload()}else{status.textContent='Error: '+(res.d.error||'update failed')}})
|
|
79
|
+
.catch(function(err){status.textContent='Error: '+err.message});
|
|
80
|
+
};
|
|
81
|
+
window.deleteJob=function(id){
|
|
82
|
+
var status=document.getElementById('job-status');
|
|
83
|
+
status.textContent='Deleting...';
|
|
84
|
+
fetch('/api/scheduled_job/'+id+'/delete',{method:'POST'})
|
|
85
|
+
.then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
|
|
86
|
+
.then(function(res){if(res.ok){location.reload()}else{status.textContent='Error: '+(res.d.error||'delete failed')}})
|
|
87
|
+
.catch(function(err){status.textContent='Error: '+err.message});
|
|
88
|
+
};
|
|
89
|
+
})();`;
|
|
90
|
+
|
|
91
|
+
return page(user, 'Scheduled Jobs | Thatcher', [{ href: '/admin/settings', label: 'Settings' }, { label: 'Scheduled Jobs' }], content, [script]);
|
|
92
|
+
}
|