@objectstack/rest 4.0.5 → 4.1.1
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/dist/index.cjs +1997 -79
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +153 -1
- package/dist/index.d.ts +153 -1
- package/dist/index.js +1997 -79
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -211,6 +211,17 @@ var RouteGroupBuilder = class {
|
|
|
211
211
|
// src/rest-server.ts
|
|
212
212
|
var logError = (...args) => globalThis.console?.error(...args);
|
|
213
213
|
function mapDataError(error, object) {
|
|
214
|
+
if (error?.code === "VALIDATION_FAILED" || error?.name === "ValidationError") {
|
|
215
|
+
return {
|
|
216
|
+
status: 400,
|
|
217
|
+
body: {
|
|
218
|
+
error: error?.message ?? "Validation failed",
|
|
219
|
+
code: "VALIDATION_FAILED",
|
|
220
|
+
fields: Array.isArray(error?.fields) ? error.fields : [],
|
|
221
|
+
...object ? { object } : {}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
}
|
|
214
225
|
if (error?.code === "PERMISSION_DENIED" || error?.name === "PermissionDeniedError" || typeof error?.message === "string" && error.message.startsWith("[Security] Access denied")) {
|
|
215
226
|
return {
|
|
216
227
|
status: 403,
|
|
@@ -234,6 +245,16 @@ function mapDataError(error, object) {
|
|
|
234
245
|
}
|
|
235
246
|
};
|
|
236
247
|
}
|
|
248
|
+
if (error?.code === "RECORD_NOT_FOUND" || /^Record\s+\S+\s+not found in\s+\S+/i.test(raw)) {
|
|
249
|
+
return {
|
|
250
|
+
status: 404,
|
|
251
|
+
body: {
|
|
252
|
+
error: raw,
|
|
253
|
+
code: "RECORD_NOT_FOUND",
|
|
254
|
+
...object ? { object } : {}
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
}
|
|
237
258
|
const looksLikeUnknownObject = lower.includes("no such table") || lower.includes("relation") && lower.includes("does not exist") || lower.includes("table not found") || lower.includes("unknown object") || lower.includes("object not found") || lower.includes("no driver available") || object !== void 0 && lower.includes(`'${object.toLowerCase()}'`) && lower.includes("not");
|
|
238
259
|
if (looksLikeUnknownObject) {
|
|
239
260
|
return {
|
|
@@ -245,13 +266,132 @@ function mapDataError(error, object) {
|
|
|
245
266
|
}
|
|
246
267
|
};
|
|
247
268
|
}
|
|
269
|
+
const looksLikeSqlLeak = lower.includes("sqlite_") || lower.includes("sqlstate") || lower.startsWith("insert into ") || lower.startsWith("update ") || lower.startsWith("select ") || lower.startsWith("delete from ") || lower.includes("constraint failed") || lower.includes("unique constraint") || lower.includes("foreign key");
|
|
270
|
+
if (looksLikeSqlLeak) {
|
|
271
|
+
if (lower.includes("unique constraint") || lower.includes("unique violation")) {
|
|
272
|
+
return {
|
|
273
|
+
status: 409,
|
|
274
|
+
body: {
|
|
275
|
+
error: "A record with this value already exists",
|
|
276
|
+
code: "UNIQUE_VIOLATION",
|
|
277
|
+
...object ? { object } : {}
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
status: 500,
|
|
283
|
+
body: { error: "Internal data error", code: "DATABASE_ERROR" }
|
|
284
|
+
};
|
|
285
|
+
}
|
|
248
286
|
return { status: 400, body: { error: raw || "Bad request" } };
|
|
249
287
|
}
|
|
288
|
+
function sendError(res, error, object) {
|
|
289
|
+
if (typeof error?.status === "number" && error.status >= 400 && error.status < 600) {
|
|
290
|
+
const safeMsg = typeof error.message === "string" && error.message.length < 500 ? error.message : "Request failed";
|
|
291
|
+
res.status(error.status).json({
|
|
292
|
+
error: safeMsg,
|
|
293
|
+
...error.code ? { code: error.code } : {}
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const mapped = mapDataError(error, object);
|
|
298
|
+
res.status(mapped.status).json(mapped.body);
|
|
299
|
+
}
|
|
250
300
|
function isExpectedDataStatus(status) {
|
|
251
|
-
return status === 403 || status === 404 || status === 502 || status === 503;
|
|
301
|
+
return status === 403 || status === 404 || status === 409 || status === 502 || status === 503;
|
|
302
|
+
}
|
|
303
|
+
function parseCsvToRows(csv, mapping = {}) {
|
|
304
|
+
const text = csv.replace(/^\uFEFF/, "");
|
|
305
|
+
const cells = [];
|
|
306
|
+
let cur = "";
|
|
307
|
+
let row = [];
|
|
308
|
+
let inQuotes = false;
|
|
309
|
+
for (let i = 0; i < text.length; i++) {
|
|
310
|
+
const ch = text[i];
|
|
311
|
+
if (inQuotes) {
|
|
312
|
+
if (ch === '"') {
|
|
313
|
+
if (text[i + 1] === '"') {
|
|
314
|
+
cur += '"';
|
|
315
|
+
i++;
|
|
316
|
+
} else {
|
|
317
|
+
inQuotes = false;
|
|
318
|
+
}
|
|
319
|
+
} else {
|
|
320
|
+
cur += ch;
|
|
321
|
+
}
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (ch === '"') {
|
|
325
|
+
inQuotes = true;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (ch === ",") {
|
|
329
|
+
row.push(cur);
|
|
330
|
+
cur = "";
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (ch === "\r") {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (ch === "\n") {
|
|
337
|
+
row.push(cur);
|
|
338
|
+
cur = "";
|
|
339
|
+
cells.push(row);
|
|
340
|
+
row = [];
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
cur += ch;
|
|
344
|
+
}
|
|
345
|
+
if (cur.length > 0 || row.length > 0) {
|
|
346
|
+
row.push(cur);
|
|
347
|
+
cells.push(row);
|
|
348
|
+
}
|
|
349
|
+
while (cells.length > 0 && cells[cells.length - 1].every((c) => c === "")) cells.pop();
|
|
350
|
+
if (cells.length < 2) return [];
|
|
351
|
+
const header = cells[0].map((h) => h.trim());
|
|
352
|
+
const fields = header.map((h) => mapping[h] ?? h);
|
|
353
|
+
const out = [];
|
|
354
|
+
for (let r = 1; r < cells.length; r++) {
|
|
355
|
+
const row2 = cells[r];
|
|
356
|
+
const obj = {};
|
|
357
|
+
for (let c = 0; c < fields.length; c++) {
|
|
358
|
+
const key = fields[c];
|
|
359
|
+
if (!key) continue;
|
|
360
|
+
const raw = row2[c] ?? "";
|
|
361
|
+
obj[key] = raw;
|
|
362
|
+
}
|
|
363
|
+
out.push(obj);
|
|
364
|
+
}
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
function formatCsvCell(value) {
|
|
368
|
+
if (value === null || value === void 0) return "";
|
|
369
|
+
let s;
|
|
370
|
+
if (typeof value === "string") s = value;
|
|
371
|
+
else if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") s = String(value);
|
|
372
|
+
else if (value instanceof Date) s = value.toISOString();
|
|
373
|
+
else {
|
|
374
|
+
try {
|
|
375
|
+
s = JSON.stringify(value);
|
|
376
|
+
} catch {
|
|
377
|
+
s = String(value);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (/[",\r\n]/.test(s)) {
|
|
381
|
+
return `"${s.replace(/"/g, '""')}"`;
|
|
382
|
+
}
|
|
383
|
+
return s;
|
|
384
|
+
}
|
|
385
|
+
function rowsToCsv(fields, rows, includeHeader) {
|
|
386
|
+
const lines = [];
|
|
387
|
+
if (includeHeader) lines.push(fields.map(formatCsvCell).join(","));
|
|
388
|
+
for (const row of rows) {
|
|
389
|
+
lines.push(fields.map((f) => formatCsvCell(row?.[f])).join(","));
|
|
390
|
+
}
|
|
391
|
+
return lines.join("\r\n") + (lines.length > 0 ? "\r\n" : "");
|
|
252
392
|
}
|
|
253
393
|
var RestServer = class {
|
|
254
|
-
constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider) {
|
|
394
|
+
constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider) {
|
|
255
395
|
this.protocol = protocol;
|
|
256
396
|
this.config = this.normalizeConfig(config);
|
|
257
397
|
this.routeManager = new RouteManager(server);
|
|
@@ -260,6 +400,12 @@ var RestServer = class {
|
|
|
260
400
|
this.defaultProjectIdProvider = defaultProjectIdProvider;
|
|
261
401
|
this.authServiceProvider = authServiceProvider;
|
|
262
402
|
this.objectQLProvider = objectQLProvider;
|
|
403
|
+
this.emailServiceProvider = emailServiceProvider;
|
|
404
|
+
this.sharingServiceProvider = sharingServiceProvider;
|
|
405
|
+
this.reportsServiceProvider = reportsServiceProvider;
|
|
406
|
+
this.approvalsServiceProvider = approvalsServiceProvider;
|
|
407
|
+
this.sharingRulesServiceProvider = sharingRulesServiceProvider;
|
|
408
|
+
this.i18nServiceProvider = i18nServiceProvider;
|
|
263
409
|
}
|
|
264
410
|
/**
|
|
265
411
|
* Resolve the protocol for a given request. When `projectId` is present
|
|
@@ -327,14 +473,70 @@ var RestServer = class {
|
|
|
327
473
|
* requests intentionally return `undefined` because the platform kernel
|
|
328
474
|
* does not own per-app translation bundles.
|
|
329
475
|
*/
|
|
330
|
-
async resolveI18nService(projectId) {
|
|
331
|
-
if (
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
476
|
+
async resolveI18nService(projectId, req) {
|
|
477
|
+
if (projectId === "platform") return void 0;
|
|
478
|
+
if (!projectId && req && this.envRegistry && this.kernelManager) {
|
|
479
|
+
const host = this.extractHostname(req);
|
|
480
|
+
if (host) {
|
|
481
|
+
try {
|
|
482
|
+
const result = await this.envRegistry.resolveByHostname(host);
|
|
483
|
+
if (result?.projectId) projectId = result.projectId;
|
|
484
|
+
} catch {
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (!projectId && typeof this.envRegistry.resolveById === "function") {
|
|
488
|
+
const headerVal = this.extractProjectIdHeader(req);
|
|
489
|
+
if (headerVal) {
|
|
490
|
+
try {
|
|
491
|
+
const driver = await this.envRegistry.resolveById(headerVal);
|
|
492
|
+
if (driver) projectId = headerVal;
|
|
493
|
+
} catch {
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (!projectId && this.defaultProjectIdProvider) {
|
|
499
|
+
try {
|
|
500
|
+
const def = this.defaultProjectIdProvider();
|
|
501
|
+
if (def) projectId = def;
|
|
502
|
+
} catch {
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (projectId && this.kernelManager) {
|
|
506
|
+
try {
|
|
507
|
+
const kernel = await this.kernelManager.getOrCreate(projectId);
|
|
508
|
+
const svc = await kernel.getServiceAsync("i18n");
|
|
509
|
+
if (svc) return svc;
|
|
510
|
+
} catch {
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (this.i18nServiceProvider) {
|
|
514
|
+
try {
|
|
515
|
+
return await this.i18nServiceProvider(projectId);
|
|
516
|
+
} catch {
|
|
517
|
+
return void 0;
|
|
518
|
+
}
|
|
337
519
|
}
|
|
520
|
+
return void 0;
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Reject anonymous requests with HTTP 401 when `api.requireAuth` is set.
|
|
524
|
+
* Returns `true` if the response was sent and the caller should stop
|
|
525
|
+
* processing. Returns `false` to continue.
|
|
526
|
+
*
|
|
527
|
+
* The check is intentionally narrow: only `context?.userId` counts as
|
|
528
|
+
* "authenticated". `isSystem` flags are never set on inbound HTTP
|
|
529
|
+
* requests (they're internal-only), so they cannot bypass this gate.
|
|
530
|
+
*/
|
|
531
|
+
enforceAuth(req, res, context) {
|
|
532
|
+
if (!this.config.api.requireAuth) return false;
|
|
533
|
+
if (context?.userId) return false;
|
|
534
|
+
if (req?.method === "OPTIONS") return false;
|
|
535
|
+
res.status(401).json({
|
|
536
|
+
error: "unauthenticated",
|
|
537
|
+
message: "Authentication is required to access this endpoint."
|
|
538
|
+
});
|
|
539
|
+
return true;
|
|
338
540
|
}
|
|
339
541
|
/**
|
|
340
542
|
* Resolve the request's execution context (RBAC/RLS/FLS) by looking up
|
|
@@ -344,6 +546,26 @@ var RestServer = class {
|
|
|
344
546
|
*/
|
|
345
547
|
async resolveExecCtx(projectId, req) {
|
|
346
548
|
try {
|
|
549
|
+
if (!projectId && req && this.envRegistry && this.kernelManager) {
|
|
550
|
+
const host = this.extractHostname(req);
|
|
551
|
+
if (host) {
|
|
552
|
+
try {
|
|
553
|
+
const result = await this.envRegistry.resolveByHostname(host);
|
|
554
|
+
if (result?.projectId) projectId = result.projectId;
|
|
555
|
+
} catch {
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (!projectId && typeof this.envRegistry.resolveById === "function") {
|
|
559
|
+
const headerVal = this.extractProjectIdHeader(req);
|
|
560
|
+
if (headerVal) {
|
|
561
|
+
try {
|
|
562
|
+
const driver = await this.envRegistry.resolveById(headerVal);
|
|
563
|
+
if (driver) projectId = headerVal;
|
|
564
|
+
} catch {
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
347
569
|
let authService;
|
|
348
570
|
let kernel;
|
|
349
571
|
if (projectId && projectId !== "platform" && this.kernelManager) {
|
|
@@ -497,8 +719,8 @@ var RestServer = class {
|
|
|
497
719
|
*/
|
|
498
720
|
async translateMetaItem(req, type, projectId, item) {
|
|
499
721
|
if (!item || typeof item !== "object") return item;
|
|
500
|
-
if (type !== "view" && type !== "action") return item;
|
|
501
|
-
const i18n = await this.resolveI18nService(projectId);
|
|
722
|
+
if (type !== "view" && type !== "action" && type !== "object") return item;
|
|
723
|
+
const i18n = await this.resolveI18nService(projectId, req);
|
|
502
724
|
const bundle = this.buildTranslationBundle(i18n);
|
|
503
725
|
if (!bundle) return item;
|
|
504
726
|
const locale = this.extractLocale(req, i18n);
|
|
@@ -511,8 +733,8 @@ var RestServer = class {
|
|
|
511
733
|
*/
|
|
512
734
|
async translateMetaItems(req, type, projectId, items) {
|
|
513
735
|
if (!Array.isArray(items)) return items;
|
|
514
|
-
if (type !== "view" && type !== "action") return items;
|
|
515
|
-
const i18n = await this.resolveI18nService(projectId);
|
|
736
|
+
if (type !== "view" && type !== "action" && type !== "object") return items;
|
|
737
|
+
const i18n = await this.resolveI18nService(projectId, req);
|
|
516
738
|
const bundle = this.buildTranslationBundle(i18n);
|
|
517
739
|
if (!bundle) return items;
|
|
518
740
|
const locale = this.extractLocale(req, i18n);
|
|
@@ -583,8 +805,10 @@ var RestServer = class {
|
|
|
583
805
|
enableUi: api.enableUi ?? true,
|
|
584
806
|
enableBatch: api.enableBatch ?? true,
|
|
585
807
|
enableDiscovery: api.enableDiscovery ?? true,
|
|
808
|
+
enableSearch: api.enableSearch ?? true,
|
|
586
809
|
enableProjectScoping: api.enableProjectScoping ?? false,
|
|
587
810
|
projectResolution: api.projectResolution ?? "auto",
|
|
811
|
+
requireAuth: api.requireAuth ?? false,
|
|
588
812
|
documentation: api.documentation,
|
|
589
813
|
responseFormat: api.responseFormat
|
|
590
814
|
},
|
|
@@ -666,9 +890,19 @@ var RestServer = class {
|
|
|
666
890
|
if (this.config.api.enableUi) {
|
|
667
891
|
this.registerUiEndpoints(bp);
|
|
668
892
|
}
|
|
893
|
+
if (this.config.api.enableSearch ?? true) {
|
|
894
|
+
this.registerSearchEndpoints(bp);
|
|
895
|
+
}
|
|
896
|
+
this.registerEmailEndpoints(bp);
|
|
897
|
+
this.registerFormEndpoints(bp);
|
|
898
|
+
this.registerSharingEndpoints(bp);
|
|
899
|
+
this.registerSharingRuleEndpoints(bp);
|
|
900
|
+
this.registerReportsEndpoints(bp);
|
|
901
|
+
this.registerApprovalsEndpoints(bp);
|
|
669
902
|
if (this.config.api.enableCrud) {
|
|
670
903
|
this.registerCrudEndpoints(bp);
|
|
671
904
|
}
|
|
905
|
+
this.registerDataActionEndpoints(bp);
|
|
672
906
|
if (this.config.api.enableBatch) {
|
|
673
907
|
this.registerBatchEndpoints(bp);
|
|
674
908
|
}
|
|
@@ -719,7 +953,7 @@ var RestServer = class {
|
|
|
719
953
|
res.json(discovery);
|
|
720
954
|
} catch (error) {
|
|
721
955
|
logError("[REST] Unhandled error:", error);
|
|
722
|
-
res
|
|
956
|
+
sendError(res, error);
|
|
723
957
|
}
|
|
724
958
|
};
|
|
725
959
|
this.routeManager.register({
|
|
@@ -760,7 +994,7 @@ var RestServer = class {
|
|
|
760
994
|
res.json(types);
|
|
761
995
|
} catch (error) {
|
|
762
996
|
logError("[REST] Unhandled error:", error);
|
|
763
|
-
res
|
|
997
|
+
sendError(res, error);
|
|
764
998
|
}
|
|
765
999
|
},
|
|
766
1000
|
metadata: {
|
|
@@ -788,7 +1022,7 @@ var RestServer = class {
|
|
|
788
1022
|
res.json(translated);
|
|
789
1023
|
} catch (error) {
|
|
790
1024
|
logError("[REST] Unhandled error:", error);
|
|
791
|
-
res
|
|
1025
|
+
sendError(res, error);
|
|
792
1026
|
}
|
|
793
1027
|
},
|
|
794
1028
|
metadata: {
|
|
@@ -846,7 +1080,7 @@ var RestServer = class {
|
|
|
846
1080
|
}
|
|
847
1081
|
} catch (error) {
|
|
848
1082
|
logError("[REST] Unhandled error:", error);
|
|
849
|
-
res
|
|
1083
|
+
sendError(res, error);
|
|
850
1084
|
}
|
|
851
1085
|
},
|
|
852
1086
|
metadata: {
|
|
@@ -866,16 +1100,18 @@ var RestServer = class {
|
|
|
866
1100
|
res.status(501).json({ error: "Save operation not supported by protocol implementation" });
|
|
867
1101
|
return;
|
|
868
1102
|
}
|
|
1103
|
+
const body = req.body ?? {};
|
|
1104
|
+
const item = body && typeof body === "object" && "metadata" in body ? body.metadata : body && typeof body === "object" && "item" in body ? body.item : body;
|
|
869
1105
|
const result = await p.saveMetaItem({
|
|
870
1106
|
type: req.params.type,
|
|
871
1107
|
name: req.params.name,
|
|
872
|
-
item
|
|
1108
|
+
item,
|
|
873
1109
|
...projectId ? { projectId } : {}
|
|
874
1110
|
});
|
|
875
1111
|
res.json(result);
|
|
876
1112
|
} catch (error) {
|
|
877
1113
|
logError("[REST] Unhandled error:", error);
|
|
878
|
-
res
|
|
1114
|
+
sendError(res, error);
|
|
879
1115
|
}
|
|
880
1116
|
},
|
|
881
1117
|
metadata: {
|
|
@@ -883,6 +1119,92 @@ var RestServer = class {
|
|
|
883
1119
|
tags: ["metadata"]
|
|
884
1120
|
}
|
|
885
1121
|
});
|
|
1122
|
+
this.routeManager.register({
|
|
1123
|
+
method: "DELETE",
|
|
1124
|
+
path: `${metaPath}/:type/:name`,
|
|
1125
|
+
handler: async (req, res) => {
|
|
1126
|
+
try {
|
|
1127
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1128
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1129
|
+
if (!p.deleteMetaItem) {
|
|
1130
|
+
res.status(501).json({
|
|
1131
|
+
error: "Reset operation not supported by protocol implementation"
|
|
1132
|
+
});
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
const result = await p.deleteMetaItem({
|
|
1136
|
+
type: req.params.type,
|
|
1137
|
+
name: req.params.name,
|
|
1138
|
+
...projectId ? { projectId } : {}
|
|
1139
|
+
});
|
|
1140
|
+
res.json(result);
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
logError("[REST] Unhandled error:", error);
|
|
1143
|
+
sendError(res, error);
|
|
1144
|
+
}
|
|
1145
|
+
},
|
|
1146
|
+
metadata: {
|
|
1147
|
+
summary: "Reset metadata item to artifact default (deletes customization overlay)",
|
|
1148
|
+
tags: ["metadata"]
|
|
1149
|
+
}
|
|
1150
|
+
});
|
|
1151
|
+
if (metadata.endpoints.item !== false) {
|
|
1152
|
+
this.routeManager.register({
|
|
1153
|
+
method: "GET",
|
|
1154
|
+
path: `${metaPath}/:type/:section/:name`,
|
|
1155
|
+
handler: async (req, res) => {
|
|
1156
|
+
try {
|
|
1157
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1158
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1159
|
+
const compoundName = `${req.params.section}/${req.params.name}`;
|
|
1160
|
+
const packageId = req.query?.package || void 0;
|
|
1161
|
+
const item = await p.getMetaItem({
|
|
1162
|
+
type: req.params.type,
|
|
1163
|
+
name: compoundName,
|
|
1164
|
+
packageId
|
|
1165
|
+
});
|
|
1166
|
+
res.header("Vary", "Accept-Language");
|
|
1167
|
+
res.json(await this.translateMetaItem(req, req.params.type, projectId, item));
|
|
1168
|
+
} catch (error) {
|
|
1169
|
+
logError("[REST] Unhandled error:", error);
|
|
1170
|
+
sendError(res, error);
|
|
1171
|
+
}
|
|
1172
|
+
},
|
|
1173
|
+
metadata: {
|
|
1174
|
+
summary: "Get specific metadata item by compound name",
|
|
1175
|
+
tags: ["metadata"]
|
|
1176
|
+
}
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
this.routeManager.register({
|
|
1180
|
+
method: "PUT",
|
|
1181
|
+
path: `${metaPath}/:type/:section/:name`,
|
|
1182
|
+
handler: async (req, res) => {
|
|
1183
|
+
try {
|
|
1184
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1185
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1186
|
+
if (!p.saveMetaItem) {
|
|
1187
|
+
res.status(501).json({ error: "Save operation not supported by protocol implementation" });
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
const compoundName = `${req.params.section}/${req.params.name}`;
|
|
1191
|
+
const result = await p.saveMetaItem({
|
|
1192
|
+
type: req.params.type,
|
|
1193
|
+
name: compoundName,
|
|
1194
|
+
item: req.body,
|
|
1195
|
+
...projectId ? { projectId } : {}
|
|
1196
|
+
});
|
|
1197
|
+
res.json(result);
|
|
1198
|
+
} catch (error) {
|
|
1199
|
+
logError("[REST] Unhandled error:", error);
|
|
1200
|
+
sendError(res, error);
|
|
1201
|
+
}
|
|
1202
|
+
},
|
|
1203
|
+
metadata: {
|
|
1204
|
+
summary: "Save specific metadata item by compound name",
|
|
1205
|
+
tags: ["metadata"]
|
|
1206
|
+
}
|
|
1207
|
+
});
|
|
886
1208
|
}
|
|
887
1209
|
/**
|
|
888
1210
|
* Register UI endpoints
|
|
@@ -909,7 +1231,7 @@ var RestServer = class {
|
|
|
909
1231
|
}
|
|
910
1232
|
} catch (error) {
|
|
911
1233
|
logError("[REST] Unhandled error:", error);
|
|
912
|
-
res
|
|
1234
|
+
sendError(res, error, req.params?.object);
|
|
913
1235
|
}
|
|
914
1236
|
},
|
|
915
1237
|
metadata: {
|
|
@@ -935,6 +1257,7 @@ var RestServer = class {
|
|
|
935
1257
|
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
936
1258
|
const p = await this.resolveProtocol(projectId, req);
|
|
937
1259
|
const context = await this.resolveExecCtx(projectId, req);
|
|
1260
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
938
1261
|
const result = await p.findData({
|
|
939
1262
|
object: req.params.object,
|
|
940
1263
|
query: req.query,
|
|
@@ -968,6 +1291,7 @@ var RestServer = class {
|
|
|
968
1291
|
const p = await this.resolveProtocol(projectId, req);
|
|
969
1292
|
const { select, expand } = req.query || {};
|
|
970
1293
|
const context = await this.resolveExecCtx(projectId, req);
|
|
1294
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
971
1295
|
const result = await p.getData({
|
|
972
1296
|
object: req.params.object,
|
|
973
1297
|
id: req.params.id,
|
|
@@ -979,7 +1303,7 @@ var RestServer = class {
|
|
|
979
1303
|
res.json(result);
|
|
980
1304
|
} catch (error) {
|
|
981
1305
|
const mapped = mapDataError(error, req.params?.object);
|
|
982
|
-
if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
|
|
1306
|
+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
|
|
983
1307
|
res.status(mapped.status === 400 ? 404 : mapped.status).json(mapped.body);
|
|
984
1308
|
}
|
|
985
1309
|
},
|
|
@@ -998,6 +1322,7 @@ var RestServer = class {
|
|
|
998
1322
|
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
999
1323
|
const p = await this.resolveProtocol(projectId, req);
|
|
1000
1324
|
const context = await this.resolveExecCtx(projectId, req);
|
|
1325
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1001
1326
|
const result = await p.createData({
|
|
1002
1327
|
object: req.params.object,
|
|
1003
1328
|
data: req.body,
|
|
@@ -1007,7 +1332,7 @@ var RestServer = class {
|
|
|
1007
1332
|
res.status(201).json(result);
|
|
1008
1333
|
} catch (error) {
|
|
1009
1334
|
const mapped = mapDataError(error, req.params?.object);
|
|
1010
|
-
if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
|
|
1335
|
+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
|
|
1011
1336
|
res.status(mapped.status).json(mapped.body);
|
|
1012
1337
|
}
|
|
1013
1338
|
},
|
|
@@ -1017,19 +1342,19 @@ var RestServer = class {
|
|
|
1017
1342
|
}
|
|
1018
1343
|
});
|
|
1019
1344
|
}
|
|
1020
|
-
if (operations.
|
|
1345
|
+
if (operations.list) {
|
|
1021
1346
|
this.routeManager.register({
|
|
1022
|
-
method: "
|
|
1023
|
-
path: `${dataPath}/:object
|
|
1347
|
+
method: "POST",
|
|
1348
|
+
path: `${dataPath}/:object/query`,
|
|
1024
1349
|
handler: async (req, res) => {
|
|
1025
1350
|
try {
|
|
1026
1351
|
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1027
1352
|
const p = await this.resolveProtocol(projectId, req);
|
|
1028
1353
|
const context = await this.resolveExecCtx(projectId, req);
|
|
1029
|
-
|
|
1354
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1355
|
+
const result = await p.findData({
|
|
1030
1356
|
object: req.params.object,
|
|
1031
|
-
|
|
1032
|
-
data: req.body,
|
|
1357
|
+
query: req.body || {},
|
|
1033
1358
|
...projectId ? { projectId } : {},
|
|
1034
1359
|
...context ? { context } : {}
|
|
1035
1360
|
});
|
|
@@ -1041,95 +1366,1640 @@ var RestServer = class {
|
|
|
1041
1366
|
}
|
|
1042
1367
|
},
|
|
1043
1368
|
metadata: {
|
|
1044
|
-
summary: "
|
|
1369
|
+
summary: "Advanced query (QueryAST in body)",
|
|
1045
1370
|
tags: ["data", "crud"]
|
|
1046
1371
|
}
|
|
1047
1372
|
});
|
|
1048
1373
|
}
|
|
1049
|
-
if (operations.
|
|
1374
|
+
if (operations.update) {
|
|
1050
1375
|
this.routeManager.register({
|
|
1051
|
-
method: "
|
|
1376
|
+
method: "PATCH",
|
|
1052
1377
|
path: `${dataPath}/:object/:id`,
|
|
1053
1378
|
handler: async (req, res) => {
|
|
1054
1379
|
try {
|
|
1055
1380
|
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1056
1381
|
const p = await this.resolveProtocol(projectId, req);
|
|
1057
1382
|
const context = await this.resolveExecCtx(projectId, req);
|
|
1058
|
-
|
|
1383
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1384
|
+
const result = await p.updateData({
|
|
1059
1385
|
object: req.params.object,
|
|
1060
1386
|
id: req.params.id,
|
|
1387
|
+
data: req.body,
|
|
1061
1388
|
...projectId ? { projectId } : {},
|
|
1062
1389
|
...context ? { context } : {}
|
|
1063
1390
|
});
|
|
1064
1391
|
res.json(result);
|
|
1065
1392
|
} catch (error) {
|
|
1066
1393
|
const mapped = mapDataError(error, req.params?.object);
|
|
1067
|
-
if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
|
|
1394
|
+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
|
|
1068
1395
|
res.status(mapped.status).json(mapped.body);
|
|
1069
1396
|
}
|
|
1070
1397
|
},
|
|
1071
1398
|
metadata: {
|
|
1072
|
-
summary: "
|
|
1399
|
+
summary: "Update record",
|
|
1073
1400
|
tags: ["data", "crud"]
|
|
1074
1401
|
}
|
|
1075
1402
|
});
|
|
1076
1403
|
}
|
|
1077
|
-
|
|
1078
|
-
/**
|
|
1079
|
-
* Register batch operation endpoints
|
|
1080
|
-
*/
|
|
1081
|
-
registerBatchEndpoints(basePath) {
|
|
1082
|
-
const { crud, batch } = this.config;
|
|
1083
|
-
const dataPath = `${basePath}${crud.dataPrefix}`;
|
|
1084
|
-
const isScoped = basePath.includes("/projects/:projectId");
|
|
1085
|
-
const operations = batch.operations;
|
|
1086
|
-
if (batch.enableBatchEndpoint && this.protocol.batchData) {
|
|
1404
|
+
if (operations.delete) {
|
|
1087
1405
|
this.routeManager.register({
|
|
1088
|
-
method: "
|
|
1089
|
-
path: `${dataPath}/:object
|
|
1406
|
+
method: "DELETE",
|
|
1407
|
+
path: `${dataPath}/:object/:id`,
|
|
1090
1408
|
handler: async (req, res) => {
|
|
1091
1409
|
try {
|
|
1092
1410
|
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1093
1411
|
const p = await this.resolveProtocol(projectId, req);
|
|
1094
|
-
const
|
|
1412
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1413
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1414
|
+
const result = await p.deleteData({
|
|
1095
1415
|
object: req.params.object,
|
|
1096
|
-
|
|
1097
|
-
...projectId ? { projectId } : {}
|
|
1416
|
+
id: req.params.id,
|
|
1417
|
+
...projectId ? { projectId } : {},
|
|
1418
|
+
...context ? { context } : {}
|
|
1098
1419
|
});
|
|
1099
1420
|
res.json(result);
|
|
1100
1421
|
} catch (error) {
|
|
1101
|
-
|
|
1102
|
-
|
|
1422
|
+
const mapped = mapDataError(error, req.params?.object);
|
|
1423
|
+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
|
|
1424
|
+
res.status(mapped.status).json(mapped.body);
|
|
1103
1425
|
}
|
|
1104
1426
|
},
|
|
1105
1427
|
metadata: {
|
|
1106
|
-
summary: "
|
|
1107
|
-
tags: ["data", "
|
|
1428
|
+
summary: "Delete record",
|
|
1429
|
+
tags: ["data", "crud"]
|
|
1108
1430
|
}
|
|
1109
1431
|
});
|
|
1110
1432
|
}
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Register object-specific action endpoints that don't fit the
|
|
1436
|
+
* generic CRUD shape. These are domain operations (Salesforce
|
|
1437
|
+
* convertLead, etc.) where the protocol implementation does its own
|
|
1438
|
+
* multi-record orchestration and we just need a thin HTTP route.
|
|
1439
|
+
*
|
|
1440
|
+
* POST {basePath}/data/lead/:id/convert — M10.6 lead conversion.
|
|
1441
|
+
*/
|
|
1442
|
+
registerDataActionEndpoints(basePath) {
|
|
1443
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
1444
|
+
const { crud } = this.config;
|
|
1445
|
+
const dataPath = `${basePath}${crud.dataPrefix}`;
|
|
1446
|
+
this.routeManager.register({
|
|
1447
|
+
method: "POST",
|
|
1448
|
+
path: `${dataPath}/lead/:id/convert`,
|
|
1449
|
+
handler: async (req, res) => {
|
|
1450
|
+
try {
|
|
1451
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1452
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1453
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1454
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1455
|
+
const convertLead = p.convertLead;
|
|
1456
|
+
if (typeof convertLead !== "function") {
|
|
1457
|
+
res.status(501).json({ code: "NOT_IMPLEMENTED", error: "Lead convert not supported by this protocol" });
|
|
1458
|
+
return;
|
|
1128
1459
|
}
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1460
|
+
const body = req.body ?? {};
|
|
1461
|
+
const result = await convertLead.call(p, {
|
|
1462
|
+
leadId: req.params.id,
|
|
1463
|
+
accountId: body.accountId,
|
|
1464
|
+
contactId: body.contactId,
|
|
1465
|
+
createOpportunity: body.createOpportunity,
|
|
1466
|
+
opportunity: body.opportunity,
|
|
1467
|
+
convertedStatus: body.convertedStatus,
|
|
1468
|
+
...context ? { context } : {}
|
|
1469
|
+
});
|
|
1470
|
+
res.json(result);
|
|
1471
|
+
} catch (error) {
|
|
1472
|
+
logError("[REST] Unhandled error:", error);
|
|
1473
|
+
sendError(res, error, "lead");
|
|
1474
|
+
}
|
|
1475
|
+
},
|
|
1476
|
+
metadata: {
|
|
1477
|
+
summary: "Convert a Lead into Account + Contact (+ optional Opportunity)",
|
|
1478
|
+
tags: ["data", "lead"]
|
|
1479
|
+
}
|
|
1480
|
+
});
|
|
1481
|
+
this.routeManager.register({
|
|
1482
|
+
method: "POST",
|
|
1483
|
+
path: `${dataPath}/:object/import`,
|
|
1484
|
+
handler: async (req, res) => {
|
|
1485
|
+
try {
|
|
1486
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1487
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1488
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1489
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1490
|
+
const objectName = String(req.params.object || "");
|
|
1491
|
+
if (!objectName) {
|
|
1492
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
const body = req.body ?? {};
|
|
1496
|
+
const dryRun = body.dryRun === true;
|
|
1497
|
+
const mapping = body.mapping ?? {};
|
|
1498
|
+
let rows = [];
|
|
1499
|
+
if (body.format === "json" && Array.isArray(body.rows)) {
|
|
1500
|
+
rows = body.rows;
|
|
1501
|
+
} else if ((body.format === "csv" || typeof body.csv === "string") && typeof body.csv === "string") {
|
|
1502
|
+
rows = parseCsvToRows(body.csv, mapping);
|
|
1503
|
+
} else if (Array.isArray(body)) {
|
|
1504
|
+
rows = body;
|
|
1505
|
+
} else {
|
|
1506
|
+
res.status(400).json({
|
|
1507
|
+
code: "INVALID_REQUEST",
|
|
1508
|
+
error: 'Provide either format:"csv" with csv text or format:"json" with rows[]'
|
|
1509
|
+
});
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
const max = 5e3;
|
|
1513
|
+
if (rows.length > max) {
|
|
1514
|
+
res.status(413).json({
|
|
1515
|
+
code: "PAYLOAD_TOO_LARGE",
|
|
1516
|
+
error: `Import limit is ${max} rows per request (got ${rows.length})`
|
|
1517
|
+
});
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
const results = [];
|
|
1521
|
+
let okCount = 0;
|
|
1522
|
+
let errCount = 0;
|
|
1523
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1524
|
+
const data = rows[i];
|
|
1525
|
+
try {
|
|
1526
|
+
if (dryRun) {
|
|
1527
|
+
const validate = p.validate;
|
|
1528
|
+
if (typeof validate === "function") {
|
|
1529
|
+
await validate.call(p, { object: objectName, data, context });
|
|
1530
|
+
}
|
|
1531
|
+
results.push({ row: i + 1, ok: true });
|
|
1532
|
+
okCount++;
|
|
1533
|
+
} else {
|
|
1534
|
+
const created = await p.createData({ object: objectName, data, context });
|
|
1535
|
+
const id = created?.id ?? created?.record?.id;
|
|
1536
|
+
results.push({ row: i + 1, ok: true, id });
|
|
1537
|
+
okCount++;
|
|
1538
|
+
}
|
|
1539
|
+
} catch (err) {
|
|
1540
|
+
errCount++;
|
|
1541
|
+
const code = err?.code ?? "IMPORT_ROW_FAILED";
|
|
1542
|
+
const message = typeof err?.message === "string" ? err.message.slice(0, 300) : "Row failed";
|
|
1543
|
+
results.push({ row: i + 1, ok: false, error: message, code });
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
res.json({
|
|
1547
|
+
object: objectName,
|
|
1548
|
+
dryRun,
|
|
1549
|
+
total: rows.length,
|
|
1550
|
+
ok: okCount,
|
|
1551
|
+
errors: errCount,
|
|
1552
|
+
results
|
|
1553
|
+
});
|
|
1554
|
+
} catch (error) {
|
|
1555
|
+
logError("[REST] Unhandled error:", error);
|
|
1556
|
+
sendError(res, error, String(req.params?.object || ""));
|
|
1557
|
+
}
|
|
1558
|
+
},
|
|
1559
|
+
metadata: {
|
|
1560
|
+
summary: "Bulk-import rows into an object (CSV or JSON, with optional dry-run)",
|
|
1561
|
+
tags: ["data", "import"]
|
|
1562
|
+
}
|
|
1563
|
+
});
|
|
1564
|
+
this.routeManager.register({
|
|
1565
|
+
method: "GET",
|
|
1566
|
+
path: `${dataPath}/:object/export`,
|
|
1567
|
+
handler: async (req, res) => {
|
|
1568
|
+
try {
|
|
1569
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1570
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1571
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1572
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1573
|
+
const objectName = String(req.params.object || "");
|
|
1574
|
+
if (!objectName) {
|
|
1575
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
const q = req.query ?? {};
|
|
1579
|
+
const format = String(q.format ?? "csv").toLowerCase() === "json" ? "json" : "csv";
|
|
1580
|
+
const HARD_CAP = 5e4;
|
|
1581
|
+
const MAX_CHUNK = 5e3;
|
|
1582
|
+
const requestedLimit = q.limit != null ? Math.max(1, Number(q.limit) || 0) : 1e4;
|
|
1583
|
+
const limit = Math.min(requestedLimit, HARD_CAP);
|
|
1584
|
+
const chunkSize = Math.min(MAX_CHUNK, Math.max(50, q.page != null ? Number(q.page) || 500 : 500));
|
|
1585
|
+
let filter = void 0;
|
|
1586
|
+
if (typeof q.filter === "string" && q.filter.length > 0) {
|
|
1587
|
+
try {
|
|
1588
|
+
filter = JSON.parse(q.filter);
|
|
1589
|
+
} catch {
|
|
1590
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "filter must be JSON" });
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
} else if (q.filter && typeof q.filter === "object") {
|
|
1594
|
+
filter = q.filter;
|
|
1595
|
+
}
|
|
1596
|
+
let orderby = void 0;
|
|
1597
|
+
if (typeof q.orderby === "string" && q.orderby.length > 0) {
|
|
1598
|
+
if (q.orderby.startsWith("{") || q.orderby.startsWith("[")) {
|
|
1599
|
+
try {
|
|
1600
|
+
orderby = JSON.parse(q.orderby);
|
|
1601
|
+
} catch {
|
|
1602
|
+
}
|
|
1603
|
+
} else {
|
|
1604
|
+
const obj = {};
|
|
1605
|
+
for (const part of q.orderby.split(",")) {
|
|
1606
|
+
const [field, dir] = part.split(":").map((s) => s.trim());
|
|
1607
|
+
if (field) obj[field] = dir?.toLowerCase() === "desc" ? "desc" : "asc";
|
|
1608
|
+
}
|
|
1609
|
+
if (Object.keys(obj).length > 0) orderby = obj;
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
let fields;
|
|
1613
|
+
if (typeof q.fields === "string" && q.fields.length > 0) {
|
|
1614
|
+
fields = q.fields.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1615
|
+
} else if (Array.isArray(q.fields)) {
|
|
1616
|
+
fields = q.fields.filter((s) => typeof s === "string" && s.length > 0);
|
|
1617
|
+
}
|
|
1618
|
+
if (!fields || fields.length === 0) {
|
|
1619
|
+
try {
|
|
1620
|
+
const schema = await p.getObjectSchema?.(objectName, projectId);
|
|
1621
|
+
const schemaFields = schema?.fields;
|
|
1622
|
+
if (Array.isArray(schemaFields)) {
|
|
1623
|
+
fields = schemaFields.map((f) => f.name).filter((n) => typeof n === "string");
|
|
1624
|
+
}
|
|
1625
|
+
} catch {
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1629
|
+
const safeObj = objectName.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
1630
|
+
if (format === "csv") {
|
|
1631
|
+
res.header("Content-Type", "text/csv; charset=utf-8");
|
|
1632
|
+
res.header("Content-Disposition", `attachment; filename="${safeObj}-${stamp}.csv"`);
|
|
1633
|
+
} else {
|
|
1634
|
+
res.header("Content-Type", "application/json; charset=utf-8");
|
|
1635
|
+
res.header("Content-Disposition", `attachment; filename="${safeObj}-${stamp}.json"`);
|
|
1636
|
+
}
|
|
1637
|
+
res.header("X-Export-Format", format);
|
|
1638
|
+
res.header("X-Export-Limit", String(limit));
|
|
1639
|
+
res.header("Cache-Control", "no-store");
|
|
1640
|
+
let exported = 0;
|
|
1641
|
+
let firstChunk = true;
|
|
1642
|
+
let skip = 0;
|
|
1643
|
+
if (format === "json") res.write("[");
|
|
1644
|
+
while (exported < limit) {
|
|
1645
|
+
const take = Math.min(chunkSize, limit - exported);
|
|
1646
|
+
const findArgs = {
|
|
1647
|
+
object: objectName,
|
|
1648
|
+
query: {
|
|
1649
|
+
...filter ? { $filter: filter } : {},
|
|
1650
|
+
...orderby ? { $orderby: orderby } : {},
|
|
1651
|
+
$top: take,
|
|
1652
|
+
$skip: skip
|
|
1653
|
+
},
|
|
1654
|
+
...projectId ? { projectId } : {},
|
|
1655
|
+
...context ? { context } : {}
|
|
1656
|
+
};
|
|
1657
|
+
const result = await p.findData(findArgs);
|
|
1658
|
+
const rows = Array.isArray(result?.data) ? result.data : Array.isArray(result?.rows) ? result.rows : Array.isArray(result) ? result : [];
|
|
1659
|
+
if (rows.length === 0) break;
|
|
1660
|
+
if (format === "csv") {
|
|
1661
|
+
if ((!fields || fields.length === 0) && firstChunk) {
|
|
1662
|
+
fields = Object.keys(rows[0] ?? {});
|
|
1663
|
+
}
|
|
1664
|
+
const text = rowsToCsv(fields ?? [], rows, firstChunk);
|
|
1665
|
+
res.write(text);
|
|
1666
|
+
} else {
|
|
1667
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1668
|
+
const prefix = firstChunk && i === 0 ? "" : ",";
|
|
1669
|
+
res.write(prefix + JSON.stringify(rows[i]));
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
firstChunk = false;
|
|
1673
|
+
exported += rows.length;
|
|
1674
|
+
skip += rows.length;
|
|
1675
|
+
if (rows.length < take) break;
|
|
1676
|
+
}
|
|
1677
|
+
if (format === "json") res.write("]");
|
|
1678
|
+
res.end();
|
|
1679
|
+
} catch (error) {
|
|
1680
|
+
logError("[REST] Unhandled error:", error);
|
|
1681
|
+
try {
|
|
1682
|
+
sendError(res, error, String(req.params?.object || ""));
|
|
1683
|
+
} catch {
|
|
1684
|
+
try {
|
|
1685
|
+
res.end();
|
|
1686
|
+
} catch {
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
},
|
|
1691
|
+
metadata: {
|
|
1692
|
+
summary: "Streaming export of object rows (CSV or JSON)",
|
|
1693
|
+
tags: ["data", "export"]
|
|
1694
|
+
}
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
/**
|
|
1698
|
+
* Register global cross-object search endpoint (M10.5).
|
|
1699
|
+
* GET {basePath}/search?q=acme&objects=lead,account&limit=20&perObject=5
|
|
1700
|
+
*/
|
|
1701
|
+
registerSearchEndpoints(basePath) {
|
|
1702
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
1703
|
+
this.routeManager.register({
|
|
1704
|
+
method: "GET",
|
|
1705
|
+
path: `${basePath}/search`,
|
|
1706
|
+
handler: async (req, res) => {
|
|
1707
|
+
try {
|
|
1708
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1709
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1710
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1711
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1712
|
+
const searchAll = p.searchAll;
|
|
1713
|
+
if (typeof searchAll !== "function") {
|
|
1714
|
+
res.status(501).json({ code: "NOT_IMPLEMENTED", message: "Search not supported by this protocol" });
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
const q = String(req.query?.q ?? req.query?.query ?? "");
|
|
1718
|
+
const objectsParam = req.query?.objects;
|
|
1719
|
+
const objects = typeof objectsParam === "string" ? objectsParam.split(",").map((s) => s.trim()).filter(Boolean) : Array.isArray(objectsParam) ? objectsParam : void 0;
|
|
1720
|
+
const result = await searchAll.call(p, {
|
|
1721
|
+
q,
|
|
1722
|
+
objects,
|
|
1723
|
+
limit: req.query?.limit ? Number(req.query.limit) : void 0,
|
|
1724
|
+
perObject: req.query?.perObject ? Number(req.query.perObject) : void 0,
|
|
1725
|
+
...context ? { context } : {}
|
|
1726
|
+
});
|
|
1727
|
+
res.json(result);
|
|
1728
|
+
} catch (error) {
|
|
1729
|
+
const mapped = mapDataError(error);
|
|
1730
|
+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") {
|
|
1731
|
+
logError("[REST] Unhandled error:", error);
|
|
1732
|
+
}
|
|
1733
|
+
res.status(mapped.status).json(mapped.body);
|
|
1734
|
+
}
|
|
1735
|
+
},
|
|
1736
|
+
metadata: {
|
|
1737
|
+
summary: "Global cross-object search",
|
|
1738
|
+
tags: ["search"]
|
|
1739
|
+
}
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
/**
|
|
1743
|
+
* Register email endpoints (M11.B1 / M10.7).
|
|
1744
|
+
*
|
|
1745
|
+
* POST {basePath}/email/send — send a transactional email via the
|
|
1746
|
+
* `IEmailService` provider registered by EmailServicePlugin. Returns
|
|
1747
|
+
* 501 when no provider is wired so deployments without email
|
|
1748
|
+
* configured fail cleanly.
|
|
1749
|
+
*
|
|
1750
|
+
* Request body:
|
|
1751
|
+
* {
|
|
1752
|
+
* to: "a@b.com" | ["a@b.com", { name, address }],
|
|
1753
|
+
* from?: ..., cc?: ..., bcc?: ..., replyTo?: ...,
|
|
1754
|
+
* subject: string,
|
|
1755
|
+
* text?: string, html?: string, // at least one required
|
|
1756
|
+
* attachments?: [{ filename, content, contentType?, cid? }],
|
|
1757
|
+
* headers?: { [name]: value },
|
|
1758
|
+
* relatedObject?: string, relatedId?: string,
|
|
1759
|
+
* }
|
|
1760
|
+
*/
|
|
1761
|
+
registerEmailEndpoints(basePath) {
|
|
1762
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
1763
|
+
this.routeManager.register({
|
|
1764
|
+
method: "POST",
|
|
1765
|
+
path: `${basePath}/email/send`,
|
|
1766
|
+
handler: async (req, res) => {
|
|
1767
|
+
try {
|
|
1768
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1769
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1770
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1771
|
+
if (!this.emailServiceProvider) {
|
|
1772
|
+
res.status(501).json({
|
|
1773
|
+
code: "NOT_IMPLEMENTED",
|
|
1774
|
+
message: "Email service is not configured on this deployment"
|
|
1775
|
+
});
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
const emailService = await this.emailServiceProvider(projectId).catch(() => void 0);
|
|
1779
|
+
if (!emailService || typeof emailService.send !== "function") {
|
|
1780
|
+
res.status(501).json({
|
|
1781
|
+
code: "NOT_IMPLEMENTED",
|
|
1782
|
+
message: "Email service is not configured on this deployment"
|
|
1783
|
+
});
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
const body = req.body ?? {};
|
|
1787
|
+
if (!body || typeof body !== "object") {
|
|
1788
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "JSON body required" });
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
const input = {
|
|
1792
|
+
...body,
|
|
1793
|
+
...body.sentBy === void 0 && context?.userId ? { sentBy: context.userId } : {}
|
|
1794
|
+
};
|
|
1795
|
+
try {
|
|
1796
|
+
const result = await emailService.send(input);
|
|
1797
|
+
if (result?.status === "sent") {
|
|
1798
|
+
res.status(200).json(result);
|
|
1799
|
+
} else {
|
|
1800
|
+
res.status(200).json(result);
|
|
1801
|
+
}
|
|
1802
|
+
} catch (err) {
|
|
1803
|
+
const message = String(err?.message ?? err ?? "send failed");
|
|
1804
|
+
if (message.startsWith("VALIDATION_FAILED")) {
|
|
1805
|
+
res.status(400).json({
|
|
1806
|
+
code: "VALIDATION_FAILED",
|
|
1807
|
+
error: message.replace(/^VALIDATION_FAILED:\s*/, "")
|
|
1808
|
+
});
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
throw err;
|
|
1812
|
+
}
|
|
1813
|
+
} catch (error) {
|
|
1814
|
+
logError("[REST] Email send unhandled error:", error);
|
|
1815
|
+
res.status(500).json({
|
|
1816
|
+
code: "EMAIL_SEND_FAILED",
|
|
1817
|
+
error: String(error?.message ?? error ?? "send failed").slice(0, 500)
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1820
|
+
},
|
|
1821
|
+
metadata: {
|
|
1822
|
+
summary: "Send a transactional email via the configured EmailService",
|
|
1823
|
+
tags: ["email"]
|
|
1824
|
+
}
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Register public (anonymous) form endpoints.
|
|
1829
|
+
*
|
|
1830
|
+
* Public forms are opt-in: a `FormView` becomes accessible to anonymous
|
|
1831
|
+
* visitors only when `sharing.allowAnonymous === true` AND a
|
|
1832
|
+
* `sharing.publicLink` slug is configured. Two routes are registered:
|
|
1833
|
+
*
|
|
1834
|
+
* GET {basePath}/forms/:slug → resolved form spec
|
|
1835
|
+
* POST {basePath}/forms/:slug/submit → INSERT record (no auth required)
|
|
1836
|
+
*
|
|
1837
|
+
* Both routes bypass `enforceAuth` even when `requireAuth=true` on the
|
|
1838
|
+
* deployment (e.g. ObjectOS multi-tenant). Security is delegated to the
|
|
1839
|
+
* `guest_portal` permission set carried on the execution context — the
|
|
1840
|
+
* SecurityPlugin enforces INSERT-only access to the target object. If
|
|
1841
|
+
* the deployment hasn't registered a `guest_portal` profile, the
|
|
1842
|
+
* security middleware falls open with `permissions: []` (no userId),
|
|
1843
|
+
* matching the existing anonymous-access semantics; deployers must
|
|
1844
|
+
* keep `requireAuth=true` deployments paired with a `guest_portal`
|
|
1845
|
+
* profile (the CRM example does this) to enforce the INSERT-only
|
|
1846
|
+
* contract.
|
|
1847
|
+
*
|
|
1848
|
+
* The matched FormView's parent ViewSchema is found by scanning
|
|
1849
|
+
* `protocol.getMetaItems({ type: 'view' })`. For each entry we inspect
|
|
1850
|
+
* `form.sharing` and every entry in `formViews`; the first FormView
|
|
1851
|
+
* whose `sharing.publicLink` matches `/forms/:slug` (or just `:slug`)
|
|
1852
|
+
* wins. The response carries the matched form view under `form` and
|
|
1853
|
+
* the inferred target object, matching what the frontend's
|
|
1854
|
+
* `mapViewSpecToEmbeddableConfig` expects.
|
|
1855
|
+
*/
|
|
1856
|
+
registerFormEndpoints(basePath) {
|
|
1857
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
1858
|
+
const slugMatchesPublicLink = (publicLink, slug) => {
|
|
1859
|
+
if (!publicLink || typeof publicLink !== "string") return false;
|
|
1860
|
+
const normalized = publicLink.replace(/^\/+/, "").replace(/^forms\//, "");
|
|
1861
|
+
return normalized === slug;
|
|
1862
|
+
};
|
|
1863
|
+
const findPublicFormView = (views, slug) => {
|
|
1864
|
+
for (const view of views ?? []) {
|
|
1865
|
+
if (!view || typeof view !== "object") continue;
|
|
1866
|
+
const candidates = [];
|
|
1867
|
+
if (view.form && view.form.sharing) candidates.push({ form: view.form });
|
|
1868
|
+
const formViews = view.formViews;
|
|
1869
|
+
if (formViews && typeof formViews === "object") {
|
|
1870
|
+
for (const [key, fv] of Object.entries(formViews)) {
|
|
1871
|
+
if (fv && typeof fv === "object" && fv.sharing) {
|
|
1872
|
+
candidates.push({ form: fv, key });
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
for (const c of candidates) {
|
|
1877
|
+
const sharing = c.form?.sharing;
|
|
1878
|
+
if (!sharing || sharing.allowAnonymous !== true) continue;
|
|
1879
|
+
if (!slugMatchesPublicLink(sharing.publicLink, slug)) continue;
|
|
1880
|
+
const objectName = c.form?.data?.object ?? view?.list?.data?.object ?? view?.form?.data?.object ?? view?.object;
|
|
1881
|
+
if (!objectName) continue;
|
|
1882
|
+
return { view, form: c.form, object: objectName };
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
return null;
|
|
1886
|
+
};
|
|
1887
|
+
const resolveFormBySlug = async (projectId, req, slug) => {
|
|
1888
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1889
|
+
if (typeof p.getMetaItems !== "function") return null;
|
|
1890
|
+
const result = await p.getMetaItems({
|
|
1891
|
+
type: "view",
|
|
1892
|
+
...projectId ? { projectId } : {}
|
|
1893
|
+
});
|
|
1894
|
+
const items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
|
|
1895
|
+
return findPublicFormView(items, slug);
|
|
1896
|
+
};
|
|
1897
|
+
this.routeManager.register({
|
|
1898
|
+
method: "GET",
|
|
1899
|
+
path: `${basePath}/forms/:slug`,
|
|
1900
|
+
handler: async (req, res) => {
|
|
1901
|
+
try {
|
|
1902
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1903
|
+
const slug = String(req.params?.slug ?? "").trim();
|
|
1904
|
+
if (!slug) {
|
|
1905
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "slug is required" });
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
const match = await resolveFormBySlug(projectId, req, slug);
|
|
1909
|
+
if (!match) {
|
|
1910
|
+
res.status(404).json({
|
|
1911
|
+
code: "FORM_NOT_FOUND",
|
|
1912
|
+
error: `No public form configured at /forms/${slug}`
|
|
1913
|
+
});
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
let objectSchema = null;
|
|
1917
|
+
try {
|
|
1918
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1919
|
+
if (typeof p.getMetaItems === "function") {
|
|
1920
|
+
const r = await p.getMetaItems({
|
|
1921
|
+
type: "object",
|
|
1922
|
+
...projectId ? { projectId } : {}
|
|
1923
|
+
});
|
|
1924
|
+
const items = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
|
|
1925
|
+
const obj = items.find((o) => o?.name === match.object);
|
|
1926
|
+
if (obj && obj.fields && typeof obj.fields === "object") {
|
|
1927
|
+
const allowed = /* @__PURE__ */ new Set();
|
|
1928
|
+
for (const sec of match.form?.sections ?? []) {
|
|
1929
|
+
for (const f of sec?.fields ?? []) {
|
|
1930
|
+
if (typeof f === "string") allowed.add(f);
|
|
1931
|
+
else if (f?.field) allowed.add(f.field);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
const fields = {};
|
|
1935
|
+
for (const [name, def] of Object.entries(obj.fields)) {
|
|
1936
|
+
if (allowed.size === 0 || allowed.has(name)) {
|
|
1937
|
+
fields[name] = def;
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
objectSchema = { name: obj.name, label: obj.label, fields };
|
|
1941
|
+
try {
|
|
1942
|
+
const i18n = await this.resolveI18nService(projectId, req);
|
|
1943
|
+
const bundle = this.buildTranslationBundle(i18n);
|
|
1944
|
+
const locale = this.extractLocale(req, i18n);
|
|
1945
|
+
if (bundle && locale) {
|
|
1946
|
+
const { translateMetadataDocument } = await import("@objectstack/spec/system");
|
|
1947
|
+
objectSchema = translateMetadataDocument("object", objectSchema, bundle, { locale });
|
|
1948
|
+
}
|
|
1949
|
+
} catch (e) {
|
|
1950
|
+
logError("[REST] Public form schema translation failed:", e);
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
} catch (e) {
|
|
1955
|
+
logError("[REST] Public form schema load failed:", e);
|
|
1956
|
+
}
|
|
1957
|
+
const safeForm = (() => {
|
|
1958
|
+
if (!match.form || !Array.isArray(match.form.sections)) return match.form;
|
|
1959
|
+
const allow = (name, cfg) => {
|
|
1960
|
+
const def = objectSchema?.fields?.[name];
|
|
1961
|
+
const t = def?.type;
|
|
1962
|
+
if (t !== "lookup" && t !== "master_detail") return true;
|
|
1963
|
+
return !!cfg?.publicPicker;
|
|
1964
|
+
};
|
|
1965
|
+
const sections = match.form.sections.map((sec) => {
|
|
1966
|
+
const fields = (sec?.fields ?? []).filter((f) => {
|
|
1967
|
+
const name = typeof f === "string" ? f : f?.field;
|
|
1968
|
+
if (!name) return false;
|
|
1969
|
+
const cfg = typeof f === "string" ? {} : f;
|
|
1970
|
+
return allow(name, cfg);
|
|
1971
|
+
});
|
|
1972
|
+
return { ...sec, fields };
|
|
1973
|
+
});
|
|
1974
|
+
return { ...match.form, sections };
|
|
1975
|
+
})();
|
|
1976
|
+
res.header("Vary", "Accept-Language");
|
|
1977
|
+
res.json({
|
|
1978
|
+
slug,
|
|
1979
|
+
object: match.object,
|
|
1980
|
+
label: match.view?.label ?? match.form?.label,
|
|
1981
|
+
form: safeForm,
|
|
1982
|
+
objectSchema
|
|
1983
|
+
});
|
|
1984
|
+
} catch (error) {
|
|
1985
|
+
logError("[REST] Public form resolve error:", error);
|
|
1986
|
+
res.status(500).json({
|
|
1987
|
+
code: "FORM_RESOLVE_FAILED",
|
|
1988
|
+
error: String(error?.message ?? error ?? "resolve failed").slice(0, 500)
|
|
1989
|
+
});
|
|
1990
|
+
}
|
|
1991
|
+
},
|
|
1992
|
+
metadata: {
|
|
1993
|
+
summary: "Resolve a public form spec by slug (anonymous)",
|
|
1994
|
+
tags: ["forms", "public"]
|
|
1995
|
+
}
|
|
1996
|
+
});
|
|
1997
|
+
this.routeManager.register({
|
|
1998
|
+
method: "POST",
|
|
1999
|
+
path: `${basePath}/forms/:slug/submit`,
|
|
2000
|
+
handler: async (req, res) => {
|
|
2001
|
+
try {
|
|
2002
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2003
|
+
const slug = String(req.params?.slug ?? "").trim();
|
|
2004
|
+
if (!slug) {
|
|
2005
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "slug is required" });
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
const match = await resolveFormBySlug(projectId, req, slug);
|
|
2009
|
+
if (!match) {
|
|
2010
|
+
res.status(404).json({
|
|
2011
|
+
code: "FORM_NOT_FOUND",
|
|
2012
|
+
error: `No public form configured at /forms/${slug}`
|
|
2013
|
+
});
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
const allowedFields = /* @__PURE__ */ new Set();
|
|
2017
|
+
for (const section of match.form?.sections ?? []) {
|
|
2018
|
+
for (const f of section?.fields ?? []) {
|
|
2019
|
+
if (typeof f === "string") allowedFields.add(f);
|
|
2020
|
+
else if (f?.field) allowedFields.add(f.field);
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
const rawBody = req.body && typeof req.body === "object" ? req.body : {};
|
|
2024
|
+
const filteredData = {};
|
|
2025
|
+
if (allowedFields.size > 0) {
|
|
2026
|
+
for (const [k, v] of Object.entries(rawBody)) {
|
|
2027
|
+
if (allowedFields.has(k)) filteredData[k] = v;
|
|
2028
|
+
}
|
|
2029
|
+
} else {
|
|
2030
|
+
Object.assign(filteredData, rawBody);
|
|
2031
|
+
}
|
|
2032
|
+
const context = {
|
|
2033
|
+
permissions: ["guest_portal"],
|
|
2034
|
+
anonymous: true
|
|
2035
|
+
};
|
|
2036
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
2037
|
+
const result = await p.createData({
|
|
2038
|
+
object: match.object,
|
|
2039
|
+
data: filteredData,
|
|
2040
|
+
...projectId ? { projectId } : {},
|
|
2041
|
+
context
|
|
2042
|
+
});
|
|
2043
|
+
res.status(201).json(result);
|
|
2044
|
+
} catch (error) {
|
|
2045
|
+
const mapped = mapDataError(error);
|
|
2046
|
+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") {
|
|
2047
|
+
logError("[REST] Public form submit error:", error);
|
|
2048
|
+
}
|
|
2049
|
+
res.status(mapped.status).json(mapped.body);
|
|
2050
|
+
}
|
|
2051
|
+
},
|
|
2052
|
+
metadata: {
|
|
2053
|
+
summary: "Submit an anonymous public form",
|
|
2054
|
+
tags: ["forms", "public"]
|
|
2055
|
+
}
|
|
2056
|
+
});
|
|
2057
|
+
this.routeManager.register({
|
|
2058
|
+
method: "GET",
|
|
2059
|
+
path: `${basePath}/forms/:slug/lookup/:field`,
|
|
2060
|
+
handler: async (req, res) => {
|
|
2061
|
+
try {
|
|
2062
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2063
|
+
const slug = String(req.params?.slug ?? "").trim();
|
|
2064
|
+
const fieldName = String(req.params?.field ?? "").trim();
|
|
2065
|
+
if (!slug || !fieldName) {
|
|
2066
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "slug and field are required" });
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
const match = await resolveFormBySlug(projectId, req, slug);
|
|
2070
|
+
if (!match) {
|
|
2071
|
+
res.status(404).json({
|
|
2072
|
+
code: "FORM_NOT_FOUND",
|
|
2073
|
+
error: `No public form configured at /forms/${slug}`
|
|
2074
|
+
});
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2077
|
+
let fieldCfg = null;
|
|
2078
|
+
for (const sec of match.form?.sections ?? []) {
|
|
2079
|
+
for (const f of sec?.fields ?? []) {
|
|
2080
|
+
const name = typeof f === "string" ? f : f?.field;
|
|
2081
|
+
if (name === fieldName) {
|
|
2082
|
+
fieldCfg = typeof f === "string" ? {} : f;
|
|
2083
|
+
break;
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
if (fieldCfg) break;
|
|
2087
|
+
}
|
|
2088
|
+
const picker = fieldCfg?.publicPicker;
|
|
2089
|
+
if (!picker) {
|
|
2090
|
+
res.status(403).json({
|
|
2091
|
+
code: "LOOKUP_NOT_PUBLIC",
|
|
2092
|
+
error: `Field "${fieldName}" is not enabled for public lookup on this form`
|
|
2093
|
+
});
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
2097
|
+
let referenceTo = picker.object;
|
|
2098
|
+
if (!referenceTo && typeof p.getMetaItems === "function") {
|
|
2099
|
+
try {
|
|
2100
|
+
const r = await p.getMetaItems({
|
|
2101
|
+
type: "object",
|
|
2102
|
+
...projectId ? { projectId } : {}
|
|
2103
|
+
});
|
|
2104
|
+
const items = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
|
|
2105
|
+
const obj = items.find((o) => o?.name === match.object);
|
|
2106
|
+
const def = obj?.fields?.[fieldName];
|
|
2107
|
+
referenceTo = def?.referenceTo ?? def?.target ?? def?.options?.objectName;
|
|
2108
|
+
} catch {
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
if (!referenceTo) {
|
|
2112
|
+
res.status(500).json({
|
|
2113
|
+
code: "LOOKUP_TARGET_MISSING",
|
|
2114
|
+
error: `Could not resolve referenced object for "${fieldName}"`
|
|
2115
|
+
});
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
const displayFields = Array.isArray(picker.displayFields) && picker.displayFields.length > 0 ? picker.displayFields.slice(0, 5) : ["name"];
|
|
2119
|
+
const hardCap = 50;
|
|
2120
|
+
const maxResults = Math.min(Math.max(1, Number(picker.maxResults) || 20), hardCap);
|
|
2121
|
+
const q = String(req.query?.q ?? "").trim().slice(0, 100);
|
|
2122
|
+
const filters = [];
|
|
2123
|
+
if (Array.isArray(picker.filter)) filters.push(...picker.filter);
|
|
2124
|
+
if (q) filters.push({ field: displayFields[0], operator: "contains", value: q });
|
|
2125
|
+
const context = {
|
|
2126
|
+
permissions: ["guest_portal"],
|
|
2127
|
+
anonymous: true
|
|
2128
|
+
};
|
|
2129
|
+
const result = await p.findData({
|
|
2130
|
+
object: referenceTo,
|
|
2131
|
+
query: {
|
|
2132
|
+
limit: maxResults,
|
|
2133
|
+
offset: 0,
|
|
2134
|
+
filters,
|
|
2135
|
+
select: ["id", ...displayFields],
|
|
2136
|
+
sort: picker.sort ?? [{ field: displayFields[0], order: "asc" }]
|
|
2137
|
+
},
|
|
2138
|
+
...projectId ? { projectId } : {},
|
|
2139
|
+
context
|
|
2140
|
+
});
|
|
2141
|
+
const rows = Array.isArray(result?.data) ? result.data : Array.isArray(result?.items) ? result.items : [];
|
|
2142
|
+
const projected = rows.slice(0, maxResults).map((row) => {
|
|
2143
|
+
const out = { id: row?.id };
|
|
2144
|
+
for (const f of displayFields) {
|
|
2145
|
+
if (row && Object.prototype.hasOwnProperty.call(row, f)) out[f] = row[f];
|
|
2146
|
+
}
|
|
2147
|
+
return out;
|
|
2148
|
+
});
|
|
2149
|
+
res.json({
|
|
2150
|
+
data: projected,
|
|
2151
|
+
total: projected.length,
|
|
2152
|
+
truncated: rows.length >= maxResults,
|
|
2153
|
+
displayFields
|
|
2154
|
+
});
|
|
2155
|
+
} catch (error) {
|
|
2156
|
+
const mapped = mapDataError(error);
|
|
2157
|
+
if (!isExpectedDataStatus(mapped.status)) {
|
|
2158
|
+
logError("[REST] Public form lookup error:", error);
|
|
2159
|
+
}
|
|
2160
|
+
res.status(mapped.status).json(mapped.body);
|
|
2161
|
+
}
|
|
2162
|
+
},
|
|
2163
|
+
metadata: {
|
|
2164
|
+
summary: "Scoped lookup picker for a public form field (anonymous)",
|
|
2165
|
+
tags: ["forms", "public"]
|
|
2166
|
+
}
|
|
2167
|
+
});
|
|
2168
|
+
}
|
|
2169
|
+
/**
|
|
2170
|
+
* Register record-level sharing endpoints (M11.C17).
|
|
2171
|
+
*
|
|
2172
|
+
* Surfaces `ISharingService` over HTTP so the UI can list, create
|
|
2173
|
+
* and revoke per-record grants without going through ObjectQL. The
|
|
2174
|
+
* three routes mirror the share-management drawer in Salesforce /
|
|
2175
|
+
* ServiceNow:
|
|
2176
|
+
*
|
|
2177
|
+
* GET {basePath}/data/:object/:id/shares
|
|
2178
|
+
* POST {basePath}/data/:object/:id/shares
|
|
2179
|
+
* DELETE {basePath}/data/:object/:id/shares/:shareId
|
|
2180
|
+
*
|
|
2181
|
+
* All three resolve via `sharingServiceProvider`; routes return 501
|
|
2182
|
+
* when no sharing service is configured so a deployment without the
|
|
2183
|
+
* `@objectstack/plugin-sharing` plugin fails cleanly.
|
|
2184
|
+
*/
|
|
2185
|
+
registerSharingEndpoints(basePath) {
|
|
2186
|
+
const { crud } = this.config;
|
|
2187
|
+
const dataPath = `${basePath}${crud.dataPrefix}`;
|
|
2188
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
2189
|
+
const resolveService = async (projectId) => {
|
|
2190
|
+
if (!this.sharingServiceProvider) return void 0;
|
|
2191
|
+
try {
|
|
2192
|
+
return await this.sharingServiceProvider(projectId);
|
|
2193
|
+
} catch {
|
|
2194
|
+
return void 0;
|
|
2195
|
+
}
|
|
2196
|
+
};
|
|
2197
|
+
const respond501 = (res) => res.status(501).json({
|
|
2198
|
+
code: "NOT_IMPLEMENTED",
|
|
2199
|
+
message: "Sharing service is not configured on this deployment"
|
|
2200
|
+
});
|
|
2201
|
+
this.routeManager.register({
|
|
2202
|
+
method: "GET",
|
|
2203
|
+
path: `${dataPath}/:object/:id/shares`,
|
|
2204
|
+
handler: async (req, res) => {
|
|
2205
|
+
try {
|
|
2206
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2207
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2208
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2209
|
+
const svc = await resolveService(projectId);
|
|
2210
|
+
if (!svc) return respond501(res);
|
|
2211
|
+
const rows = await svc.listShares(req.params.object, req.params.id, context ?? {});
|
|
2212
|
+
res.json({ data: rows });
|
|
2213
|
+
} catch (error) {
|
|
2214
|
+
logError("[REST] List shares error:", error);
|
|
2215
|
+
res.status(500).json({ code: "SHARES_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2216
|
+
}
|
|
2217
|
+
},
|
|
2218
|
+
metadata: { summary: "List per-record sharing grants", tags: ["sharing"] }
|
|
2219
|
+
});
|
|
2220
|
+
this.routeManager.register({
|
|
2221
|
+
method: "POST",
|
|
2222
|
+
path: `${dataPath}/:object/:id/shares`,
|
|
2223
|
+
handler: async (req, res) => {
|
|
2224
|
+
try {
|
|
2225
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2226
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2227
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2228
|
+
const svc = await resolveService(projectId);
|
|
2229
|
+
if (!svc) return respond501(res);
|
|
2230
|
+
const body = req.body ?? {};
|
|
2231
|
+
const input = {
|
|
2232
|
+
object: req.params.object,
|
|
2233
|
+
recordId: req.params.id,
|
|
2234
|
+
recipientType: body.recipientType ?? body.recipient_type,
|
|
2235
|
+
recipientId: body.recipientId ?? body.recipient_id,
|
|
2236
|
+
accessLevel: body.accessLevel ?? body.access_level,
|
|
2237
|
+
source: body.source,
|
|
2238
|
+
sourceId: body.sourceId ?? body.source_id,
|
|
2239
|
+
reason: body.reason
|
|
2240
|
+
};
|
|
2241
|
+
try {
|
|
2242
|
+
const row = await svc.grant(input, context ?? {});
|
|
2243
|
+
res.status(201).json(row);
|
|
2244
|
+
} catch (err) {
|
|
2245
|
+
const msg = String(err?.message ?? err ?? "");
|
|
2246
|
+
if (msg.startsWith("VALIDATION_FAILED")) {
|
|
2247
|
+
res.status(400).json({
|
|
2248
|
+
code: "VALIDATION_FAILED",
|
|
2249
|
+
error: msg.replace(/^VALIDATION_FAILED:\s*/, "")
|
|
2250
|
+
});
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
throw err;
|
|
2254
|
+
}
|
|
2255
|
+
} catch (error) {
|
|
2256
|
+
logError("[REST] Grant share error:", error);
|
|
2257
|
+
res.status(500).json({ code: "SHARE_GRANT_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2258
|
+
}
|
|
2259
|
+
},
|
|
2260
|
+
metadata: { summary: "Grant a per-record share to a principal", tags: ["sharing"] }
|
|
2261
|
+
});
|
|
2262
|
+
this.routeManager.register({
|
|
2263
|
+
method: "DELETE",
|
|
2264
|
+
path: `${dataPath}/:object/:id/shares/:shareId`,
|
|
2265
|
+
handler: async (req, res) => {
|
|
2266
|
+
try {
|
|
2267
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2268
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2269
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2270
|
+
const svc = await resolveService(projectId);
|
|
2271
|
+
if (!svc) return respond501(res);
|
|
2272
|
+
await svc.revoke(req.params.shareId, context ?? {});
|
|
2273
|
+
res.status(204).end();
|
|
2274
|
+
} catch (error) {
|
|
2275
|
+
logError("[REST] Revoke share error:", error);
|
|
2276
|
+
res.status(500).json({ code: "SHARE_REVOKE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2277
|
+
}
|
|
2278
|
+
},
|
|
2279
|
+
metadata: { summary: "Revoke a per-record share by id", tags: ["sharing"] }
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
/**
|
|
2283
|
+
* Register sharing-rule endpoints (M10.17). Mirrors the existing
|
|
2284
|
+
* sharing endpoints but operates on `sys_sharing_rule` rows.
|
|
2285
|
+
*
|
|
2286
|
+
* GET {basePath}/sharing/rules?object=&activeOnly=
|
|
2287
|
+
* POST {basePath}/sharing/rules
|
|
2288
|
+
* GET {basePath}/sharing/rules/:idOrName
|
|
2289
|
+
* DELETE {basePath}/sharing/rules/:idOrName
|
|
2290
|
+
* POST {basePath}/sharing/rules/:idOrName/evaluate
|
|
2291
|
+
*
|
|
2292
|
+
* Returns 501 when no sharing-rule service is configured.
|
|
2293
|
+
*/
|
|
2294
|
+
registerSharingRuleEndpoints(basePath) {
|
|
2295
|
+
const dataPath = basePath;
|
|
2296
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
2297
|
+
const resolveService = async (projectId) => {
|
|
2298
|
+
if (!this.sharingRulesServiceProvider) return void 0;
|
|
2299
|
+
try {
|
|
2300
|
+
return await this.sharingRulesServiceProvider(projectId);
|
|
2301
|
+
} catch {
|
|
2302
|
+
return void 0;
|
|
2303
|
+
}
|
|
2304
|
+
};
|
|
2305
|
+
const respond501 = (res) => res.status(501).json({
|
|
2306
|
+
code: "NOT_IMPLEMENTED",
|
|
2307
|
+
message: "Sharing-rule service is not configured on this deployment"
|
|
2308
|
+
});
|
|
2309
|
+
const handleError = (err, res, defaultCode) => {
|
|
2310
|
+
const msg = String(err?.message ?? err ?? "");
|
|
2311
|
+
if (msg.startsWith("VALIDATION_FAILED")) {
|
|
2312
|
+
return res.status(400).json({ code: "VALIDATION_FAILED", error: msg.replace(/^VALIDATION_FAILED:\s*/, "") });
|
|
2313
|
+
}
|
|
2314
|
+
if (msg.startsWith("RULE_NOT_FOUND")) {
|
|
2315
|
+
return res.status(404).json({ code: "RULE_NOT_FOUND", error: msg.replace(/^RULE_NOT_FOUND:?\s*/, "") });
|
|
2316
|
+
}
|
|
2317
|
+
logError(`[REST] sharing-rule ${defaultCode}:`, err);
|
|
2318
|
+
return res.status(500).json({ code: defaultCode, error: msg.slice(0, 500) });
|
|
2319
|
+
};
|
|
2320
|
+
this.routeManager.register({
|
|
2321
|
+
method: "GET",
|
|
2322
|
+
path: `${dataPath}/sharing/rules`,
|
|
2323
|
+
handler: async (req, res) => {
|
|
2324
|
+
try {
|
|
2325
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2326
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2327
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2328
|
+
const svc = await resolveService(projectId);
|
|
2329
|
+
if (!svc) return respond501(res);
|
|
2330
|
+
const rows = await svc.listRules({
|
|
2331
|
+
object: req.query?.object,
|
|
2332
|
+
activeOnly: req.query?.activeOnly === "true" || req.query?.activeOnly === true
|
|
2333
|
+
}, context ?? {});
|
|
2334
|
+
res.json({ data: rows });
|
|
2335
|
+
} catch (err) {
|
|
2336
|
+
handleError(err, res, "RULE_LIST_FAILED");
|
|
2337
|
+
}
|
|
2338
|
+
},
|
|
2339
|
+
metadata: { summary: "List sharing rules", tags: ["sharing"] }
|
|
2340
|
+
});
|
|
2341
|
+
this.routeManager.register({
|
|
2342
|
+
method: "POST",
|
|
2343
|
+
path: `${dataPath}/sharing/rules`,
|
|
2344
|
+
handler: async (req, res) => {
|
|
2345
|
+
try {
|
|
2346
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2347
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2348
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2349
|
+
const svc = await resolveService(projectId);
|
|
2350
|
+
if (!svc) return respond501(res);
|
|
2351
|
+
const body = req.body ?? {};
|
|
2352
|
+
const input = {
|
|
2353
|
+
name: body.name,
|
|
2354
|
+
label: body.label,
|
|
2355
|
+
description: body.description,
|
|
2356
|
+
object: body.object ?? body.object_name,
|
|
2357
|
+
criteria: body.criteria,
|
|
2358
|
+
recipientType: body.recipientType ?? body.recipient_type,
|
|
2359
|
+
recipientId: body.recipientId ?? body.recipient_id,
|
|
2360
|
+
accessLevel: body.accessLevel ?? body.access_level,
|
|
2361
|
+
active: body.active
|
|
2362
|
+
};
|
|
2363
|
+
const row = await svc.defineRule(input, context ?? {});
|
|
2364
|
+
res.status(201).json(row);
|
|
2365
|
+
} catch (err) {
|
|
2366
|
+
handleError(err, res, "RULE_DEFINE_FAILED");
|
|
2367
|
+
}
|
|
2368
|
+
},
|
|
2369
|
+
metadata: { summary: "Create or upsert a sharing rule", tags: ["sharing"] }
|
|
2370
|
+
});
|
|
2371
|
+
this.routeManager.register({
|
|
2372
|
+
method: "GET",
|
|
2373
|
+
path: `${dataPath}/sharing/rules/:idOrName`,
|
|
2374
|
+
handler: async (req, res) => {
|
|
2375
|
+
try {
|
|
2376
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2377
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2378
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2379
|
+
const svc = await resolveService(projectId);
|
|
2380
|
+
if (!svc) return respond501(res);
|
|
2381
|
+
const row = await svc.getRule(req.params.idOrName, context ?? {});
|
|
2382
|
+
if (!row) return res.status(404).json({ code: "RULE_NOT_FOUND" });
|
|
2383
|
+
res.json(row);
|
|
2384
|
+
} catch (err) {
|
|
2385
|
+
handleError(err, res, "RULE_GET_FAILED");
|
|
2386
|
+
}
|
|
2387
|
+
},
|
|
2388
|
+
metadata: { summary: "Get a sharing rule by id or name", tags: ["sharing"] }
|
|
2389
|
+
});
|
|
2390
|
+
this.routeManager.register({
|
|
2391
|
+
method: "DELETE",
|
|
2392
|
+
path: `${dataPath}/sharing/rules/:idOrName`,
|
|
2393
|
+
handler: async (req, res) => {
|
|
2394
|
+
try {
|
|
2395
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2396
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2397
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2398
|
+
const svc = await resolveService(projectId);
|
|
2399
|
+
if (!svc) return respond501(res);
|
|
2400
|
+
await svc.deleteRule(req.params.idOrName, context ?? {});
|
|
2401
|
+
res.status(204).end();
|
|
2402
|
+
} catch (err) {
|
|
2403
|
+
handleError(err, res, "RULE_DELETE_FAILED");
|
|
2404
|
+
}
|
|
2405
|
+
},
|
|
2406
|
+
metadata: { summary: "Delete a sharing rule and its materialised grants", tags: ["sharing"] }
|
|
2407
|
+
});
|
|
2408
|
+
this.routeManager.register({
|
|
2409
|
+
method: "POST",
|
|
2410
|
+
path: `${dataPath}/sharing/rules/:idOrName/evaluate`,
|
|
2411
|
+
handler: async (req, res) => {
|
|
2412
|
+
try {
|
|
2413
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2414
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2415
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2416
|
+
const svc = await resolveService(projectId);
|
|
2417
|
+
if (!svc) return respond501(res);
|
|
2418
|
+
const result = await svc.evaluateRule(req.params.idOrName, context ?? {});
|
|
2419
|
+
res.json(result);
|
|
2420
|
+
} catch (err) {
|
|
2421
|
+
handleError(err, res, "RULE_EVALUATE_FAILED");
|
|
2422
|
+
}
|
|
2423
|
+
},
|
|
2424
|
+
metadata: { summary: "Re-evaluate a sharing rule and reconcile grants", tags: ["sharing"] }
|
|
2425
|
+
});
|
|
2426
|
+
}
|
|
2427
|
+
/**
|
|
2428
|
+
* Register saved-report + scheduled-digest endpoints (M11.C16).
|
|
2429
|
+
*
|
|
2430
|
+
* Surfaces `IReportService` over HTTP so the UI can build,
|
|
2431
|
+
* run, and schedule reports without dropping to ObjectQL. Routes
|
|
2432
|
+
* live at the top of the API surface (alongside `/approvals` and
|
|
2433
|
+
* `/sharing`) — reports are a tenant-wide capability, not a record
|
|
2434
|
+
* on a specific CRUD object:
|
|
2435
|
+
*
|
|
2436
|
+
* GET {basePath}/reports?object=&ownerId=
|
|
2437
|
+
* POST {basePath}/reports
|
|
2438
|
+
* GET {basePath}/reports/:id
|
|
2439
|
+
* DELETE {basePath}/reports/:id
|
|
2440
|
+
* POST {basePath}/reports/:id/run
|
|
2441
|
+
* POST {basePath}/reports/:id/schedule
|
|
2442
|
+
* GET {basePath}/reports/:id/schedules
|
|
2443
|
+
* DELETE {basePath}/reports/schedules/:scheduleId
|
|
2444
|
+
*
|
|
2445
|
+
* All routes return 501 when `reportsServiceProvider` is unset so
|
|
2446
|
+
* a deployment without `@objectstack/plugin-reports` fails cleanly.
|
|
2447
|
+
*/
|
|
2448
|
+
registerReportsEndpoints(basePath) {
|
|
2449
|
+
const dataPath = basePath;
|
|
2450
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
2451
|
+
const resolveService = async (projectId) => {
|
|
2452
|
+
if (!this.reportsServiceProvider) return void 0;
|
|
2453
|
+
try {
|
|
2454
|
+
return await this.reportsServiceProvider(projectId);
|
|
2455
|
+
} catch {
|
|
2456
|
+
return void 0;
|
|
2457
|
+
}
|
|
2458
|
+
};
|
|
2459
|
+
const respond501 = (res) => res.status(501).json({
|
|
2460
|
+
code: "NOT_IMPLEMENTED",
|
|
2461
|
+
message: "Reports service is not configured on this deployment"
|
|
2462
|
+
});
|
|
2463
|
+
const handleValidation = (res, err) => {
|
|
2464
|
+
const msg = String(err?.message ?? err ?? "");
|
|
2465
|
+
if (msg.startsWith("VALIDATION_FAILED")) {
|
|
2466
|
+
res.status(400).json({
|
|
2467
|
+
code: "VALIDATION_FAILED",
|
|
2468
|
+
error: msg.replace(/^VALIDATION_FAILED:\s*/, "")
|
|
2469
|
+
});
|
|
2470
|
+
return true;
|
|
2471
|
+
}
|
|
2472
|
+
if (msg.startsWith("REPORT_NOT_FOUND")) {
|
|
2473
|
+
res.status(404).json({ code: "REPORT_NOT_FOUND", error: msg });
|
|
2474
|
+
return true;
|
|
2475
|
+
}
|
|
2476
|
+
return false;
|
|
2477
|
+
};
|
|
2478
|
+
this.routeManager.register({
|
|
2479
|
+
method: "GET",
|
|
2480
|
+
path: `${dataPath}/reports`,
|
|
2481
|
+
handler: async (req, res) => {
|
|
2482
|
+
try {
|
|
2483
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2484
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2485
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2486
|
+
const svc = await resolveService(projectId);
|
|
2487
|
+
if (!svc) return respond501(res);
|
|
2488
|
+
const q = req.query ?? {};
|
|
2489
|
+
const rows = await svc.listReports({ object: q.object, ownerId: q.ownerId }, context ?? {});
|
|
2490
|
+
res.json({ data: rows });
|
|
2491
|
+
} catch (error) {
|
|
2492
|
+
logError("[REST] List reports error:", error);
|
|
2493
|
+
res.status(500).json({ code: "REPORTS_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2494
|
+
}
|
|
2495
|
+
},
|
|
2496
|
+
metadata: { summary: "List saved reports", tags: ["reports"] }
|
|
2497
|
+
});
|
|
2498
|
+
this.routeManager.register({
|
|
2499
|
+
method: "POST",
|
|
2500
|
+
path: `${dataPath}/reports`,
|
|
2501
|
+
handler: async (req, res) => {
|
|
2502
|
+
try {
|
|
2503
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2504
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2505
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2506
|
+
const svc = await resolveService(projectId);
|
|
2507
|
+
if (!svc) return respond501(res);
|
|
2508
|
+
try {
|
|
2509
|
+
const row = await svc.saveReport(req.body ?? {}, context ?? {});
|
|
2510
|
+
res.status(201).json(row);
|
|
2511
|
+
} catch (err) {
|
|
2512
|
+
if (handleValidation(res, err)) return;
|
|
2513
|
+
throw err;
|
|
2514
|
+
}
|
|
2515
|
+
} catch (error) {
|
|
2516
|
+
logError("[REST] Save report error:", error);
|
|
2517
|
+
res.status(500).json({ code: "REPORT_SAVE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2518
|
+
}
|
|
2519
|
+
},
|
|
2520
|
+
metadata: { summary: "Create or update a saved report", tags: ["reports"] }
|
|
2521
|
+
});
|
|
2522
|
+
this.routeManager.register({
|
|
2523
|
+
method: "GET",
|
|
2524
|
+
path: `${dataPath}/reports/:id`,
|
|
2525
|
+
handler: async (req, res) => {
|
|
2526
|
+
try {
|
|
2527
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2528
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2529
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2530
|
+
const svc = await resolveService(projectId);
|
|
2531
|
+
if (!svc) return respond501(res);
|
|
2532
|
+
const row = await svc.getReport(req.params.id, context ?? {});
|
|
2533
|
+
if (!row) {
|
|
2534
|
+
res.status(404).json({ code: "REPORT_NOT_FOUND", error: `Report ${req.params.id} not found` });
|
|
2535
|
+
return;
|
|
2536
|
+
}
|
|
2537
|
+
res.json(row);
|
|
2538
|
+
} catch (error) {
|
|
2539
|
+
logError("[REST] Get report error:", error);
|
|
2540
|
+
res.status(500).json({ code: "REPORT_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2541
|
+
}
|
|
2542
|
+
},
|
|
2543
|
+
metadata: { summary: "Get a saved report by id", tags: ["reports"] }
|
|
2544
|
+
});
|
|
2545
|
+
this.routeManager.register({
|
|
2546
|
+
method: "DELETE",
|
|
2547
|
+
path: `${dataPath}/reports/:id`,
|
|
2548
|
+
handler: async (req, res) => {
|
|
2549
|
+
try {
|
|
2550
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2551
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2552
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2553
|
+
const svc = await resolveService(projectId);
|
|
2554
|
+
if (!svc) return respond501(res);
|
|
2555
|
+
await svc.deleteReport(req.params.id, context ?? {});
|
|
2556
|
+
res.status(204).end();
|
|
2557
|
+
} catch (error) {
|
|
2558
|
+
logError("[REST] Delete report error:", error);
|
|
2559
|
+
res.status(500).json({ code: "REPORT_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2560
|
+
}
|
|
2561
|
+
},
|
|
2562
|
+
metadata: { summary: "Delete a saved report (cascades schedules)", tags: ["reports"] }
|
|
2563
|
+
});
|
|
2564
|
+
this.routeManager.register({
|
|
2565
|
+
method: "POST",
|
|
2566
|
+
path: `${dataPath}/reports/:id/run`,
|
|
2567
|
+
handler: async (req, res) => {
|
|
2568
|
+
try {
|
|
2569
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2570
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2571
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2572
|
+
const svc = await resolveService(projectId);
|
|
2573
|
+
if (!svc) return respond501(res);
|
|
2574
|
+
try {
|
|
2575
|
+
const result = await svc.run(req.params.id, context ?? {});
|
|
2576
|
+
res.json(result);
|
|
2577
|
+
} catch (err) {
|
|
2578
|
+
if (handleValidation(res, err)) return;
|
|
2579
|
+
throw err;
|
|
2580
|
+
}
|
|
2581
|
+
} catch (error) {
|
|
2582
|
+
logError("[REST] Run report error:", error);
|
|
2583
|
+
res.status(500).json({ code: "REPORT_RUN_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2584
|
+
}
|
|
2585
|
+
},
|
|
2586
|
+
metadata: { summary: "Execute a saved report and return rendered output", tags: ["reports"] }
|
|
2587
|
+
});
|
|
2588
|
+
this.routeManager.register({
|
|
2589
|
+
method: "POST",
|
|
2590
|
+
path: `${dataPath}/reports/:id/schedule`,
|
|
2591
|
+
handler: async (req, res) => {
|
|
2592
|
+
try {
|
|
2593
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2594
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2595
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2596
|
+
const svc = await resolveService(projectId);
|
|
2597
|
+
if (!svc) return respond501(res);
|
|
2598
|
+
const body = req.body ?? {};
|
|
2599
|
+
try {
|
|
2600
|
+
const row = await svc.scheduleReport({
|
|
2601
|
+
reportId: req.params.id,
|
|
2602
|
+
recipients: body.recipients ?? [],
|
|
2603
|
+
name: body.name,
|
|
2604
|
+
intervalMinutes: body.intervalMinutes ?? body.interval_minutes,
|
|
2605
|
+
cronExpression: body.cronExpression ?? body.cron_expression,
|
|
2606
|
+
timezone: body.timezone,
|
|
2607
|
+
format: body.format,
|
|
2608
|
+
subjectTemplate: body.subjectTemplate ?? body.subject_template,
|
|
2609
|
+
ownerId: body.ownerId ?? body.owner_id,
|
|
2610
|
+
active: body.active
|
|
2611
|
+
}, context ?? {});
|
|
2612
|
+
res.status(201).json(row);
|
|
2613
|
+
} catch (err) {
|
|
2614
|
+
if (handleValidation(res, err)) return;
|
|
2615
|
+
throw err;
|
|
2616
|
+
}
|
|
2617
|
+
} catch (error) {
|
|
2618
|
+
logError("[REST] Schedule report error:", error);
|
|
2619
|
+
res.status(500).json({ code: "REPORT_SCHEDULE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2620
|
+
}
|
|
2621
|
+
},
|
|
2622
|
+
metadata: { summary: "Create a recurring email schedule for a report", tags: ["reports"] }
|
|
2623
|
+
});
|
|
2624
|
+
this.routeManager.register({
|
|
2625
|
+
method: "GET",
|
|
2626
|
+
path: `${dataPath}/reports/:id/schedules`,
|
|
2627
|
+
handler: async (req, res) => {
|
|
2628
|
+
try {
|
|
2629
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2630
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2631
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2632
|
+
const svc = await resolveService(projectId);
|
|
2633
|
+
if (!svc) return respond501(res);
|
|
2634
|
+
const rows = await svc.listSchedules({ reportId: req.params.id }, context ?? {});
|
|
2635
|
+
res.json({ data: rows });
|
|
2636
|
+
} catch (error) {
|
|
2637
|
+
logError("[REST] List schedules error:", error);
|
|
2638
|
+
res.status(500).json({ code: "SCHEDULES_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2639
|
+
}
|
|
2640
|
+
},
|
|
2641
|
+
metadata: { summary: "List schedules for a report", tags: ["reports"] }
|
|
2642
|
+
});
|
|
2643
|
+
this.routeManager.register({
|
|
2644
|
+
method: "DELETE",
|
|
2645
|
+
path: `${dataPath}/reports/schedules/:scheduleId`,
|
|
2646
|
+
handler: async (req, res) => {
|
|
2647
|
+
try {
|
|
2648
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2649
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2650
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2651
|
+
const svc = await resolveService(projectId);
|
|
2652
|
+
if (!svc) return respond501(res);
|
|
2653
|
+
await svc.unscheduleReport(req.params.scheduleId, context ?? {});
|
|
2654
|
+
res.status(204).end();
|
|
2655
|
+
} catch (error) {
|
|
2656
|
+
logError("[REST] Unschedule report error:", error);
|
|
2657
|
+
res.status(500).json({ code: "SCHEDULE_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2658
|
+
}
|
|
2659
|
+
},
|
|
2660
|
+
metadata: { summary: "Delete a report schedule by id", tags: ["reports"] }
|
|
2661
|
+
});
|
|
2662
|
+
}
|
|
2663
|
+
/**
|
|
2664
|
+
* Register approval engine endpoints.
|
|
2665
|
+
*
|
|
2666
|
+
* Routes (all under {basePath}/approvals):
|
|
2667
|
+
* GET /processes — list approval processes
|
|
2668
|
+
* POST /processes — upsert (defineProcess)
|
|
2669
|
+
* GET /processes/:id — get by id or name
|
|
2670
|
+
* DELETE /processes/:id — delete process
|
|
2671
|
+
* POST /requests — submit
|
|
2672
|
+
* GET /requests — list (filters: status, object, recordId, approverId, submitterId)
|
|
2673
|
+
* GET /requests/:id — get request
|
|
2674
|
+
* POST /requests/:id/approve — approve current step
|
|
2675
|
+
* POST /requests/:id/reject — reject current step
|
|
2676
|
+
* POST /requests/:id/recall — recall (submitter only)
|
|
2677
|
+
* GET /requests/:id/actions — audit trail
|
|
2678
|
+
*
|
|
2679
|
+
* Returns 501 when `approvalsServiceProvider` is unset so deployments
|
|
2680
|
+
* without `@objectstack/plugin-approvals` fail cleanly.
|
|
2681
|
+
*/
|
|
2682
|
+
registerApprovalsEndpoints(basePath) {
|
|
2683
|
+
const dataPath = basePath;
|
|
2684
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
2685
|
+
const resolveService = async (projectId) => {
|
|
2686
|
+
if (!this.approvalsServiceProvider) return void 0;
|
|
2687
|
+
try {
|
|
2688
|
+
return await this.approvalsServiceProvider(projectId);
|
|
2689
|
+
} catch {
|
|
2690
|
+
return void 0;
|
|
2691
|
+
}
|
|
2692
|
+
};
|
|
2693
|
+
const respond501 = (res) => res.status(501).json({
|
|
2694
|
+
code: "NOT_IMPLEMENTED",
|
|
2695
|
+
message: "Approvals service is not configured on this deployment"
|
|
2696
|
+
});
|
|
2697
|
+
const handleApprovalError = (res, err) => {
|
|
2698
|
+
const msg = String(err?.message ?? err ?? "");
|
|
2699
|
+
const mapping = [
|
|
2700
|
+
[/^VALIDATION_FAILED/, 400, "VALIDATION_FAILED"],
|
|
2701
|
+
[/^DUPLICATE_REQUEST/, 409, "DUPLICATE_REQUEST"],
|
|
2702
|
+
[/^INVALID_STATE/, 409, "INVALID_STATE"],
|
|
2703
|
+
[/^FORBIDDEN/, 403, "FORBIDDEN"],
|
|
2704
|
+
[/^NO_ACTIVE_PROCESS/, 404, "NO_ACTIVE_PROCESS"],
|
|
2705
|
+
[/^PROCESS_NOT_FOUND/, 404, "PROCESS_NOT_FOUND"],
|
|
2706
|
+
[/^REQUEST_NOT_FOUND/, 404, "REQUEST_NOT_FOUND"]
|
|
2707
|
+
];
|
|
2708
|
+
for (const [re, status, code] of mapping) {
|
|
2709
|
+
if (re.test(msg)) {
|
|
2710
|
+
res.status(status).json({ code, error: msg.replace(/^[A-Z_]+:\s*/, "") });
|
|
2711
|
+
return true;
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
return false;
|
|
2715
|
+
};
|
|
2716
|
+
this.routeManager.register({
|
|
2717
|
+
method: "GET",
|
|
2718
|
+
path: `${dataPath}/approvals/processes`,
|
|
2719
|
+
handler: async (req, res) => {
|
|
2720
|
+
try {
|
|
2721
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2722
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2723
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2724
|
+
const svc = await resolveService(projectId);
|
|
2725
|
+
if (!svc) return respond501(res);
|
|
2726
|
+
const q = req.query ?? {};
|
|
2727
|
+
const rows = await svc.listProcesses({
|
|
2728
|
+
object: q.object,
|
|
2729
|
+
activeOnly: q.activeOnly === "true" || q.activeOnly === true
|
|
2730
|
+
}, context ?? {});
|
|
2731
|
+
res.json({ data: rows });
|
|
2732
|
+
} catch (error) {
|
|
2733
|
+
logError("[REST] List approval processes error:", error);
|
|
2734
|
+
res.status(500).json({ code: "APPROVAL_PROCESS_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2735
|
+
}
|
|
2736
|
+
},
|
|
2737
|
+
metadata: { summary: "List approval processes", tags: ["approvals"] }
|
|
2738
|
+
});
|
|
2739
|
+
this.routeManager.register({
|
|
2740
|
+
method: "POST",
|
|
2741
|
+
path: `${dataPath}/approvals/processes`,
|
|
2742
|
+
handler: async (req, res) => {
|
|
2743
|
+
try {
|
|
2744
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2745
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2746
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2747
|
+
const svc = await resolveService(projectId);
|
|
2748
|
+
if (!svc) return respond501(res);
|
|
2749
|
+
try {
|
|
2750
|
+
const row = await svc.defineProcess(req.body ?? {}, context ?? {});
|
|
2751
|
+
res.status(201).json(row);
|
|
2752
|
+
} catch (err) {
|
|
2753
|
+
if (handleApprovalError(res, err)) return;
|
|
2754
|
+
throw err;
|
|
2755
|
+
}
|
|
2756
|
+
} catch (error) {
|
|
2757
|
+
logError("[REST] Define approval process error:", error);
|
|
2758
|
+
res.status(500).json({ code: "APPROVAL_PROCESS_DEFINE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2759
|
+
}
|
|
2760
|
+
},
|
|
2761
|
+
metadata: { summary: "Define (upsert) an approval process", tags: ["approvals"] }
|
|
2762
|
+
});
|
|
2763
|
+
this.routeManager.register({
|
|
2764
|
+
method: "GET",
|
|
2765
|
+
path: `${dataPath}/approvals/processes/:id`,
|
|
2766
|
+
handler: async (req, res) => {
|
|
2767
|
+
try {
|
|
2768
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2769
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2770
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2771
|
+
const svc = await resolveService(projectId);
|
|
2772
|
+
if (!svc) return respond501(res);
|
|
2773
|
+
const row = await svc.getProcess(req.params.id, context ?? {});
|
|
2774
|
+
if (!row) {
|
|
2775
|
+
res.status(404).json({ code: "PROCESS_NOT_FOUND", error: `Approval process '${req.params.id}' not found` });
|
|
2776
|
+
return;
|
|
2777
|
+
}
|
|
2778
|
+
res.json(row);
|
|
2779
|
+
} catch (error) {
|
|
2780
|
+
logError("[REST] Get approval process error:", error);
|
|
2781
|
+
res.status(500).json({ code: "APPROVAL_PROCESS_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2782
|
+
}
|
|
2783
|
+
},
|
|
2784
|
+
metadata: { summary: "Get an approval process by id or name", tags: ["approvals"] }
|
|
2785
|
+
});
|
|
2786
|
+
this.routeManager.register({
|
|
2787
|
+
method: "DELETE",
|
|
2788
|
+
path: `${dataPath}/approvals/processes/:id`,
|
|
2789
|
+
handler: async (req, res) => {
|
|
2790
|
+
try {
|
|
2791
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2792
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2793
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2794
|
+
const svc = await resolveService(projectId);
|
|
2795
|
+
if (!svc) return respond501(res);
|
|
2796
|
+
await svc.deleteProcess(req.params.id, context ?? {});
|
|
2797
|
+
res.status(204).end();
|
|
2798
|
+
} catch (error) {
|
|
2799
|
+
logError("[REST] Delete approval process error:", error);
|
|
2800
|
+
res.status(500).json({ code: "APPROVAL_PROCESS_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2801
|
+
}
|
|
2802
|
+
},
|
|
2803
|
+
metadata: { summary: "Delete an approval process", tags: ["approvals"] }
|
|
2804
|
+
});
|
|
2805
|
+
this.routeManager.register({
|
|
2806
|
+
method: "POST",
|
|
2807
|
+
path: `${dataPath}/approvals/requests`,
|
|
2808
|
+
handler: async (req, res) => {
|
|
2809
|
+
try {
|
|
2810
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2811
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2812
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2813
|
+
const svc = await resolveService(projectId);
|
|
2814
|
+
if (!svc) return respond501(res);
|
|
2815
|
+
const body = req.body ?? {};
|
|
2816
|
+
try {
|
|
2817
|
+
const row = await svc.submit({
|
|
2818
|
+
object: body.object,
|
|
2819
|
+
recordId: body.recordId ?? body.record_id,
|
|
2820
|
+
processName: body.processName ?? body.process_name,
|
|
2821
|
+
submitterId: body.submitterId ?? body.submitter_id ?? context?.userId,
|
|
2822
|
+
comment: body.comment,
|
|
2823
|
+
payload: body.payload
|
|
2824
|
+
}, context ?? {});
|
|
2825
|
+
res.status(201).json(row);
|
|
2826
|
+
} catch (err) {
|
|
2827
|
+
if (handleApprovalError(res, err)) return;
|
|
2828
|
+
throw err;
|
|
2829
|
+
}
|
|
2830
|
+
} catch (error) {
|
|
2831
|
+
logError("[REST] Submit approval error:", error);
|
|
2832
|
+
res.status(500).json({ code: "APPROVAL_SUBMIT_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2833
|
+
}
|
|
2834
|
+
},
|
|
2835
|
+
metadata: { summary: "Submit a record for approval", tags: ["approvals"] }
|
|
2836
|
+
});
|
|
2837
|
+
this.routeManager.register({
|
|
2838
|
+
method: "GET",
|
|
2839
|
+
path: `${dataPath}/approvals/requests`,
|
|
2840
|
+
handler: async (req, res) => {
|
|
2841
|
+
try {
|
|
2842
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2843
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2844
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2845
|
+
const svc = await resolveService(projectId);
|
|
2846
|
+
if (!svc) {
|
|
2847
|
+
res.json({ data: [] });
|
|
2848
|
+
return;
|
|
2849
|
+
}
|
|
2850
|
+
const q = req.query ?? {};
|
|
2851
|
+
const rows = await svc.listRequests({
|
|
2852
|
+
object: q.object,
|
|
2853
|
+
recordId: q.recordId ?? q.record_id,
|
|
2854
|
+
status: q.status,
|
|
2855
|
+
approverId: q.approverId ?? q.approver_id,
|
|
2856
|
+
submitterId: q.submitterId ?? q.submitter_id
|
|
2857
|
+
}, context ?? {});
|
|
2858
|
+
res.json({ data: rows });
|
|
2859
|
+
} catch (error) {
|
|
2860
|
+
logError("[REST] List approval requests error:", error);
|
|
2861
|
+
res.status(500).json({ code: "APPROVAL_REQUEST_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2862
|
+
}
|
|
2863
|
+
},
|
|
2864
|
+
metadata: { summary: "List approval requests", tags: ["approvals"] }
|
|
2865
|
+
});
|
|
2866
|
+
this.routeManager.register({
|
|
2867
|
+
method: "GET",
|
|
2868
|
+
path: `${dataPath}/approvals/requests/:id`,
|
|
2869
|
+
handler: async (req, res) => {
|
|
2870
|
+
try {
|
|
2871
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2872
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2873
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2874
|
+
const svc = await resolveService(projectId);
|
|
2875
|
+
if (!svc) return respond501(res);
|
|
2876
|
+
const row = await svc.getRequest(req.params.id, context ?? {});
|
|
2877
|
+
if (!row) {
|
|
2878
|
+
res.status(404).json({ code: "REQUEST_NOT_FOUND", error: `Approval request '${req.params.id}' not found` });
|
|
2879
|
+
return;
|
|
2880
|
+
}
|
|
2881
|
+
res.json(row);
|
|
2882
|
+
} catch (error) {
|
|
2883
|
+
logError("[REST] Get approval request error:", error);
|
|
2884
|
+
res.status(500).json({ code: "APPROVAL_REQUEST_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2885
|
+
}
|
|
2886
|
+
},
|
|
2887
|
+
metadata: { summary: "Get an approval request by id", tags: ["approvals"] }
|
|
2888
|
+
});
|
|
2889
|
+
const decisionRoute = (suffix, method) => {
|
|
2890
|
+
this.routeManager.register({
|
|
2891
|
+
method: "POST",
|
|
2892
|
+
path: `${dataPath}/approvals/requests/:id/${suffix}`,
|
|
2893
|
+
handler: async (req, res) => {
|
|
2894
|
+
try {
|
|
2895
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2896
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2897
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2898
|
+
const svc = await resolveService(projectId);
|
|
2899
|
+
if (!svc) return respond501(res);
|
|
2900
|
+
const body = req.body ?? {};
|
|
2901
|
+
try {
|
|
2902
|
+
const out = await svc[method](req.params.id, {
|
|
2903
|
+
actorId: body.actorId ?? body.actor_id ?? context?.userId,
|
|
2904
|
+
comment: body.comment
|
|
2905
|
+
}, context ?? {});
|
|
2906
|
+
res.json(out);
|
|
2907
|
+
} catch (err) {
|
|
2908
|
+
if (handleApprovalError(res, err)) return;
|
|
2909
|
+
throw err;
|
|
2910
|
+
}
|
|
2911
|
+
} catch (error) {
|
|
2912
|
+
logError(`[REST] ${suffix} approval error:`, error);
|
|
2913
|
+
res.status(500).json({ code: `APPROVAL_${suffix.toUpperCase()}_FAILED`, error: String(error?.message ?? error).slice(0, 500) });
|
|
2914
|
+
}
|
|
2915
|
+
},
|
|
2916
|
+
metadata: { summary: `${suffix[0].toUpperCase()}${suffix.slice(1)} an approval request`, tags: ["approvals"] }
|
|
2917
|
+
});
|
|
2918
|
+
};
|
|
2919
|
+
decisionRoute("approve", "approve");
|
|
2920
|
+
decisionRoute("reject", "reject");
|
|
2921
|
+
decisionRoute("recall", "recall");
|
|
2922
|
+
this.routeManager.register({
|
|
2923
|
+
method: "GET",
|
|
2924
|
+
path: `${dataPath}/approvals/requests/:id/actions`,
|
|
2925
|
+
handler: async (req, res) => {
|
|
2926
|
+
try {
|
|
2927
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2928
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2929
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2930
|
+
const svc = await resolveService(projectId);
|
|
2931
|
+
if (!svc) return respond501(res);
|
|
2932
|
+
const rows = await svc.listActions(req.params.id, context ?? {});
|
|
2933
|
+
res.json({ data: rows });
|
|
2934
|
+
} catch (error) {
|
|
2935
|
+
logError("[REST] List approval actions error:", error);
|
|
2936
|
+
res.status(500).json({ code: "APPROVAL_ACTIONS_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2937
|
+
}
|
|
2938
|
+
},
|
|
2939
|
+
metadata: { summary: "List actions (audit trail) for an approval request", tags: ["approvals"] }
|
|
2940
|
+
});
|
|
2941
|
+
}
|
|
2942
|
+
/**
|
|
2943
|
+
* Register batch operation endpoints
|
|
2944
|
+
*/
|
|
2945
|
+
registerBatchEndpoints(basePath) {
|
|
2946
|
+
const { crud, batch } = this.config;
|
|
2947
|
+
const dataPath = `${basePath}${crud.dataPrefix}`;
|
|
2948
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
2949
|
+
const operations = batch.operations;
|
|
2950
|
+
if (batch.enableBatchEndpoint && this.protocol.batchData) {
|
|
2951
|
+
this.routeManager.register({
|
|
2952
|
+
method: "POST",
|
|
2953
|
+
path: `${dataPath}/:object/batch`,
|
|
2954
|
+
handler: async (req, res) => {
|
|
2955
|
+
try {
|
|
2956
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2957
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
2958
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2959
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2960
|
+
const result = await p.batchData({
|
|
2961
|
+
object: req.params.object,
|
|
2962
|
+
request: req.body,
|
|
2963
|
+
...projectId ? { projectId } : {},
|
|
2964
|
+
...context ? { context } : {}
|
|
2965
|
+
});
|
|
2966
|
+
res.json(result);
|
|
2967
|
+
} catch (error) {
|
|
2968
|
+
logError("[REST] Unhandled error:", error);
|
|
2969
|
+
sendError(res, error, req.params?.object);
|
|
2970
|
+
}
|
|
2971
|
+
},
|
|
2972
|
+
metadata: {
|
|
2973
|
+
summary: "Batch operations",
|
|
2974
|
+
tags: ["data", "batch"]
|
|
2975
|
+
}
|
|
2976
|
+
});
|
|
2977
|
+
}
|
|
2978
|
+
if (operations.createMany && this.protocol.createManyData) {
|
|
2979
|
+
this.routeManager.register({
|
|
2980
|
+
method: "POST",
|
|
2981
|
+
path: `${dataPath}/:object/createMany`,
|
|
2982
|
+
handler: async (req, res) => {
|
|
2983
|
+
try {
|
|
2984
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2985
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
2986
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2987
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2988
|
+
const result = await p.createManyData({
|
|
2989
|
+
object: req.params.object,
|
|
2990
|
+
records: req.body || [],
|
|
2991
|
+
...projectId ? { projectId } : {},
|
|
2992
|
+
...context ? { context } : {}
|
|
2993
|
+
});
|
|
2994
|
+
res.status(201).json(result);
|
|
2995
|
+
} catch (error) {
|
|
2996
|
+
logError("[REST] Unhandled error:", error);
|
|
2997
|
+
sendError(res, error, req.params?.object);
|
|
2998
|
+
}
|
|
2999
|
+
},
|
|
3000
|
+
metadata: {
|
|
3001
|
+
summary: "Create multiple records",
|
|
3002
|
+
tags: ["data", "batch"]
|
|
1133
3003
|
}
|
|
1134
3004
|
});
|
|
1135
3005
|
}
|
|
@@ -1141,15 +3011,18 @@ var RestServer = class {
|
|
|
1141
3011
|
try {
|
|
1142
3012
|
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1143
3013
|
const p = await this.resolveProtocol(projectId, req);
|
|
3014
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
3015
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1144
3016
|
const result = await p.updateManyData({
|
|
1145
3017
|
object: req.params.object,
|
|
1146
3018
|
...req.body,
|
|
1147
|
-
...projectId ? { projectId } : {}
|
|
3019
|
+
...projectId ? { projectId } : {},
|
|
3020
|
+
...context ? { context } : {}
|
|
1148
3021
|
});
|
|
1149
3022
|
res.json(result);
|
|
1150
3023
|
} catch (error) {
|
|
1151
3024
|
logError("[REST] Unhandled error:", error);
|
|
1152
|
-
res
|
|
3025
|
+
sendError(res, error, req.params?.object);
|
|
1153
3026
|
}
|
|
1154
3027
|
},
|
|
1155
3028
|
metadata: {
|
|
@@ -1166,15 +3039,18 @@ var RestServer = class {
|
|
|
1166
3039
|
try {
|
|
1167
3040
|
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1168
3041
|
const p = await this.resolveProtocol(projectId, req);
|
|
3042
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
3043
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1169
3044
|
const result = await p.deleteManyData({
|
|
1170
3045
|
object: req.params.object,
|
|
1171
3046
|
...req.body,
|
|
1172
|
-
...projectId ? { projectId } : {}
|
|
3047
|
+
...projectId ? { projectId } : {},
|
|
3048
|
+
...context ? { context } : {}
|
|
1173
3049
|
});
|
|
1174
3050
|
res.json(result);
|
|
1175
3051
|
} catch (error) {
|
|
1176
3052
|
logError("[REST] Unhandled error:", error);
|
|
1177
|
-
res
|
|
3053
|
+
sendError(res, error, req.params?.object);
|
|
1178
3054
|
}
|
|
1179
3055
|
},
|
|
1180
3056
|
metadata: {
|
|
@@ -1368,6 +3244,48 @@ function createRestApiPlugin(config = {}) {
|
|
|
1368
3244
|
return void 0;
|
|
1369
3245
|
}
|
|
1370
3246
|
};
|
|
3247
|
+
const emailServiceProvider = async (_projectId) => {
|
|
3248
|
+
try {
|
|
3249
|
+
return ctx.getService("email");
|
|
3250
|
+
} catch {
|
|
3251
|
+
return void 0;
|
|
3252
|
+
}
|
|
3253
|
+
};
|
|
3254
|
+
const sharingServiceProvider = async (_projectId) => {
|
|
3255
|
+
try {
|
|
3256
|
+
return ctx.getService("sharing");
|
|
3257
|
+
} catch {
|
|
3258
|
+
return void 0;
|
|
3259
|
+
}
|
|
3260
|
+
};
|
|
3261
|
+
const reportsServiceProvider = async (_projectId) => {
|
|
3262
|
+
try {
|
|
3263
|
+
return ctx.getService("reports");
|
|
3264
|
+
} catch {
|
|
3265
|
+
return void 0;
|
|
3266
|
+
}
|
|
3267
|
+
};
|
|
3268
|
+
const approvalsServiceProvider = async (_projectId) => {
|
|
3269
|
+
try {
|
|
3270
|
+
return ctx.getService("approvals");
|
|
3271
|
+
} catch {
|
|
3272
|
+
return void 0;
|
|
3273
|
+
}
|
|
3274
|
+
};
|
|
3275
|
+
const sharingRulesServiceProvider = async (_projectId) => {
|
|
3276
|
+
try {
|
|
3277
|
+
return ctx.getService("sharingRules");
|
|
3278
|
+
} catch {
|
|
3279
|
+
return void 0;
|
|
3280
|
+
}
|
|
3281
|
+
};
|
|
3282
|
+
const i18nServiceProvider = async (_projectId) => {
|
|
3283
|
+
try {
|
|
3284
|
+
return ctx.getService("i18n");
|
|
3285
|
+
} catch {
|
|
3286
|
+
return void 0;
|
|
3287
|
+
}
|
|
3288
|
+
};
|
|
1371
3289
|
if (!server) {
|
|
1372
3290
|
ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
|
|
1373
3291
|
return;
|
|
@@ -1378,7 +3296,7 @@ function createRestApiPlugin(config = {}) {
|
|
|
1378
3296
|
}
|
|
1379
3297
|
ctx.logger.info("Hydrating REST API from Protocol...");
|
|
1380
3298
|
try {
|
|
1381
|
-
const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider);
|
|
3299
|
+
const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider);
|
|
1382
3300
|
restServer.registerRoutes();
|
|
1383
3301
|
ctx.logger.info("REST API successfully registered");
|
|
1384
3302
|
} catch (err) {
|