@objectstack/rest 4.0.5 → 4.1.0
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 +1620 -90
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +122 -1
- package/dist/index.d.ts +122 -1
- package/dist/index.js +1620 -90
- 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) {
|
|
294
434
|
this.protocol = protocol;
|
|
295
435
|
this.config = this.normalizeConfig(config);
|
|
296
436
|
this.routeManager = new RouteManager(server);
|
|
@@ -299,6 +439,11 @@ 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;
|
|
302
447
|
}
|
|
303
448
|
/**
|
|
304
449
|
* Resolve the protocol for a given request. When `projectId` is present
|
|
@@ -375,6 +520,25 @@ var RestServer = class {
|
|
|
375
520
|
return void 0;
|
|
376
521
|
}
|
|
377
522
|
}
|
|
523
|
+
/**
|
|
524
|
+
* Reject anonymous requests with HTTP 401 when `api.requireAuth` is set.
|
|
525
|
+
* Returns `true` if the response was sent and the caller should stop
|
|
526
|
+
* processing. Returns `false` to continue.
|
|
527
|
+
*
|
|
528
|
+
* The check is intentionally narrow: only `context?.userId` counts as
|
|
529
|
+
* "authenticated". `isSystem` flags are never set on inbound HTTP
|
|
530
|
+
* requests (they're internal-only), so they cannot bypass this gate.
|
|
531
|
+
*/
|
|
532
|
+
enforceAuth(req, res, context) {
|
|
533
|
+
if (!this.config.api.requireAuth) return false;
|
|
534
|
+
if (context?.userId) return false;
|
|
535
|
+
if (req?.method === "OPTIONS") return false;
|
|
536
|
+
res.status(401).json({
|
|
537
|
+
error: "unauthenticated",
|
|
538
|
+
message: "Authentication is required to access this endpoint."
|
|
539
|
+
});
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
378
542
|
/**
|
|
379
543
|
* Resolve the request's execution context (RBAC/RLS/FLS) by looking up
|
|
380
544
|
* the better-auth session via the project's `auth` service. Returns
|
|
@@ -383,6 +547,26 @@ var RestServer = class {
|
|
|
383
547
|
*/
|
|
384
548
|
async resolveExecCtx(projectId, req) {
|
|
385
549
|
try {
|
|
550
|
+
if (!projectId && req && this.envRegistry && this.kernelManager) {
|
|
551
|
+
const host = this.extractHostname(req);
|
|
552
|
+
if (host) {
|
|
553
|
+
try {
|
|
554
|
+
const result = await this.envRegistry.resolveByHostname(host);
|
|
555
|
+
if (result?.projectId) projectId = result.projectId;
|
|
556
|
+
} catch {
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (!projectId && typeof this.envRegistry.resolveById === "function") {
|
|
560
|
+
const headerVal = this.extractProjectIdHeader(req);
|
|
561
|
+
if (headerVal) {
|
|
562
|
+
try {
|
|
563
|
+
const driver = await this.envRegistry.resolveById(headerVal);
|
|
564
|
+
if (driver) projectId = headerVal;
|
|
565
|
+
} catch {
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
386
570
|
let authService;
|
|
387
571
|
let kernel;
|
|
388
572
|
if (projectId && projectId !== "platform" && this.kernelManager) {
|
|
@@ -622,8 +806,10 @@ var RestServer = class {
|
|
|
622
806
|
enableUi: api.enableUi ?? true,
|
|
623
807
|
enableBatch: api.enableBatch ?? true,
|
|
624
808
|
enableDiscovery: api.enableDiscovery ?? true,
|
|
809
|
+
enableSearch: api.enableSearch ?? true,
|
|
625
810
|
enableProjectScoping: api.enableProjectScoping ?? false,
|
|
626
811
|
projectResolution: api.projectResolution ?? "auto",
|
|
812
|
+
requireAuth: api.requireAuth ?? false,
|
|
627
813
|
documentation: api.documentation,
|
|
628
814
|
responseFormat: api.responseFormat
|
|
629
815
|
},
|
|
@@ -705,9 +891,18 @@ var RestServer = class {
|
|
|
705
891
|
if (this.config.api.enableUi) {
|
|
706
892
|
this.registerUiEndpoints(bp);
|
|
707
893
|
}
|
|
894
|
+
if (this.config.api.enableSearch ?? true) {
|
|
895
|
+
this.registerSearchEndpoints(bp);
|
|
896
|
+
}
|
|
897
|
+
this.registerEmailEndpoints(bp);
|
|
898
|
+
this.registerSharingEndpoints(bp);
|
|
899
|
+
this.registerSharingRuleEndpoints(bp);
|
|
900
|
+
this.registerReportsEndpoints(bp);
|
|
901
|
+
this.registerApprovalsEndpoints(bp);
|
|
708
902
|
if (this.config.api.enableCrud) {
|
|
709
903
|
this.registerCrudEndpoints(bp);
|
|
710
904
|
}
|
|
905
|
+
this.registerDataActionEndpoints(bp);
|
|
711
906
|
if (this.config.api.enableBatch) {
|
|
712
907
|
this.registerBatchEndpoints(bp);
|
|
713
908
|
}
|
|
@@ -758,7 +953,7 @@ var RestServer = class {
|
|
|
758
953
|
res.json(discovery);
|
|
759
954
|
} catch (error) {
|
|
760
955
|
logError("[REST] Unhandled error:", error);
|
|
761
|
-
res
|
|
956
|
+
sendError(res, error);
|
|
762
957
|
}
|
|
763
958
|
};
|
|
764
959
|
this.routeManager.register({
|
|
@@ -799,7 +994,7 @@ var RestServer = class {
|
|
|
799
994
|
res.json(types);
|
|
800
995
|
} catch (error) {
|
|
801
996
|
logError("[REST] Unhandled error:", error);
|
|
802
|
-
res
|
|
997
|
+
sendError(res, error);
|
|
803
998
|
}
|
|
804
999
|
},
|
|
805
1000
|
metadata: {
|
|
@@ -827,7 +1022,7 @@ var RestServer = class {
|
|
|
827
1022
|
res.json(translated);
|
|
828
1023
|
} catch (error) {
|
|
829
1024
|
logError("[REST] Unhandled error:", error);
|
|
830
|
-
res
|
|
1025
|
+
sendError(res, error);
|
|
831
1026
|
}
|
|
832
1027
|
},
|
|
833
1028
|
metadata: {
|
|
@@ -885,7 +1080,7 @@ var RestServer = class {
|
|
|
885
1080
|
}
|
|
886
1081
|
} catch (error) {
|
|
887
1082
|
logError("[REST] Unhandled error:", error);
|
|
888
|
-
res
|
|
1083
|
+
sendError(res, error);
|
|
889
1084
|
}
|
|
890
1085
|
},
|
|
891
1086
|
metadata: {
|
|
@@ -905,16 +1100,18 @@ var RestServer = class {
|
|
|
905
1100
|
res.status(501).json({ error: "Save operation not supported by protocol implementation" });
|
|
906
1101
|
return;
|
|
907
1102
|
}
|
|
1103
|
+
const body = req.body ?? {};
|
|
1104
|
+
const item = body && typeof body === "object" && "metadata" in body ? body.metadata : body && typeof body === "object" && "item" in body ? body.item : body;
|
|
908
1105
|
const result = await p.saveMetaItem({
|
|
909
1106
|
type: req.params.type,
|
|
910
1107
|
name: req.params.name,
|
|
911
|
-
item
|
|
1108
|
+
item,
|
|
912
1109
|
...projectId ? { projectId } : {}
|
|
913
1110
|
});
|
|
914
1111
|
res.json(result);
|
|
915
1112
|
} catch (error) {
|
|
916
1113
|
logError("[REST] Unhandled error:", error);
|
|
917
|
-
res
|
|
1114
|
+
sendError(res, error);
|
|
918
1115
|
}
|
|
919
1116
|
},
|
|
920
1117
|
metadata: {
|
|
@@ -922,6 +1119,92 @@ var RestServer = class {
|
|
|
922
1119
|
tags: ["metadata"]
|
|
923
1120
|
}
|
|
924
1121
|
});
|
|
1122
|
+
this.routeManager.register({
|
|
1123
|
+
method: "DELETE",
|
|
1124
|
+
path: `${metaPath}/:type/:name`,
|
|
1125
|
+
handler: async (req, res) => {
|
|
1126
|
+
try {
|
|
1127
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1128
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1129
|
+
if (!p.deleteMetaItem) {
|
|
1130
|
+
res.status(501).json({
|
|
1131
|
+
error: "Reset operation not supported by protocol implementation"
|
|
1132
|
+
});
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
const result = await p.deleteMetaItem({
|
|
1136
|
+
type: req.params.type,
|
|
1137
|
+
name: req.params.name,
|
|
1138
|
+
...projectId ? { projectId } : {}
|
|
1139
|
+
});
|
|
1140
|
+
res.json(result);
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
logError("[REST] Unhandled error:", error);
|
|
1143
|
+
sendError(res, error);
|
|
1144
|
+
}
|
|
1145
|
+
},
|
|
1146
|
+
metadata: {
|
|
1147
|
+
summary: "Reset metadata item to artifact default (deletes customization overlay)",
|
|
1148
|
+
tags: ["metadata"]
|
|
1149
|
+
}
|
|
1150
|
+
});
|
|
1151
|
+
if (metadata.endpoints.item !== false) {
|
|
1152
|
+
this.routeManager.register({
|
|
1153
|
+
method: "GET",
|
|
1154
|
+
path: `${metaPath}/:type/:section/:name`,
|
|
1155
|
+
handler: async (req, res) => {
|
|
1156
|
+
try {
|
|
1157
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1158
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1159
|
+
const compoundName = `${req.params.section}/${req.params.name}`;
|
|
1160
|
+
const packageId = req.query?.package || void 0;
|
|
1161
|
+
const item = await p.getMetaItem({
|
|
1162
|
+
type: req.params.type,
|
|
1163
|
+
name: compoundName,
|
|
1164
|
+
packageId
|
|
1165
|
+
});
|
|
1166
|
+
res.header("Vary", "Accept-Language");
|
|
1167
|
+
res.json(await this.translateMetaItem(req, req.params.type, projectId, item));
|
|
1168
|
+
} catch (error) {
|
|
1169
|
+
logError("[REST] Unhandled error:", error);
|
|
1170
|
+
sendError(res, error);
|
|
1171
|
+
}
|
|
1172
|
+
},
|
|
1173
|
+
metadata: {
|
|
1174
|
+
summary: "Get specific metadata item by compound name",
|
|
1175
|
+
tags: ["metadata"]
|
|
1176
|
+
}
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
this.routeManager.register({
|
|
1180
|
+
method: "PUT",
|
|
1181
|
+
path: `${metaPath}/:type/:section/:name`,
|
|
1182
|
+
handler: async (req, res) => {
|
|
1183
|
+
try {
|
|
1184
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1185
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1186
|
+
if (!p.saveMetaItem) {
|
|
1187
|
+
res.status(501).json({ error: "Save operation not supported by protocol implementation" });
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
const compoundName = `${req.params.section}/${req.params.name}`;
|
|
1191
|
+
const result = await p.saveMetaItem({
|
|
1192
|
+
type: req.params.type,
|
|
1193
|
+
name: compoundName,
|
|
1194
|
+
item: req.body,
|
|
1195
|
+
...projectId ? { projectId } : {}
|
|
1196
|
+
});
|
|
1197
|
+
res.json(result);
|
|
1198
|
+
} catch (error) {
|
|
1199
|
+
logError("[REST] Unhandled error:", error);
|
|
1200
|
+
sendError(res, error);
|
|
1201
|
+
}
|
|
1202
|
+
},
|
|
1203
|
+
metadata: {
|
|
1204
|
+
summary: "Save specific metadata item by compound name",
|
|
1205
|
+
tags: ["metadata"]
|
|
1206
|
+
}
|
|
1207
|
+
});
|
|
925
1208
|
}
|
|
926
1209
|
/**
|
|
927
1210
|
* Register UI endpoints
|
|
@@ -948,7 +1231,7 @@ var RestServer = class {
|
|
|
948
1231
|
}
|
|
949
1232
|
} catch (error) {
|
|
950
1233
|
logError("[REST] Unhandled error:", error);
|
|
951
|
-
res
|
|
1234
|
+
sendError(res, error, req.params?.object);
|
|
952
1235
|
}
|
|
953
1236
|
},
|
|
954
1237
|
metadata: {
|
|
@@ -974,6 +1257,7 @@ var RestServer = class {
|
|
|
974
1257
|
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
975
1258
|
const p = await this.resolveProtocol(projectId, req);
|
|
976
1259
|
const context = await this.resolveExecCtx(projectId, req);
|
|
1260
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
977
1261
|
const result = await p.findData({
|
|
978
1262
|
object: req.params.object,
|
|
979
1263
|
query: req.query,
|
|
@@ -1007,6 +1291,7 @@ var RestServer = class {
|
|
|
1007
1291
|
const p = await this.resolveProtocol(projectId, req);
|
|
1008
1292
|
const { select, expand } = req.query || {};
|
|
1009
1293
|
const context = await this.resolveExecCtx(projectId, req);
|
|
1294
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1010
1295
|
const result = await p.getData({
|
|
1011
1296
|
object: req.params.object,
|
|
1012
1297
|
id: req.params.id,
|
|
@@ -1018,7 +1303,7 @@ var RestServer = class {
|
|
|
1018
1303
|
res.json(result);
|
|
1019
1304
|
} catch (error) {
|
|
1020
1305
|
const mapped = mapDataError(error, req.params?.object);
|
|
1021
|
-
if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
|
|
1306
|
+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
|
|
1022
1307
|
res.status(mapped.status === 400 ? 404 : mapped.status).json(mapped.body);
|
|
1023
1308
|
}
|
|
1024
1309
|
},
|
|
@@ -1037,6 +1322,7 @@ var RestServer = class {
|
|
|
1037
1322
|
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1038
1323
|
const p = await this.resolveProtocol(projectId, req);
|
|
1039
1324
|
const context = await this.resolveExecCtx(projectId, req);
|
|
1325
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1040
1326
|
const result = await p.createData({
|
|
1041
1327
|
object: req.params.object,
|
|
1042
1328
|
data: req.body,
|
|
@@ -1046,7 +1332,7 @@ var RestServer = class {
|
|
|
1046
1332
|
res.status(201).json(result);
|
|
1047
1333
|
} catch (error) {
|
|
1048
1334
|
const mapped = mapDataError(error, req.params?.object);
|
|
1049
|
-
if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
|
|
1335
|
+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
|
|
1050
1336
|
res.status(mapped.status).json(mapped.body);
|
|
1051
1337
|
}
|
|
1052
1338
|
},
|
|
@@ -1056,6 +1342,35 @@ var RestServer = class {
|
|
|
1056
1342
|
}
|
|
1057
1343
|
});
|
|
1058
1344
|
}
|
|
1345
|
+
if (operations.list) {
|
|
1346
|
+
this.routeManager.register({
|
|
1347
|
+
method: "POST",
|
|
1348
|
+
path: `${dataPath}/:object/query`,
|
|
1349
|
+
handler: async (req, res) => {
|
|
1350
|
+
try {
|
|
1351
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1352
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1353
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1354
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1355
|
+
const result = await p.findData({
|
|
1356
|
+
object: req.params.object,
|
|
1357
|
+
query: req.body || {},
|
|
1358
|
+
...projectId ? { projectId } : {},
|
|
1359
|
+
...context ? { context } : {}
|
|
1360
|
+
});
|
|
1361
|
+
res.json(result);
|
|
1362
|
+
} catch (error) {
|
|
1363
|
+
const mapped = mapDataError(error, req.params?.object);
|
|
1364
|
+
if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
|
|
1365
|
+
res.status(mapped.status).json(mapped.body);
|
|
1366
|
+
}
|
|
1367
|
+
},
|
|
1368
|
+
metadata: {
|
|
1369
|
+
summary: "Advanced query (QueryAST in body)",
|
|
1370
|
+
tags: ["data", "crud"]
|
|
1371
|
+
}
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1059
1374
|
if (operations.update) {
|
|
1060
1375
|
this.routeManager.register({
|
|
1061
1376
|
method: "PATCH",
|
|
@@ -1065,6 +1380,7 @@ var RestServer = class {
|
|
|
1065
1380
|
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1066
1381
|
const p = await this.resolveProtocol(projectId, req);
|
|
1067
1382
|
const context = await this.resolveExecCtx(projectId, req);
|
|
1383
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1068
1384
|
const result = await p.updateData({
|
|
1069
1385
|
object: req.params.object,
|
|
1070
1386
|
id: req.params.id,
|
|
@@ -1075,7 +1391,7 @@ var RestServer = class {
|
|
|
1075
1391
|
res.json(result);
|
|
1076
1392
|
} catch (error) {
|
|
1077
1393
|
const mapped = mapDataError(error, req.params?.object);
|
|
1078
|
-
if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
|
|
1394
|
+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
|
|
1079
1395
|
res.status(mapped.status).json(mapped.body);
|
|
1080
1396
|
}
|
|
1081
1397
|
},
|
|
@@ -1094,6 +1410,7 @@ var RestServer = class {
|
|
|
1094
1410
|
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1095
1411
|
const p = await this.resolveProtocol(projectId, req);
|
|
1096
1412
|
const context = await this.resolveExecCtx(projectId, req);
|
|
1413
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1097
1414
|
const result = await p.deleteData({
|
|
1098
1415
|
object: req.params.object,
|
|
1099
1416
|
id: req.params.id,
|
|
@@ -1103,7 +1420,7 @@ var RestServer = class {
|
|
|
1103
1420
|
res.json(result);
|
|
1104
1421
|
} catch (error) {
|
|
1105
1422
|
const mapped = mapDataError(error, req.params?.object);
|
|
1106
|
-
if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
|
|
1423
|
+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
|
|
1107
1424
|
res.status(mapped.status).json(mapped.body);
|
|
1108
1425
|
}
|
|
1109
1426
|
},
|
|
@@ -1115,88 +1432,1263 @@ var RestServer = class {
|
|
|
1115
1432
|
}
|
|
1116
1433
|
}
|
|
1117
1434
|
/**
|
|
1118
|
-
* Register
|
|
1435
|
+
* Register object-specific action endpoints that don't fit the
|
|
1436
|
+
* generic CRUD shape. These are domain operations (Salesforce
|
|
1437
|
+
* convertLead, etc.) where the protocol implementation does its own
|
|
1438
|
+
* multi-record orchestration and we just need a thin HTTP route.
|
|
1439
|
+
*
|
|
1440
|
+
* POST {basePath}/data/lead/:id/convert — M10.6 lead conversion.
|
|
1119
1441
|
*/
|
|
1120
|
-
|
|
1121
|
-
const { crud, batch } = this.config;
|
|
1122
|
-
const dataPath = `${basePath}${crud.dataPrefix}`;
|
|
1442
|
+
registerDataActionEndpoints(basePath) {
|
|
1123
1443
|
const isScoped = basePath.includes("/projects/:projectId");
|
|
1124
|
-
const
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
});
|
|
1138
|
-
|
|
1139
|
-
} catch (error) {
|
|
1140
|
-
logError("[REST] Unhandled error:", error);
|
|
1141
|
-
res.status(400).json({ error: error.message });
|
|
1444
|
+
const { crud } = this.config;
|
|
1445
|
+
const dataPath = `${basePath}${crud.dataPrefix}`;
|
|
1446
|
+
this.routeManager.register({
|
|
1447
|
+
method: "POST",
|
|
1448
|
+
path: `${dataPath}/lead/:id/convert`,
|
|
1449
|
+
handler: async (req, res) => {
|
|
1450
|
+
try {
|
|
1451
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1452
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1453
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1454
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1455
|
+
const convertLead = p.convertLead;
|
|
1456
|
+
if (typeof convertLead !== "function") {
|
|
1457
|
+
res.status(501).json({ code: "NOT_IMPLEMENTED", error: "Lead convert not supported by this protocol" });
|
|
1458
|
+
return;
|
|
1142
1459
|
}
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1460
|
+
const body = req.body ?? {};
|
|
1461
|
+
const result = await convertLead.call(p, {
|
|
1462
|
+
leadId: req.params.id,
|
|
1463
|
+
accountId: body.accountId,
|
|
1464
|
+
contactId: body.contactId,
|
|
1465
|
+
createOpportunity: body.createOpportunity,
|
|
1466
|
+
opportunity: body.opportunity,
|
|
1467
|
+
convertedStatus: body.convertedStatus,
|
|
1468
|
+
...context ? { context } : {}
|
|
1469
|
+
});
|
|
1470
|
+
res.json(result);
|
|
1471
|
+
} catch (error) {
|
|
1472
|
+
logError("[REST] Unhandled error:", error);
|
|
1473
|
+
sendError(res, error, "lead");
|
|
1147
1474
|
}
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1475
|
+
},
|
|
1476
|
+
metadata: {
|
|
1477
|
+
summary: "Convert a Lead into Account + Contact (+ optional Opportunity)",
|
|
1478
|
+
tags: ["data", "lead"]
|
|
1479
|
+
}
|
|
1480
|
+
});
|
|
1481
|
+
this.routeManager.register({
|
|
1482
|
+
method: "POST",
|
|
1483
|
+
path: `${dataPath}/:object/import`,
|
|
1484
|
+
handler: async (req, res) => {
|
|
1485
|
+
try {
|
|
1486
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1487
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1488
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1489
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1490
|
+
const objectName = String(req.params.object || "");
|
|
1491
|
+
if (!objectName) {
|
|
1492
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
const body = req.body ?? {};
|
|
1496
|
+
const dryRun = body.dryRun === true;
|
|
1497
|
+
const mapping = body.mapping ?? {};
|
|
1498
|
+
let rows = [];
|
|
1499
|
+
if (body.format === "json" && Array.isArray(body.rows)) {
|
|
1500
|
+
rows = body.rows;
|
|
1501
|
+
} else if ((body.format === "csv" || typeof body.csv === "string") && typeof body.csv === "string") {
|
|
1502
|
+
rows = parseCsvToRows(body.csv, mapping);
|
|
1503
|
+
} else if (Array.isArray(body)) {
|
|
1504
|
+
rows = body;
|
|
1505
|
+
} else {
|
|
1506
|
+
res.status(400).json({
|
|
1507
|
+
code: "INVALID_REQUEST",
|
|
1508
|
+
error: 'Provide either format:"csv" with csv text or format:"json" with rows[]'
|
|
1162
1509
|
});
|
|
1163
|
-
|
|
1164
|
-
} catch (error) {
|
|
1165
|
-
logError("[REST] Unhandled error:", error);
|
|
1166
|
-
res.status(400).json({ error: error.message });
|
|
1510
|
+
return;
|
|
1167
1511
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
});
|
|
1174
|
-
}
|
|
1175
|
-
if (operations.updateMany && this.protocol.updateManyData) {
|
|
1176
|
-
this.routeManager.register({
|
|
1177
|
-
method: "POST",
|
|
1178
|
-
path: `${dataPath}/:object/updateMany`,
|
|
1179
|
-
handler: async (req, res) => {
|
|
1180
|
-
try {
|
|
1181
|
-
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1182
|
-
const p = await this.resolveProtocol(projectId, req);
|
|
1183
|
-
const result = await p.updateManyData({
|
|
1184
|
-
object: req.params.object,
|
|
1185
|
-
...req.body,
|
|
1186
|
-
...projectId ? { projectId } : {}
|
|
1512
|
+
const max = 5e3;
|
|
1513
|
+
if (rows.length > max) {
|
|
1514
|
+
res.status(413).json({
|
|
1515
|
+
code: "PAYLOAD_TOO_LARGE",
|
|
1516
|
+
error: `Import limit is ${max} rows per request (got ${rows.length})`
|
|
1187
1517
|
});
|
|
1188
|
-
|
|
1189
|
-
} catch (error) {
|
|
1190
|
-
logError("[REST] Unhandled error:", error);
|
|
1191
|
-
res.status(400).json({ error: error.message });
|
|
1518
|
+
return;
|
|
1192
1519
|
}
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1520
|
+
const results = [];
|
|
1521
|
+
let okCount = 0;
|
|
1522
|
+
let errCount = 0;
|
|
1523
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1524
|
+
const data = rows[i];
|
|
1525
|
+
try {
|
|
1526
|
+
if (dryRun) {
|
|
1527
|
+
const validate = p.validate;
|
|
1528
|
+
if (typeof validate === "function") {
|
|
1529
|
+
await validate.call(p, { object: objectName, data, context });
|
|
1530
|
+
}
|
|
1531
|
+
results.push({ row: i + 1, ok: true });
|
|
1532
|
+
okCount++;
|
|
1533
|
+
} else {
|
|
1534
|
+
const created = await p.createData({ object: objectName, data, context });
|
|
1535
|
+
const id = created?.id ?? created?.record?.id;
|
|
1536
|
+
results.push({ row: i + 1, ok: true, id });
|
|
1537
|
+
okCount++;
|
|
1538
|
+
}
|
|
1539
|
+
} catch (err) {
|
|
1540
|
+
errCount++;
|
|
1541
|
+
const code = err?.code ?? "IMPORT_ROW_FAILED";
|
|
1542
|
+
const message = typeof err?.message === "string" ? err.message.slice(0, 300) : "Row failed";
|
|
1543
|
+
results.push({ row: i + 1, ok: false, error: message, code });
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
res.json({
|
|
1547
|
+
object: objectName,
|
|
1548
|
+
dryRun,
|
|
1549
|
+
total: rows.length,
|
|
1550
|
+
ok: okCount,
|
|
1551
|
+
errors: errCount,
|
|
1552
|
+
results
|
|
1553
|
+
});
|
|
1554
|
+
} catch (error) {
|
|
1555
|
+
logError("[REST] Unhandled error:", error);
|
|
1556
|
+
sendError(res, error, String(req.params?.object || ""));
|
|
1557
|
+
}
|
|
1558
|
+
},
|
|
1559
|
+
metadata: {
|
|
1560
|
+
summary: "Bulk-import rows into an object (CSV or JSON, with optional dry-run)",
|
|
1561
|
+
tags: ["data", "import"]
|
|
1562
|
+
}
|
|
1563
|
+
});
|
|
1564
|
+
this.routeManager.register({
|
|
1565
|
+
method: "GET",
|
|
1566
|
+
path: `${dataPath}/:object/export`,
|
|
1567
|
+
handler: async (req, res) => {
|
|
1568
|
+
try {
|
|
1569
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1570
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1571
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1572
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1573
|
+
const objectName = String(req.params.object || "");
|
|
1574
|
+
if (!objectName) {
|
|
1575
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
const q = req.query ?? {};
|
|
1579
|
+
const format = String(q.format ?? "csv").toLowerCase() === "json" ? "json" : "csv";
|
|
1580
|
+
const HARD_CAP = 5e4;
|
|
1581
|
+
const MAX_CHUNK = 5e3;
|
|
1582
|
+
const requestedLimit = q.limit != null ? Math.max(1, Number(q.limit) || 0) : 1e4;
|
|
1583
|
+
const limit = Math.min(requestedLimit, HARD_CAP);
|
|
1584
|
+
const chunkSize = Math.min(MAX_CHUNK, Math.max(50, q.page != null ? Number(q.page) || 500 : 500));
|
|
1585
|
+
let filter = void 0;
|
|
1586
|
+
if (typeof q.filter === "string" && q.filter.length > 0) {
|
|
1587
|
+
try {
|
|
1588
|
+
filter = JSON.parse(q.filter);
|
|
1589
|
+
} catch {
|
|
1590
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "filter must be JSON" });
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
} else if (q.filter && typeof q.filter === "object") {
|
|
1594
|
+
filter = q.filter;
|
|
1595
|
+
}
|
|
1596
|
+
let orderby = void 0;
|
|
1597
|
+
if (typeof q.orderby === "string" && q.orderby.length > 0) {
|
|
1598
|
+
if (q.orderby.startsWith("{") || q.orderby.startsWith("[")) {
|
|
1599
|
+
try {
|
|
1600
|
+
orderby = JSON.parse(q.orderby);
|
|
1601
|
+
} catch {
|
|
1602
|
+
}
|
|
1603
|
+
} else {
|
|
1604
|
+
const obj = {};
|
|
1605
|
+
for (const part of q.orderby.split(",")) {
|
|
1606
|
+
const [field, dir] = part.split(":").map((s) => s.trim());
|
|
1607
|
+
if (field) obj[field] = dir?.toLowerCase() === "desc" ? "desc" : "asc";
|
|
1608
|
+
}
|
|
1609
|
+
if (Object.keys(obj).length > 0) orderby = obj;
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
let fields;
|
|
1613
|
+
if (typeof q.fields === "string" && q.fields.length > 0) {
|
|
1614
|
+
fields = q.fields.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1615
|
+
} else if (Array.isArray(q.fields)) {
|
|
1616
|
+
fields = q.fields.filter((s) => typeof s === "string" && s.length > 0);
|
|
1617
|
+
}
|
|
1618
|
+
if (!fields || fields.length === 0) {
|
|
1619
|
+
try {
|
|
1620
|
+
const schema = await p.getObjectSchema?.(objectName, projectId);
|
|
1621
|
+
const schemaFields = schema?.fields;
|
|
1622
|
+
if (Array.isArray(schemaFields)) {
|
|
1623
|
+
fields = schemaFields.map((f) => f.name).filter((n) => typeof n === "string");
|
|
1624
|
+
}
|
|
1625
|
+
} catch {
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1629
|
+
const safeObj = objectName.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
1630
|
+
if (format === "csv") {
|
|
1631
|
+
res.header("Content-Type", "text/csv; charset=utf-8");
|
|
1632
|
+
res.header("Content-Disposition", `attachment; filename="${safeObj}-${stamp}.csv"`);
|
|
1633
|
+
} else {
|
|
1634
|
+
res.header("Content-Type", "application/json; charset=utf-8");
|
|
1635
|
+
res.header("Content-Disposition", `attachment; filename="${safeObj}-${stamp}.json"`);
|
|
1636
|
+
}
|
|
1637
|
+
res.header("X-Export-Format", format);
|
|
1638
|
+
res.header("X-Export-Limit", String(limit));
|
|
1639
|
+
res.header("Cache-Control", "no-store");
|
|
1640
|
+
let exported = 0;
|
|
1641
|
+
let firstChunk = true;
|
|
1642
|
+
let skip = 0;
|
|
1643
|
+
if (format === "json") res.write("[");
|
|
1644
|
+
while (exported < limit) {
|
|
1645
|
+
const take = Math.min(chunkSize, limit - exported);
|
|
1646
|
+
const findArgs = {
|
|
1647
|
+
object: objectName,
|
|
1648
|
+
query: {
|
|
1649
|
+
...filter ? { $filter: filter } : {},
|
|
1650
|
+
...orderby ? { $orderby: orderby } : {},
|
|
1651
|
+
$top: take,
|
|
1652
|
+
$skip: skip
|
|
1653
|
+
},
|
|
1654
|
+
...projectId ? { projectId } : {},
|
|
1655
|
+
...context ? { context } : {}
|
|
1656
|
+
};
|
|
1657
|
+
const result = await p.findData(findArgs);
|
|
1658
|
+
const rows = Array.isArray(result?.data) ? result.data : Array.isArray(result?.rows) ? result.rows : Array.isArray(result) ? result : [];
|
|
1659
|
+
if (rows.length === 0) break;
|
|
1660
|
+
if (format === "csv") {
|
|
1661
|
+
if ((!fields || fields.length === 0) && firstChunk) {
|
|
1662
|
+
fields = Object.keys(rows[0] ?? {});
|
|
1663
|
+
}
|
|
1664
|
+
const text = rowsToCsv(fields ?? [], rows, firstChunk);
|
|
1665
|
+
res.write(text);
|
|
1666
|
+
} else {
|
|
1667
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1668
|
+
const prefix = firstChunk && i === 0 ? "" : ",";
|
|
1669
|
+
res.write(prefix + JSON.stringify(rows[i]));
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
firstChunk = false;
|
|
1673
|
+
exported += rows.length;
|
|
1674
|
+
skip += rows.length;
|
|
1675
|
+
if (rows.length < take) break;
|
|
1676
|
+
}
|
|
1677
|
+
if (format === "json") res.write("]");
|
|
1678
|
+
res.end();
|
|
1679
|
+
} catch (error) {
|
|
1680
|
+
logError("[REST] Unhandled error:", error);
|
|
1681
|
+
try {
|
|
1682
|
+
sendError(res, error, String(req.params?.object || ""));
|
|
1683
|
+
} catch {
|
|
1684
|
+
try {
|
|
1685
|
+
res.end();
|
|
1686
|
+
} catch {
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
},
|
|
1691
|
+
metadata: {
|
|
1692
|
+
summary: "Streaming export of object rows (CSV or JSON)",
|
|
1693
|
+
tags: ["data", "export"]
|
|
1694
|
+
}
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
/**
|
|
1698
|
+
* Register global cross-object search endpoint (M10.5).
|
|
1699
|
+
* GET {basePath}/search?q=acme&objects=lead,account&limit=20&perObject=5
|
|
1700
|
+
*/
|
|
1701
|
+
registerSearchEndpoints(basePath) {
|
|
1702
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
1703
|
+
this.routeManager.register({
|
|
1704
|
+
method: "GET",
|
|
1705
|
+
path: `${basePath}/search`,
|
|
1706
|
+
handler: async (req, res) => {
|
|
1707
|
+
try {
|
|
1708
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1709
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1710
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1711
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1712
|
+
const searchAll = p.searchAll;
|
|
1713
|
+
if (typeof searchAll !== "function") {
|
|
1714
|
+
res.status(501).json({ code: "NOT_IMPLEMENTED", message: "Search not supported by this protocol" });
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
const q = String(req.query?.q ?? req.query?.query ?? "");
|
|
1718
|
+
const objectsParam = req.query?.objects;
|
|
1719
|
+
const objects = typeof objectsParam === "string" ? objectsParam.split(",").map((s) => s.trim()).filter(Boolean) : Array.isArray(objectsParam) ? objectsParam : void 0;
|
|
1720
|
+
const result = await searchAll.call(p, {
|
|
1721
|
+
q,
|
|
1722
|
+
objects,
|
|
1723
|
+
limit: req.query?.limit ? Number(req.query.limit) : void 0,
|
|
1724
|
+
perObject: req.query?.perObject ? Number(req.query.perObject) : void 0,
|
|
1725
|
+
...context ? { context } : {}
|
|
1726
|
+
});
|
|
1727
|
+
res.json(result);
|
|
1728
|
+
} catch (error) {
|
|
1729
|
+
const mapped = mapDataError(error);
|
|
1730
|
+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") {
|
|
1731
|
+
logError("[REST] Unhandled error:", error);
|
|
1732
|
+
}
|
|
1733
|
+
res.status(mapped.status).json(mapped.body);
|
|
1734
|
+
}
|
|
1735
|
+
},
|
|
1736
|
+
metadata: {
|
|
1737
|
+
summary: "Global cross-object search",
|
|
1738
|
+
tags: ["search"]
|
|
1739
|
+
}
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
/**
|
|
1743
|
+
* Register email endpoints (M11.B1 / M10.7).
|
|
1744
|
+
*
|
|
1745
|
+
* POST {basePath}/email/send — send a transactional email via the
|
|
1746
|
+
* `IEmailService` provider registered by EmailServicePlugin. Returns
|
|
1747
|
+
* 501 when no provider is wired so deployments without email
|
|
1748
|
+
* configured fail cleanly.
|
|
1749
|
+
*
|
|
1750
|
+
* Request body:
|
|
1751
|
+
* {
|
|
1752
|
+
* to: "a@b.com" | ["a@b.com", { name, address }],
|
|
1753
|
+
* from?: ..., cc?: ..., bcc?: ..., replyTo?: ...,
|
|
1754
|
+
* subject: string,
|
|
1755
|
+
* text?: string, html?: string, // at least one required
|
|
1756
|
+
* attachments?: [{ filename, content, contentType?, cid? }],
|
|
1757
|
+
* headers?: { [name]: value },
|
|
1758
|
+
* relatedObject?: string, relatedId?: string,
|
|
1759
|
+
* }
|
|
1760
|
+
*/
|
|
1761
|
+
registerEmailEndpoints(basePath) {
|
|
1762
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
1763
|
+
this.routeManager.register({
|
|
1764
|
+
method: "POST",
|
|
1765
|
+
path: `${basePath}/email/send`,
|
|
1766
|
+
handler: async (req, res) => {
|
|
1767
|
+
try {
|
|
1768
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1769
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1770
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1771
|
+
if (!this.emailServiceProvider) {
|
|
1772
|
+
res.status(501).json({
|
|
1773
|
+
code: "NOT_IMPLEMENTED",
|
|
1774
|
+
message: "Email service is not configured on this deployment"
|
|
1775
|
+
});
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
const emailService = await this.emailServiceProvider(projectId).catch(() => void 0);
|
|
1779
|
+
if (!emailService || typeof emailService.send !== "function") {
|
|
1780
|
+
res.status(501).json({
|
|
1781
|
+
code: "NOT_IMPLEMENTED",
|
|
1782
|
+
message: "Email service is not configured on this deployment"
|
|
1783
|
+
});
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
const body = req.body ?? {};
|
|
1787
|
+
if (!body || typeof body !== "object") {
|
|
1788
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "JSON body required" });
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
const input = {
|
|
1792
|
+
...body,
|
|
1793
|
+
...body.sentBy === void 0 && context?.userId ? { sentBy: context.userId } : {}
|
|
1794
|
+
};
|
|
1795
|
+
try {
|
|
1796
|
+
const result = await emailService.send(input);
|
|
1797
|
+
if (result?.status === "sent") {
|
|
1798
|
+
res.status(200).json(result);
|
|
1799
|
+
} else {
|
|
1800
|
+
res.status(200).json(result);
|
|
1801
|
+
}
|
|
1802
|
+
} catch (err) {
|
|
1803
|
+
const message = String(err?.message ?? err ?? "send failed");
|
|
1804
|
+
if (message.startsWith("VALIDATION_FAILED")) {
|
|
1805
|
+
res.status(400).json({
|
|
1806
|
+
code: "VALIDATION_FAILED",
|
|
1807
|
+
error: message.replace(/^VALIDATION_FAILED:\s*/, "")
|
|
1808
|
+
});
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
throw err;
|
|
1812
|
+
}
|
|
1813
|
+
} catch (error) {
|
|
1814
|
+
logError("[REST] Email send unhandled error:", error);
|
|
1815
|
+
res.status(500).json({
|
|
1816
|
+
code: "EMAIL_SEND_FAILED",
|
|
1817
|
+
error: String(error?.message ?? error ?? "send failed").slice(0, 500)
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1820
|
+
},
|
|
1821
|
+
metadata: {
|
|
1822
|
+
summary: "Send a transactional email via the configured EmailService",
|
|
1823
|
+
tags: ["email"]
|
|
1824
|
+
}
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* Register record-level sharing endpoints (M11.C17).
|
|
1829
|
+
*
|
|
1830
|
+
* Surfaces `ISharingService` over HTTP so the UI can list, create
|
|
1831
|
+
* and revoke per-record grants without going through ObjectQL. The
|
|
1832
|
+
* three routes mirror the share-management drawer in Salesforce /
|
|
1833
|
+
* ServiceNow:
|
|
1834
|
+
*
|
|
1835
|
+
* GET {basePath}/data/:object/:id/shares
|
|
1836
|
+
* POST {basePath}/data/:object/:id/shares
|
|
1837
|
+
* DELETE {basePath}/data/:object/:id/shares/:shareId
|
|
1838
|
+
*
|
|
1839
|
+
* All three resolve via `sharingServiceProvider`; routes return 501
|
|
1840
|
+
* when no sharing service is configured so a deployment without the
|
|
1841
|
+
* `@objectstack/plugin-sharing` plugin fails cleanly.
|
|
1842
|
+
*/
|
|
1843
|
+
registerSharingEndpoints(basePath) {
|
|
1844
|
+
const { crud } = this.config;
|
|
1845
|
+
const dataPath = `${basePath}${crud.dataPrefix}`;
|
|
1846
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
1847
|
+
const resolveService = async (projectId) => {
|
|
1848
|
+
if (!this.sharingServiceProvider) return void 0;
|
|
1849
|
+
try {
|
|
1850
|
+
return await this.sharingServiceProvider(projectId);
|
|
1851
|
+
} catch {
|
|
1852
|
+
return void 0;
|
|
1853
|
+
}
|
|
1854
|
+
};
|
|
1855
|
+
const respond501 = (res) => res.status(501).json({
|
|
1856
|
+
code: "NOT_IMPLEMENTED",
|
|
1857
|
+
message: "Sharing service is not configured on this deployment"
|
|
1858
|
+
});
|
|
1859
|
+
this.routeManager.register({
|
|
1860
|
+
method: "GET",
|
|
1861
|
+
path: `${dataPath}/:object/:id/shares`,
|
|
1862
|
+
handler: async (req, res) => {
|
|
1863
|
+
try {
|
|
1864
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1865
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1866
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1867
|
+
const svc = await resolveService(projectId);
|
|
1868
|
+
if (!svc) return respond501(res);
|
|
1869
|
+
const rows = await svc.listShares(req.params.object, req.params.id, context ?? {});
|
|
1870
|
+
res.json({ data: rows });
|
|
1871
|
+
} catch (error) {
|
|
1872
|
+
logError("[REST] List shares error:", error);
|
|
1873
|
+
res.status(500).json({ code: "SHARES_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
1874
|
+
}
|
|
1875
|
+
},
|
|
1876
|
+
metadata: { summary: "List per-record sharing grants", tags: ["sharing"] }
|
|
1877
|
+
});
|
|
1878
|
+
this.routeManager.register({
|
|
1879
|
+
method: "POST",
|
|
1880
|
+
path: `${dataPath}/:object/:id/shares`,
|
|
1881
|
+
handler: async (req, res) => {
|
|
1882
|
+
try {
|
|
1883
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1884
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1885
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1886
|
+
const svc = await resolveService(projectId);
|
|
1887
|
+
if (!svc) return respond501(res);
|
|
1888
|
+
const body = req.body ?? {};
|
|
1889
|
+
const input = {
|
|
1890
|
+
object: req.params.object,
|
|
1891
|
+
recordId: req.params.id,
|
|
1892
|
+
recipientType: body.recipientType ?? body.recipient_type,
|
|
1893
|
+
recipientId: body.recipientId ?? body.recipient_id,
|
|
1894
|
+
accessLevel: body.accessLevel ?? body.access_level,
|
|
1895
|
+
source: body.source,
|
|
1896
|
+
sourceId: body.sourceId ?? body.source_id,
|
|
1897
|
+
reason: body.reason
|
|
1898
|
+
};
|
|
1899
|
+
try {
|
|
1900
|
+
const row = await svc.grant(input, context ?? {});
|
|
1901
|
+
res.status(201).json(row);
|
|
1902
|
+
} catch (err) {
|
|
1903
|
+
const msg = String(err?.message ?? err ?? "");
|
|
1904
|
+
if (msg.startsWith("VALIDATION_FAILED")) {
|
|
1905
|
+
res.status(400).json({
|
|
1906
|
+
code: "VALIDATION_FAILED",
|
|
1907
|
+
error: msg.replace(/^VALIDATION_FAILED:\s*/, "")
|
|
1908
|
+
});
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
throw err;
|
|
1912
|
+
}
|
|
1913
|
+
} catch (error) {
|
|
1914
|
+
logError("[REST] Grant share error:", error);
|
|
1915
|
+
res.status(500).json({ code: "SHARE_GRANT_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
1916
|
+
}
|
|
1917
|
+
},
|
|
1918
|
+
metadata: { summary: "Grant a per-record share to a principal", tags: ["sharing"] }
|
|
1919
|
+
});
|
|
1920
|
+
this.routeManager.register({
|
|
1921
|
+
method: "DELETE",
|
|
1922
|
+
path: `${dataPath}/:object/:id/shares/:shareId`,
|
|
1923
|
+
handler: async (req, res) => {
|
|
1924
|
+
try {
|
|
1925
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1926
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1927
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1928
|
+
const svc = await resolveService(projectId);
|
|
1929
|
+
if (!svc) return respond501(res);
|
|
1930
|
+
await svc.revoke(req.params.shareId, context ?? {});
|
|
1931
|
+
res.status(204).end();
|
|
1932
|
+
} catch (error) {
|
|
1933
|
+
logError("[REST] Revoke share error:", error);
|
|
1934
|
+
res.status(500).json({ code: "SHARE_REVOKE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
1935
|
+
}
|
|
1936
|
+
},
|
|
1937
|
+
metadata: { summary: "Revoke a per-record share by id", tags: ["sharing"] }
|
|
1938
|
+
});
|
|
1939
|
+
}
|
|
1940
|
+
/**
|
|
1941
|
+
* Register sharing-rule endpoints (M10.17). Mirrors the existing
|
|
1942
|
+
* sharing endpoints but operates on `sys_sharing_rule` rows.
|
|
1943
|
+
*
|
|
1944
|
+
* GET {basePath}/sharing/rules?object=&activeOnly=
|
|
1945
|
+
* POST {basePath}/sharing/rules
|
|
1946
|
+
* GET {basePath}/sharing/rules/:idOrName
|
|
1947
|
+
* DELETE {basePath}/sharing/rules/:idOrName
|
|
1948
|
+
* POST {basePath}/sharing/rules/:idOrName/evaluate
|
|
1949
|
+
*
|
|
1950
|
+
* Returns 501 when no sharing-rule service is configured.
|
|
1951
|
+
*/
|
|
1952
|
+
registerSharingRuleEndpoints(basePath) {
|
|
1953
|
+
const dataPath = basePath;
|
|
1954
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
1955
|
+
const resolveService = async (projectId) => {
|
|
1956
|
+
if (!this.sharingRulesServiceProvider) return void 0;
|
|
1957
|
+
try {
|
|
1958
|
+
return await this.sharingRulesServiceProvider(projectId);
|
|
1959
|
+
} catch {
|
|
1960
|
+
return void 0;
|
|
1961
|
+
}
|
|
1962
|
+
};
|
|
1963
|
+
const respond501 = (res) => res.status(501).json({
|
|
1964
|
+
code: "NOT_IMPLEMENTED",
|
|
1965
|
+
message: "Sharing-rule service is not configured on this deployment"
|
|
1966
|
+
});
|
|
1967
|
+
const handleError = (err, res, defaultCode) => {
|
|
1968
|
+
const msg = String(err?.message ?? err ?? "");
|
|
1969
|
+
if (msg.startsWith("VALIDATION_FAILED")) {
|
|
1970
|
+
return res.status(400).json({ code: "VALIDATION_FAILED", error: msg.replace(/^VALIDATION_FAILED:\s*/, "") });
|
|
1971
|
+
}
|
|
1972
|
+
if (msg.startsWith("RULE_NOT_FOUND")) {
|
|
1973
|
+
return res.status(404).json({ code: "RULE_NOT_FOUND", error: msg.replace(/^RULE_NOT_FOUND:?\s*/, "") });
|
|
1974
|
+
}
|
|
1975
|
+
logError(`[REST] sharing-rule ${defaultCode}:`, err);
|
|
1976
|
+
return res.status(500).json({ code: defaultCode, error: msg.slice(0, 500) });
|
|
1977
|
+
};
|
|
1978
|
+
this.routeManager.register({
|
|
1979
|
+
method: "GET",
|
|
1980
|
+
path: `${dataPath}/sharing/rules`,
|
|
1981
|
+
handler: async (req, res) => {
|
|
1982
|
+
try {
|
|
1983
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1984
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1985
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1986
|
+
const svc = await resolveService(projectId);
|
|
1987
|
+
if (!svc) return respond501(res);
|
|
1988
|
+
const rows = await svc.listRules({
|
|
1989
|
+
object: req.query?.object,
|
|
1990
|
+
activeOnly: req.query?.activeOnly === "true" || req.query?.activeOnly === true
|
|
1991
|
+
}, context ?? {});
|
|
1992
|
+
res.json({ data: rows });
|
|
1993
|
+
} catch (err) {
|
|
1994
|
+
handleError(err, res, "RULE_LIST_FAILED");
|
|
1995
|
+
}
|
|
1996
|
+
},
|
|
1997
|
+
metadata: { summary: "List sharing rules", tags: ["sharing"] }
|
|
1998
|
+
});
|
|
1999
|
+
this.routeManager.register({
|
|
2000
|
+
method: "POST",
|
|
2001
|
+
path: `${dataPath}/sharing/rules`,
|
|
2002
|
+
handler: async (req, res) => {
|
|
2003
|
+
try {
|
|
2004
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2005
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2006
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2007
|
+
const svc = await resolveService(projectId);
|
|
2008
|
+
if (!svc) return respond501(res);
|
|
2009
|
+
const body = req.body ?? {};
|
|
2010
|
+
const input = {
|
|
2011
|
+
name: body.name,
|
|
2012
|
+
label: body.label,
|
|
2013
|
+
description: body.description,
|
|
2014
|
+
object: body.object ?? body.object_name,
|
|
2015
|
+
criteria: body.criteria,
|
|
2016
|
+
recipientType: body.recipientType ?? body.recipient_type,
|
|
2017
|
+
recipientId: body.recipientId ?? body.recipient_id,
|
|
2018
|
+
accessLevel: body.accessLevel ?? body.access_level,
|
|
2019
|
+
active: body.active
|
|
2020
|
+
};
|
|
2021
|
+
const row = await svc.defineRule(input, context ?? {});
|
|
2022
|
+
res.status(201).json(row);
|
|
2023
|
+
} catch (err) {
|
|
2024
|
+
handleError(err, res, "RULE_DEFINE_FAILED");
|
|
2025
|
+
}
|
|
2026
|
+
},
|
|
2027
|
+
metadata: { summary: "Create or upsert a sharing rule", tags: ["sharing"] }
|
|
2028
|
+
});
|
|
2029
|
+
this.routeManager.register({
|
|
2030
|
+
method: "GET",
|
|
2031
|
+
path: `${dataPath}/sharing/rules/:idOrName`,
|
|
2032
|
+
handler: async (req, res) => {
|
|
2033
|
+
try {
|
|
2034
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2035
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2036
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2037
|
+
const svc = await resolveService(projectId);
|
|
2038
|
+
if (!svc) return respond501(res);
|
|
2039
|
+
const row = await svc.getRule(req.params.idOrName, context ?? {});
|
|
2040
|
+
if (!row) return res.status(404).json({ code: "RULE_NOT_FOUND" });
|
|
2041
|
+
res.json(row);
|
|
2042
|
+
} catch (err) {
|
|
2043
|
+
handleError(err, res, "RULE_GET_FAILED");
|
|
2044
|
+
}
|
|
2045
|
+
},
|
|
2046
|
+
metadata: { summary: "Get a sharing rule by id or name", tags: ["sharing"] }
|
|
2047
|
+
});
|
|
2048
|
+
this.routeManager.register({
|
|
2049
|
+
method: "DELETE",
|
|
2050
|
+
path: `${dataPath}/sharing/rules/:idOrName`,
|
|
2051
|
+
handler: async (req, res) => {
|
|
2052
|
+
try {
|
|
2053
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2054
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2055
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2056
|
+
const svc = await resolveService(projectId);
|
|
2057
|
+
if (!svc) return respond501(res);
|
|
2058
|
+
await svc.deleteRule(req.params.idOrName, context ?? {});
|
|
2059
|
+
res.status(204).end();
|
|
2060
|
+
} catch (err) {
|
|
2061
|
+
handleError(err, res, "RULE_DELETE_FAILED");
|
|
2062
|
+
}
|
|
2063
|
+
},
|
|
2064
|
+
metadata: { summary: "Delete a sharing rule and its materialised grants", tags: ["sharing"] }
|
|
2065
|
+
});
|
|
2066
|
+
this.routeManager.register({
|
|
2067
|
+
method: "POST",
|
|
2068
|
+
path: `${dataPath}/sharing/rules/:idOrName/evaluate`,
|
|
2069
|
+
handler: async (req, res) => {
|
|
2070
|
+
try {
|
|
2071
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2072
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2073
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2074
|
+
const svc = await resolveService(projectId);
|
|
2075
|
+
if (!svc) return respond501(res);
|
|
2076
|
+
const result = await svc.evaluateRule(req.params.idOrName, context ?? {});
|
|
2077
|
+
res.json(result);
|
|
2078
|
+
} catch (err) {
|
|
2079
|
+
handleError(err, res, "RULE_EVALUATE_FAILED");
|
|
2080
|
+
}
|
|
2081
|
+
},
|
|
2082
|
+
metadata: { summary: "Re-evaluate a sharing rule and reconcile grants", tags: ["sharing"] }
|
|
2083
|
+
});
|
|
2084
|
+
}
|
|
2085
|
+
/**
|
|
2086
|
+
* Register saved-report + scheduled-digest endpoints (M11.C16).
|
|
2087
|
+
*
|
|
2088
|
+
* Surfaces `IReportService` over HTTP so the UI can build,
|
|
2089
|
+
* run, and schedule reports without dropping to ObjectQL. Routes
|
|
2090
|
+
* live at the top of the API surface (alongside `/approvals` and
|
|
2091
|
+
* `/sharing`) — reports are a tenant-wide capability, not a record
|
|
2092
|
+
* on a specific CRUD object:
|
|
2093
|
+
*
|
|
2094
|
+
* GET {basePath}/reports?object=&ownerId=
|
|
2095
|
+
* POST {basePath}/reports
|
|
2096
|
+
* GET {basePath}/reports/:id
|
|
2097
|
+
* DELETE {basePath}/reports/:id
|
|
2098
|
+
* POST {basePath}/reports/:id/run
|
|
2099
|
+
* POST {basePath}/reports/:id/schedule
|
|
2100
|
+
* GET {basePath}/reports/:id/schedules
|
|
2101
|
+
* DELETE {basePath}/reports/schedules/:scheduleId
|
|
2102
|
+
*
|
|
2103
|
+
* All routes return 501 when `reportsServiceProvider` is unset so
|
|
2104
|
+
* a deployment without `@objectstack/plugin-reports` fails cleanly.
|
|
2105
|
+
*/
|
|
2106
|
+
registerReportsEndpoints(basePath) {
|
|
2107
|
+
const dataPath = basePath;
|
|
2108
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
2109
|
+
const resolveService = async (projectId) => {
|
|
2110
|
+
if (!this.reportsServiceProvider) return void 0;
|
|
2111
|
+
try {
|
|
2112
|
+
return await this.reportsServiceProvider(projectId);
|
|
2113
|
+
} catch {
|
|
2114
|
+
return void 0;
|
|
2115
|
+
}
|
|
2116
|
+
};
|
|
2117
|
+
const respond501 = (res) => res.status(501).json({
|
|
2118
|
+
code: "NOT_IMPLEMENTED",
|
|
2119
|
+
message: "Reports service is not configured on this deployment"
|
|
2120
|
+
});
|
|
2121
|
+
const handleValidation = (res, err) => {
|
|
2122
|
+
const msg = String(err?.message ?? err ?? "");
|
|
2123
|
+
if (msg.startsWith("VALIDATION_FAILED")) {
|
|
2124
|
+
res.status(400).json({
|
|
2125
|
+
code: "VALIDATION_FAILED",
|
|
2126
|
+
error: msg.replace(/^VALIDATION_FAILED:\s*/, "")
|
|
2127
|
+
});
|
|
2128
|
+
return true;
|
|
2129
|
+
}
|
|
2130
|
+
if (msg.startsWith("REPORT_NOT_FOUND")) {
|
|
2131
|
+
res.status(404).json({ code: "REPORT_NOT_FOUND", error: msg });
|
|
2132
|
+
return true;
|
|
2133
|
+
}
|
|
2134
|
+
return false;
|
|
2135
|
+
};
|
|
2136
|
+
this.routeManager.register({
|
|
2137
|
+
method: "GET",
|
|
2138
|
+
path: `${dataPath}/reports`,
|
|
2139
|
+
handler: async (req, res) => {
|
|
2140
|
+
try {
|
|
2141
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2142
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2143
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2144
|
+
const svc = await resolveService(projectId);
|
|
2145
|
+
if (!svc) return respond501(res);
|
|
2146
|
+
const q = req.query ?? {};
|
|
2147
|
+
const rows = await svc.listReports({ object: q.object, ownerId: q.ownerId }, context ?? {});
|
|
2148
|
+
res.json({ data: rows });
|
|
2149
|
+
} catch (error) {
|
|
2150
|
+
logError("[REST] List reports error:", error);
|
|
2151
|
+
res.status(500).json({ code: "REPORTS_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2152
|
+
}
|
|
2153
|
+
},
|
|
2154
|
+
metadata: { summary: "List saved reports", tags: ["reports"] }
|
|
2155
|
+
});
|
|
2156
|
+
this.routeManager.register({
|
|
2157
|
+
method: "POST",
|
|
2158
|
+
path: `${dataPath}/reports`,
|
|
2159
|
+
handler: async (req, res) => {
|
|
2160
|
+
try {
|
|
2161
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2162
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2163
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2164
|
+
const svc = await resolveService(projectId);
|
|
2165
|
+
if (!svc) return respond501(res);
|
|
2166
|
+
try {
|
|
2167
|
+
const row = await svc.saveReport(req.body ?? {}, context ?? {});
|
|
2168
|
+
res.status(201).json(row);
|
|
2169
|
+
} catch (err) {
|
|
2170
|
+
if (handleValidation(res, err)) return;
|
|
2171
|
+
throw err;
|
|
2172
|
+
}
|
|
2173
|
+
} catch (error) {
|
|
2174
|
+
logError("[REST] Save report error:", error);
|
|
2175
|
+
res.status(500).json({ code: "REPORT_SAVE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2176
|
+
}
|
|
2177
|
+
},
|
|
2178
|
+
metadata: { summary: "Create or update a saved report", tags: ["reports"] }
|
|
2179
|
+
});
|
|
2180
|
+
this.routeManager.register({
|
|
2181
|
+
method: "GET",
|
|
2182
|
+
path: `${dataPath}/reports/:id`,
|
|
2183
|
+
handler: async (req, res) => {
|
|
2184
|
+
try {
|
|
2185
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2186
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2187
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2188
|
+
const svc = await resolveService(projectId);
|
|
2189
|
+
if (!svc) return respond501(res);
|
|
2190
|
+
const row = await svc.getReport(req.params.id, context ?? {});
|
|
2191
|
+
if (!row) {
|
|
2192
|
+
res.status(404).json({ code: "REPORT_NOT_FOUND", error: `Report ${req.params.id} not found` });
|
|
2193
|
+
return;
|
|
2194
|
+
}
|
|
2195
|
+
res.json(row);
|
|
2196
|
+
} catch (error) {
|
|
2197
|
+
logError("[REST] Get report error:", error);
|
|
2198
|
+
res.status(500).json({ code: "REPORT_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2199
|
+
}
|
|
2200
|
+
},
|
|
2201
|
+
metadata: { summary: "Get a saved report by id", tags: ["reports"] }
|
|
2202
|
+
});
|
|
2203
|
+
this.routeManager.register({
|
|
2204
|
+
method: "DELETE",
|
|
2205
|
+
path: `${dataPath}/reports/:id`,
|
|
2206
|
+
handler: async (req, res) => {
|
|
2207
|
+
try {
|
|
2208
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2209
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2210
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2211
|
+
const svc = await resolveService(projectId);
|
|
2212
|
+
if (!svc) return respond501(res);
|
|
2213
|
+
await svc.deleteReport(req.params.id, context ?? {});
|
|
2214
|
+
res.status(204).end();
|
|
2215
|
+
} catch (error) {
|
|
2216
|
+
logError("[REST] Delete report error:", error);
|
|
2217
|
+
res.status(500).json({ code: "REPORT_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2218
|
+
}
|
|
2219
|
+
},
|
|
2220
|
+
metadata: { summary: "Delete a saved report (cascades schedules)", tags: ["reports"] }
|
|
2221
|
+
});
|
|
2222
|
+
this.routeManager.register({
|
|
2223
|
+
method: "POST",
|
|
2224
|
+
path: `${dataPath}/reports/:id/run`,
|
|
2225
|
+
handler: async (req, res) => {
|
|
2226
|
+
try {
|
|
2227
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2228
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2229
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2230
|
+
const svc = await resolveService(projectId);
|
|
2231
|
+
if (!svc) return respond501(res);
|
|
2232
|
+
try {
|
|
2233
|
+
const result = await svc.run(req.params.id, context ?? {});
|
|
2234
|
+
res.json(result);
|
|
2235
|
+
} catch (err) {
|
|
2236
|
+
if (handleValidation(res, err)) return;
|
|
2237
|
+
throw err;
|
|
2238
|
+
}
|
|
2239
|
+
} catch (error) {
|
|
2240
|
+
logError("[REST] Run report error:", error);
|
|
2241
|
+
res.status(500).json({ code: "REPORT_RUN_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2242
|
+
}
|
|
2243
|
+
},
|
|
2244
|
+
metadata: { summary: "Execute a saved report and return rendered output", tags: ["reports"] }
|
|
2245
|
+
});
|
|
2246
|
+
this.routeManager.register({
|
|
2247
|
+
method: "POST",
|
|
2248
|
+
path: `${dataPath}/reports/:id/schedule`,
|
|
2249
|
+
handler: async (req, res) => {
|
|
2250
|
+
try {
|
|
2251
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2252
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2253
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2254
|
+
const svc = await resolveService(projectId);
|
|
2255
|
+
if (!svc) return respond501(res);
|
|
2256
|
+
const body = req.body ?? {};
|
|
2257
|
+
try {
|
|
2258
|
+
const row = await svc.scheduleReport({
|
|
2259
|
+
reportId: req.params.id,
|
|
2260
|
+
recipients: body.recipients ?? [],
|
|
2261
|
+
name: body.name,
|
|
2262
|
+
intervalMinutes: body.intervalMinutes ?? body.interval_minutes,
|
|
2263
|
+
cronExpression: body.cronExpression ?? body.cron_expression,
|
|
2264
|
+
timezone: body.timezone,
|
|
2265
|
+
format: body.format,
|
|
2266
|
+
subjectTemplate: body.subjectTemplate ?? body.subject_template,
|
|
2267
|
+
ownerId: body.ownerId ?? body.owner_id,
|
|
2268
|
+
active: body.active
|
|
2269
|
+
}, context ?? {});
|
|
2270
|
+
res.status(201).json(row);
|
|
2271
|
+
} catch (err) {
|
|
2272
|
+
if (handleValidation(res, err)) return;
|
|
2273
|
+
throw err;
|
|
2274
|
+
}
|
|
2275
|
+
} catch (error) {
|
|
2276
|
+
logError("[REST] Schedule report error:", error);
|
|
2277
|
+
res.status(500).json({ code: "REPORT_SCHEDULE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2278
|
+
}
|
|
2279
|
+
},
|
|
2280
|
+
metadata: { summary: "Create a recurring email schedule for a report", tags: ["reports"] }
|
|
2281
|
+
});
|
|
2282
|
+
this.routeManager.register({
|
|
2283
|
+
method: "GET",
|
|
2284
|
+
path: `${dataPath}/reports/:id/schedules`,
|
|
2285
|
+
handler: async (req, res) => {
|
|
2286
|
+
try {
|
|
2287
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2288
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2289
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2290
|
+
const svc = await resolveService(projectId);
|
|
2291
|
+
if (!svc) return respond501(res);
|
|
2292
|
+
const rows = await svc.listSchedules({ reportId: req.params.id }, context ?? {});
|
|
2293
|
+
res.json({ data: rows });
|
|
2294
|
+
} catch (error) {
|
|
2295
|
+
logError("[REST] List schedules error:", error);
|
|
2296
|
+
res.status(500).json({ code: "SCHEDULES_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2297
|
+
}
|
|
2298
|
+
},
|
|
2299
|
+
metadata: { summary: "List schedules for a report", tags: ["reports"] }
|
|
2300
|
+
});
|
|
2301
|
+
this.routeManager.register({
|
|
2302
|
+
method: "DELETE",
|
|
2303
|
+
path: `${dataPath}/reports/schedules/:scheduleId`,
|
|
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.unscheduleReport(req.params.scheduleId, context ?? {});
|
|
2312
|
+
res.status(204).end();
|
|
2313
|
+
} catch (error) {
|
|
2314
|
+
logError("[REST] Unschedule report error:", error);
|
|
2315
|
+
res.status(500).json({ code: "SCHEDULE_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2316
|
+
}
|
|
2317
|
+
},
|
|
2318
|
+
metadata: { summary: "Delete a report schedule by id", tags: ["reports"] }
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
/**
|
|
2322
|
+
* Register approval engine endpoints.
|
|
2323
|
+
*
|
|
2324
|
+
* Routes (all under {basePath}/approvals):
|
|
2325
|
+
* GET /processes — list approval processes
|
|
2326
|
+
* POST /processes — upsert (defineProcess)
|
|
2327
|
+
* GET /processes/:id — get by id or name
|
|
2328
|
+
* DELETE /processes/:id — delete process
|
|
2329
|
+
* POST /requests — submit
|
|
2330
|
+
* GET /requests — list (filters: status, object, recordId, approverId, submitterId)
|
|
2331
|
+
* GET /requests/:id — get request
|
|
2332
|
+
* POST /requests/:id/approve — approve current step
|
|
2333
|
+
* POST /requests/:id/reject — reject current step
|
|
2334
|
+
* POST /requests/:id/recall — recall (submitter only)
|
|
2335
|
+
* GET /requests/:id/actions — audit trail
|
|
2336
|
+
*
|
|
2337
|
+
* Returns 501 when `approvalsServiceProvider` is unset so deployments
|
|
2338
|
+
* without `@objectstack/plugin-approvals` fail cleanly.
|
|
2339
|
+
*/
|
|
2340
|
+
registerApprovalsEndpoints(basePath) {
|
|
2341
|
+
const dataPath = basePath;
|
|
2342
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
2343
|
+
const resolveService = async (projectId) => {
|
|
2344
|
+
if (!this.approvalsServiceProvider) return void 0;
|
|
2345
|
+
try {
|
|
2346
|
+
return await this.approvalsServiceProvider(projectId);
|
|
2347
|
+
} catch {
|
|
2348
|
+
return void 0;
|
|
2349
|
+
}
|
|
2350
|
+
};
|
|
2351
|
+
const respond501 = (res) => res.status(501).json({
|
|
2352
|
+
code: "NOT_IMPLEMENTED",
|
|
2353
|
+
message: "Approvals service is not configured on this deployment"
|
|
2354
|
+
});
|
|
2355
|
+
const handleApprovalError = (res, err) => {
|
|
2356
|
+
const msg = String(err?.message ?? err ?? "");
|
|
2357
|
+
const mapping = [
|
|
2358
|
+
[/^VALIDATION_FAILED/, 400, "VALIDATION_FAILED"],
|
|
2359
|
+
[/^DUPLICATE_REQUEST/, 409, "DUPLICATE_REQUEST"],
|
|
2360
|
+
[/^INVALID_STATE/, 409, "INVALID_STATE"],
|
|
2361
|
+
[/^FORBIDDEN/, 403, "FORBIDDEN"],
|
|
2362
|
+
[/^NO_ACTIVE_PROCESS/, 404, "NO_ACTIVE_PROCESS"],
|
|
2363
|
+
[/^PROCESS_NOT_FOUND/, 404, "PROCESS_NOT_FOUND"],
|
|
2364
|
+
[/^REQUEST_NOT_FOUND/, 404, "REQUEST_NOT_FOUND"]
|
|
2365
|
+
];
|
|
2366
|
+
for (const [re, status, code] of mapping) {
|
|
2367
|
+
if (re.test(msg)) {
|
|
2368
|
+
res.status(status).json({ code, error: msg.replace(/^[A-Z_]+:\s*/, "") });
|
|
2369
|
+
return true;
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
return false;
|
|
2373
|
+
};
|
|
2374
|
+
this.routeManager.register({
|
|
2375
|
+
method: "GET",
|
|
2376
|
+
path: `${dataPath}/approvals/processes`,
|
|
2377
|
+
handler: async (req, res) => {
|
|
2378
|
+
try {
|
|
2379
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2380
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2381
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2382
|
+
const svc = await resolveService(projectId);
|
|
2383
|
+
if (!svc) return respond501(res);
|
|
2384
|
+
const q = req.query ?? {};
|
|
2385
|
+
const rows = await svc.listProcesses({
|
|
2386
|
+
object: q.object,
|
|
2387
|
+
activeOnly: q.activeOnly === "true" || q.activeOnly === true
|
|
2388
|
+
}, context ?? {});
|
|
2389
|
+
res.json({ data: rows });
|
|
2390
|
+
} catch (error) {
|
|
2391
|
+
logError("[REST] List approval processes error:", error);
|
|
2392
|
+
res.status(500).json({ code: "APPROVAL_PROCESS_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2393
|
+
}
|
|
2394
|
+
},
|
|
2395
|
+
metadata: { summary: "List approval processes", tags: ["approvals"] }
|
|
2396
|
+
});
|
|
2397
|
+
this.routeManager.register({
|
|
2398
|
+
method: "POST",
|
|
2399
|
+
path: `${dataPath}/approvals/processes`,
|
|
2400
|
+
handler: async (req, res) => {
|
|
2401
|
+
try {
|
|
2402
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2403
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2404
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2405
|
+
const svc = await resolveService(projectId);
|
|
2406
|
+
if (!svc) return respond501(res);
|
|
2407
|
+
try {
|
|
2408
|
+
const row = await svc.defineProcess(req.body ?? {}, context ?? {});
|
|
2409
|
+
res.status(201).json(row);
|
|
2410
|
+
} catch (err) {
|
|
2411
|
+
if (handleApprovalError(res, err)) return;
|
|
2412
|
+
throw err;
|
|
2413
|
+
}
|
|
2414
|
+
} catch (error) {
|
|
2415
|
+
logError("[REST] Define approval process error:", error);
|
|
2416
|
+
res.status(500).json({ code: "APPROVAL_PROCESS_DEFINE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2417
|
+
}
|
|
2418
|
+
},
|
|
2419
|
+
metadata: { summary: "Define (upsert) an approval process", tags: ["approvals"] }
|
|
2420
|
+
});
|
|
2421
|
+
this.routeManager.register({
|
|
2422
|
+
method: "GET",
|
|
2423
|
+
path: `${dataPath}/approvals/processes/:id`,
|
|
2424
|
+
handler: async (req, res) => {
|
|
2425
|
+
try {
|
|
2426
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2427
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2428
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2429
|
+
const svc = await resolveService(projectId);
|
|
2430
|
+
if (!svc) return respond501(res);
|
|
2431
|
+
const row = await svc.getProcess(req.params.id, context ?? {});
|
|
2432
|
+
if (!row) {
|
|
2433
|
+
res.status(404).json({ code: "PROCESS_NOT_FOUND", error: `Approval process '${req.params.id}' not found` });
|
|
2434
|
+
return;
|
|
2435
|
+
}
|
|
2436
|
+
res.json(row);
|
|
2437
|
+
} catch (error) {
|
|
2438
|
+
logError("[REST] Get approval process error:", error);
|
|
2439
|
+
res.status(500).json({ code: "APPROVAL_PROCESS_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2440
|
+
}
|
|
2441
|
+
},
|
|
2442
|
+
metadata: { summary: "Get an approval process by id or name", tags: ["approvals"] }
|
|
2443
|
+
});
|
|
2444
|
+
this.routeManager.register({
|
|
2445
|
+
method: "DELETE",
|
|
2446
|
+
path: `${dataPath}/approvals/processes/:id`,
|
|
2447
|
+
handler: async (req, res) => {
|
|
2448
|
+
try {
|
|
2449
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2450
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2451
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2452
|
+
const svc = await resolveService(projectId);
|
|
2453
|
+
if (!svc) return respond501(res);
|
|
2454
|
+
await svc.deleteProcess(req.params.id, context ?? {});
|
|
2455
|
+
res.status(204).end();
|
|
2456
|
+
} catch (error) {
|
|
2457
|
+
logError("[REST] Delete approval process error:", error);
|
|
2458
|
+
res.status(500).json({ code: "APPROVAL_PROCESS_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2459
|
+
}
|
|
2460
|
+
},
|
|
2461
|
+
metadata: { summary: "Delete an approval process", tags: ["approvals"] }
|
|
2462
|
+
});
|
|
2463
|
+
this.routeManager.register({
|
|
2464
|
+
method: "POST",
|
|
2465
|
+
path: `${dataPath}/approvals/requests`,
|
|
2466
|
+
handler: async (req, res) => {
|
|
2467
|
+
try {
|
|
2468
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2469
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2470
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2471
|
+
const svc = await resolveService(projectId);
|
|
2472
|
+
if (!svc) return respond501(res);
|
|
2473
|
+
const body = req.body ?? {};
|
|
2474
|
+
try {
|
|
2475
|
+
const row = await svc.submit({
|
|
2476
|
+
object: body.object,
|
|
2477
|
+
recordId: body.recordId ?? body.record_id,
|
|
2478
|
+
processName: body.processName ?? body.process_name,
|
|
2479
|
+
submitterId: body.submitterId ?? body.submitter_id ?? context?.userId,
|
|
2480
|
+
comment: body.comment,
|
|
2481
|
+
payload: body.payload
|
|
2482
|
+
}, context ?? {});
|
|
2483
|
+
res.status(201).json(row);
|
|
2484
|
+
} catch (err) {
|
|
2485
|
+
if (handleApprovalError(res, err)) return;
|
|
2486
|
+
throw err;
|
|
2487
|
+
}
|
|
2488
|
+
} catch (error) {
|
|
2489
|
+
logError("[REST] Submit approval error:", error);
|
|
2490
|
+
res.status(500).json({ code: "APPROVAL_SUBMIT_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2491
|
+
}
|
|
2492
|
+
},
|
|
2493
|
+
metadata: { summary: "Submit a record for approval", tags: ["approvals"] }
|
|
2494
|
+
});
|
|
2495
|
+
this.routeManager.register({
|
|
2496
|
+
method: "GET",
|
|
2497
|
+
path: `${dataPath}/approvals/requests`,
|
|
2498
|
+
handler: async (req, res) => {
|
|
2499
|
+
try {
|
|
2500
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2501
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2502
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2503
|
+
const svc = await resolveService(projectId);
|
|
2504
|
+
if (!svc) {
|
|
2505
|
+
res.json({ data: [] });
|
|
2506
|
+
return;
|
|
2507
|
+
}
|
|
2508
|
+
const q = req.query ?? {};
|
|
2509
|
+
const rows = await svc.listRequests({
|
|
2510
|
+
object: q.object,
|
|
2511
|
+
recordId: q.recordId ?? q.record_id,
|
|
2512
|
+
status: q.status,
|
|
2513
|
+
approverId: q.approverId ?? q.approver_id,
|
|
2514
|
+
submitterId: q.submitterId ?? q.submitter_id
|
|
2515
|
+
}, context ?? {});
|
|
2516
|
+
res.json({ data: rows });
|
|
2517
|
+
} catch (error) {
|
|
2518
|
+
logError("[REST] List approval requests error:", error);
|
|
2519
|
+
res.status(500).json({ code: "APPROVAL_REQUEST_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2520
|
+
}
|
|
2521
|
+
},
|
|
2522
|
+
metadata: { summary: "List approval requests", tags: ["approvals"] }
|
|
2523
|
+
});
|
|
2524
|
+
this.routeManager.register({
|
|
2525
|
+
method: "GET",
|
|
2526
|
+
path: `${dataPath}/approvals/requests/:id`,
|
|
2527
|
+
handler: async (req, res) => {
|
|
2528
|
+
try {
|
|
2529
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2530
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2531
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2532
|
+
const svc = await resolveService(projectId);
|
|
2533
|
+
if (!svc) return respond501(res);
|
|
2534
|
+
const row = await svc.getRequest(req.params.id, context ?? {});
|
|
2535
|
+
if (!row) {
|
|
2536
|
+
res.status(404).json({ code: "REQUEST_NOT_FOUND", error: `Approval request '${req.params.id}' not found` });
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
res.json(row);
|
|
2540
|
+
} catch (error) {
|
|
2541
|
+
logError("[REST] Get approval request error:", error);
|
|
2542
|
+
res.status(500).json({ code: "APPROVAL_REQUEST_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2543
|
+
}
|
|
2544
|
+
},
|
|
2545
|
+
metadata: { summary: "Get an approval request by id", tags: ["approvals"] }
|
|
2546
|
+
});
|
|
2547
|
+
const decisionRoute = (suffix, method) => {
|
|
2548
|
+
this.routeManager.register({
|
|
2549
|
+
method: "POST",
|
|
2550
|
+
path: `${dataPath}/approvals/requests/:id/${suffix}`,
|
|
2551
|
+
handler: async (req, res) => {
|
|
2552
|
+
try {
|
|
2553
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2554
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2555
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2556
|
+
const svc = await resolveService(projectId);
|
|
2557
|
+
if (!svc) return respond501(res);
|
|
2558
|
+
const body = req.body ?? {};
|
|
2559
|
+
try {
|
|
2560
|
+
const out = await svc[method](req.params.id, {
|
|
2561
|
+
actorId: body.actorId ?? body.actor_id ?? context?.userId,
|
|
2562
|
+
comment: body.comment
|
|
2563
|
+
}, context ?? {});
|
|
2564
|
+
res.json(out);
|
|
2565
|
+
} catch (err) {
|
|
2566
|
+
if (handleApprovalError(res, err)) return;
|
|
2567
|
+
throw err;
|
|
2568
|
+
}
|
|
2569
|
+
} catch (error) {
|
|
2570
|
+
logError(`[REST] ${suffix} approval error:`, error);
|
|
2571
|
+
res.status(500).json({ code: `APPROVAL_${suffix.toUpperCase()}_FAILED`, error: String(error?.message ?? error).slice(0, 500) });
|
|
2572
|
+
}
|
|
2573
|
+
},
|
|
2574
|
+
metadata: { summary: `${suffix[0].toUpperCase()}${suffix.slice(1)} an approval request`, tags: ["approvals"] }
|
|
2575
|
+
});
|
|
2576
|
+
};
|
|
2577
|
+
decisionRoute("approve", "approve");
|
|
2578
|
+
decisionRoute("reject", "reject");
|
|
2579
|
+
decisionRoute("recall", "recall");
|
|
2580
|
+
this.routeManager.register({
|
|
2581
|
+
method: "GET",
|
|
2582
|
+
path: `${dataPath}/approvals/requests/:id/actions`,
|
|
2583
|
+
handler: async (req, res) => {
|
|
2584
|
+
try {
|
|
2585
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2586
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2587
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2588
|
+
const svc = await resolveService(projectId);
|
|
2589
|
+
if (!svc) return respond501(res);
|
|
2590
|
+
const rows = await svc.listActions(req.params.id, context ?? {});
|
|
2591
|
+
res.json({ data: rows });
|
|
2592
|
+
} catch (error) {
|
|
2593
|
+
logError("[REST] List approval actions error:", error);
|
|
2594
|
+
res.status(500).json({ code: "APPROVAL_ACTIONS_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2595
|
+
}
|
|
2596
|
+
},
|
|
2597
|
+
metadata: { summary: "List actions (audit trail) for an approval request", tags: ["approvals"] }
|
|
2598
|
+
});
|
|
2599
|
+
}
|
|
2600
|
+
/**
|
|
2601
|
+
* Register batch operation endpoints
|
|
2602
|
+
*/
|
|
2603
|
+
registerBatchEndpoints(basePath) {
|
|
2604
|
+
const { crud, batch } = this.config;
|
|
2605
|
+
const dataPath = `${basePath}${crud.dataPrefix}`;
|
|
2606
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
2607
|
+
const operations = batch.operations;
|
|
2608
|
+
if (batch.enableBatchEndpoint && this.protocol.batchData) {
|
|
2609
|
+
this.routeManager.register({
|
|
2610
|
+
method: "POST",
|
|
2611
|
+
path: `${dataPath}/:object/batch`,
|
|
2612
|
+
handler: async (req, res) => {
|
|
2613
|
+
try {
|
|
2614
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2615
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
2616
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2617
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2618
|
+
const result = await p.batchData({
|
|
2619
|
+
object: req.params.object,
|
|
2620
|
+
request: req.body,
|
|
2621
|
+
...projectId ? { projectId } : {},
|
|
2622
|
+
...context ? { context } : {}
|
|
2623
|
+
});
|
|
2624
|
+
res.json(result);
|
|
2625
|
+
} catch (error) {
|
|
2626
|
+
logError("[REST] Unhandled error:", error);
|
|
2627
|
+
sendError(res, error, req.params?.object);
|
|
2628
|
+
}
|
|
2629
|
+
},
|
|
2630
|
+
metadata: {
|
|
2631
|
+
summary: "Batch operations",
|
|
2632
|
+
tags: ["data", "batch"]
|
|
2633
|
+
}
|
|
2634
|
+
});
|
|
2635
|
+
}
|
|
2636
|
+
if (operations.createMany && this.protocol.createManyData) {
|
|
2637
|
+
this.routeManager.register({
|
|
2638
|
+
method: "POST",
|
|
2639
|
+
path: `${dataPath}/:object/createMany`,
|
|
2640
|
+
handler: async (req, res) => {
|
|
2641
|
+
try {
|
|
2642
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2643
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
2644
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2645
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2646
|
+
const result = await p.createManyData({
|
|
2647
|
+
object: req.params.object,
|
|
2648
|
+
records: req.body || [],
|
|
2649
|
+
...projectId ? { projectId } : {},
|
|
2650
|
+
...context ? { context } : {}
|
|
2651
|
+
});
|
|
2652
|
+
res.status(201).json(result);
|
|
2653
|
+
} catch (error) {
|
|
2654
|
+
logError("[REST] Unhandled error:", error);
|
|
2655
|
+
sendError(res, error, req.params?.object);
|
|
2656
|
+
}
|
|
2657
|
+
},
|
|
2658
|
+
metadata: {
|
|
2659
|
+
summary: "Create multiple records",
|
|
2660
|
+
tags: ["data", "batch"]
|
|
2661
|
+
}
|
|
2662
|
+
});
|
|
2663
|
+
}
|
|
2664
|
+
if (operations.updateMany && this.protocol.updateManyData) {
|
|
2665
|
+
this.routeManager.register({
|
|
2666
|
+
method: "POST",
|
|
2667
|
+
path: `${dataPath}/:object/updateMany`,
|
|
2668
|
+
handler: async (req, res) => {
|
|
2669
|
+
try {
|
|
2670
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2671
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
2672
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2673
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2674
|
+
const result = await p.updateManyData({
|
|
2675
|
+
object: req.params.object,
|
|
2676
|
+
...req.body,
|
|
2677
|
+
...projectId ? { projectId } : {},
|
|
2678
|
+
...context ? { context } : {}
|
|
2679
|
+
});
|
|
2680
|
+
res.json(result);
|
|
2681
|
+
} catch (error) {
|
|
2682
|
+
logError("[REST] Unhandled error:", error);
|
|
2683
|
+
sendError(res, error, req.params?.object);
|
|
2684
|
+
}
|
|
2685
|
+
},
|
|
2686
|
+
metadata: {
|
|
2687
|
+
summary: "Update multiple records",
|
|
2688
|
+
tags: ["data", "batch"]
|
|
2689
|
+
}
|
|
2690
|
+
});
|
|
2691
|
+
}
|
|
1200
2692
|
if (operations.deleteMany && this.protocol.deleteManyData) {
|
|
1201
2693
|
this.routeManager.register({
|
|
1202
2694
|
method: "POST",
|
|
@@ -1205,15 +2697,18 @@ var RestServer = class {
|
|
|
1205
2697
|
try {
|
|
1206
2698
|
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1207
2699
|
const p = await this.resolveProtocol(projectId, req);
|
|
2700
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2701
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1208
2702
|
const result = await p.deleteManyData({
|
|
1209
2703
|
object: req.params.object,
|
|
1210
2704
|
...req.body,
|
|
1211
|
-
...projectId ? { projectId } : {}
|
|
2705
|
+
...projectId ? { projectId } : {},
|
|
2706
|
+
...context ? { context } : {}
|
|
1212
2707
|
});
|
|
1213
2708
|
res.json(result);
|
|
1214
2709
|
} catch (error) {
|
|
1215
2710
|
logError("[REST] Unhandled error:", error);
|
|
1216
|
-
res
|
|
2711
|
+
sendError(res, error, req.params?.object);
|
|
1217
2712
|
}
|
|
1218
2713
|
},
|
|
1219
2714
|
metadata: {
|
|
@@ -1407,6 +2902,41 @@ function createRestApiPlugin(config = {}) {
|
|
|
1407
2902
|
return void 0;
|
|
1408
2903
|
}
|
|
1409
2904
|
};
|
|
2905
|
+
const emailServiceProvider = async (_projectId) => {
|
|
2906
|
+
try {
|
|
2907
|
+
return ctx.getService("email");
|
|
2908
|
+
} catch {
|
|
2909
|
+
return void 0;
|
|
2910
|
+
}
|
|
2911
|
+
};
|
|
2912
|
+
const sharingServiceProvider = async (_projectId) => {
|
|
2913
|
+
try {
|
|
2914
|
+
return ctx.getService("sharing");
|
|
2915
|
+
} catch {
|
|
2916
|
+
return void 0;
|
|
2917
|
+
}
|
|
2918
|
+
};
|
|
2919
|
+
const reportsServiceProvider = async (_projectId) => {
|
|
2920
|
+
try {
|
|
2921
|
+
return ctx.getService("reports");
|
|
2922
|
+
} catch {
|
|
2923
|
+
return void 0;
|
|
2924
|
+
}
|
|
2925
|
+
};
|
|
2926
|
+
const approvalsServiceProvider = async (_projectId) => {
|
|
2927
|
+
try {
|
|
2928
|
+
return ctx.getService("approvals");
|
|
2929
|
+
} catch {
|
|
2930
|
+
return void 0;
|
|
2931
|
+
}
|
|
2932
|
+
};
|
|
2933
|
+
const sharingRulesServiceProvider = async (_projectId) => {
|
|
2934
|
+
try {
|
|
2935
|
+
return ctx.getService("sharingRules");
|
|
2936
|
+
} catch {
|
|
2937
|
+
return void 0;
|
|
2938
|
+
}
|
|
2939
|
+
};
|
|
1410
2940
|
if (!server) {
|
|
1411
2941
|
ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
|
|
1412
2942
|
return;
|
|
@@ -1417,7 +2947,7 @@ function createRestApiPlugin(config = {}) {
|
|
|
1417
2947
|
}
|
|
1418
2948
|
ctx.logger.info("Hydrating REST API from Protocol...");
|
|
1419
2949
|
try {
|
|
1420
|
-
const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider);
|
|
2950
|
+
const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider);
|
|
1421
2951
|
restServer.registerRoutes();
|
|
1422
2952
|
ctx.logger.info("REST API successfully registered");
|
|
1423
2953
|
} catch (err) {
|