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