@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.js
CHANGED
|
@@ -211,6 +211,17 @@ var RouteGroupBuilder = class {
|
|
|
211
211
|
// src/rest-server.ts
|
|
212
212
|
var logError = (...args) => globalThis.console?.error(...args);
|
|
213
213
|
function mapDataError(error, object) {
|
|
214
|
+
if (error?.code === "VALIDATION_FAILED" || error?.name === "ValidationError") {
|
|
215
|
+
return {
|
|
216
|
+
status: 400,
|
|
217
|
+
body: {
|
|
218
|
+
error: error?.message ?? "Validation failed",
|
|
219
|
+
code: "VALIDATION_FAILED",
|
|
220
|
+
fields: Array.isArray(error?.fields) ? error.fields : [],
|
|
221
|
+
...object ? { object } : {}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
}
|
|
214
225
|
if (error?.code === "PERMISSION_DENIED" || error?.name === "PermissionDeniedError" || typeof error?.message === "string" && error.message.startsWith("[Security] Access denied")) {
|
|
215
226
|
return {
|
|
216
227
|
status: 403,
|
|
@@ -234,6 +245,16 @@ function mapDataError(error, object) {
|
|
|
234
245
|
}
|
|
235
246
|
};
|
|
236
247
|
}
|
|
248
|
+
if (error?.code === "RECORD_NOT_FOUND" || /^Record\s+\S+\s+not found in\s+\S+/i.test(raw)) {
|
|
249
|
+
return {
|
|
250
|
+
status: 404,
|
|
251
|
+
body: {
|
|
252
|
+
error: raw,
|
|
253
|
+
code: "RECORD_NOT_FOUND",
|
|
254
|
+
...object ? { object } : {}
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
}
|
|
237
258
|
const looksLikeUnknownObject = lower.includes("no such table") || lower.includes("relation") && lower.includes("does not exist") || lower.includes("table not found") || lower.includes("unknown object") || lower.includes("object not found") || lower.includes("no driver available") || object !== void 0 && lower.includes(`'${object.toLowerCase()}'`) && lower.includes("not");
|
|
238
259
|
if (looksLikeUnknownObject) {
|
|
239
260
|
return {
|
|
@@ -245,13 +266,132 @@ function mapDataError(error, object) {
|
|
|
245
266
|
}
|
|
246
267
|
};
|
|
247
268
|
}
|
|
269
|
+
const looksLikeSqlLeak = lower.includes("sqlite_") || lower.includes("sqlstate") || lower.startsWith("insert into ") || lower.startsWith("update ") || lower.startsWith("select ") || lower.startsWith("delete from ") || lower.includes("constraint failed") || lower.includes("unique constraint") || lower.includes("foreign key");
|
|
270
|
+
if (looksLikeSqlLeak) {
|
|
271
|
+
if (lower.includes("unique constraint") || lower.includes("unique violation")) {
|
|
272
|
+
return {
|
|
273
|
+
status: 409,
|
|
274
|
+
body: {
|
|
275
|
+
error: "A record with this value already exists",
|
|
276
|
+
code: "UNIQUE_VIOLATION",
|
|
277
|
+
...object ? { object } : {}
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
status: 500,
|
|
283
|
+
body: { error: "Internal data error", code: "DATABASE_ERROR" }
|
|
284
|
+
};
|
|
285
|
+
}
|
|
248
286
|
return { status: 400, body: { error: raw || "Bad request" } };
|
|
249
287
|
}
|
|
288
|
+
function sendError(res, error, object) {
|
|
289
|
+
if (typeof error?.status === "number" && error.status >= 400 && error.status < 600) {
|
|
290
|
+
const safeMsg = typeof error.message === "string" && error.message.length < 500 ? error.message : "Request failed";
|
|
291
|
+
res.status(error.status).json({
|
|
292
|
+
error: safeMsg,
|
|
293
|
+
...error.code ? { code: error.code } : {}
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const mapped = mapDataError(error, object);
|
|
298
|
+
res.status(mapped.status).json(mapped.body);
|
|
299
|
+
}
|
|
250
300
|
function isExpectedDataStatus(status) {
|
|
251
|
-
return status === 403 || status === 404 || status === 502 || status === 503;
|
|
301
|
+
return status === 403 || status === 404 || status === 409 || status === 502 || status === 503;
|
|
302
|
+
}
|
|
303
|
+
function parseCsvToRows(csv, mapping = {}) {
|
|
304
|
+
const text = csv.replace(/^\uFEFF/, "");
|
|
305
|
+
const cells = [];
|
|
306
|
+
let cur = "";
|
|
307
|
+
let row = [];
|
|
308
|
+
let inQuotes = false;
|
|
309
|
+
for (let i = 0; i < text.length; i++) {
|
|
310
|
+
const ch = text[i];
|
|
311
|
+
if (inQuotes) {
|
|
312
|
+
if (ch === '"') {
|
|
313
|
+
if (text[i + 1] === '"') {
|
|
314
|
+
cur += '"';
|
|
315
|
+
i++;
|
|
316
|
+
} else {
|
|
317
|
+
inQuotes = false;
|
|
318
|
+
}
|
|
319
|
+
} else {
|
|
320
|
+
cur += ch;
|
|
321
|
+
}
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (ch === '"') {
|
|
325
|
+
inQuotes = true;
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (ch === ",") {
|
|
329
|
+
row.push(cur);
|
|
330
|
+
cur = "";
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (ch === "\r") {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (ch === "\n") {
|
|
337
|
+
row.push(cur);
|
|
338
|
+
cur = "";
|
|
339
|
+
cells.push(row);
|
|
340
|
+
row = [];
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
cur += ch;
|
|
344
|
+
}
|
|
345
|
+
if (cur.length > 0 || row.length > 0) {
|
|
346
|
+
row.push(cur);
|
|
347
|
+
cells.push(row);
|
|
348
|
+
}
|
|
349
|
+
while (cells.length > 0 && cells[cells.length - 1].every((c) => c === "")) cells.pop();
|
|
350
|
+
if (cells.length < 2) return [];
|
|
351
|
+
const header = cells[0].map((h) => h.trim());
|
|
352
|
+
const fields = header.map((h) => mapping[h] ?? h);
|
|
353
|
+
const out = [];
|
|
354
|
+
for (let r = 1; r < cells.length; r++) {
|
|
355
|
+
const row2 = cells[r];
|
|
356
|
+
const obj = {};
|
|
357
|
+
for (let c = 0; c < fields.length; c++) {
|
|
358
|
+
const key = fields[c];
|
|
359
|
+
if (!key) continue;
|
|
360
|
+
const raw = row2[c] ?? "";
|
|
361
|
+
obj[key] = raw;
|
|
362
|
+
}
|
|
363
|
+
out.push(obj);
|
|
364
|
+
}
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
function formatCsvCell(value) {
|
|
368
|
+
if (value === null || value === void 0) return "";
|
|
369
|
+
let s;
|
|
370
|
+
if (typeof value === "string") s = value;
|
|
371
|
+
else if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") s = String(value);
|
|
372
|
+
else if (value instanceof Date) s = value.toISOString();
|
|
373
|
+
else {
|
|
374
|
+
try {
|
|
375
|
+
s = JSON.stringify(value);
|
|
376
|
+
} catch {
|
|
377
|
+
s = String(value);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (/[",\r\n]/.test(s)) {
|
|
381
|
+
return `"${s.replace(/"/g, '""')}"`;
|
|
382
|
+
}
|
|
383
|
+
return s;
|
|
384
|
+
}
|
|
385
|
+
function rowsToCsv(fields, rows, includeHeader) {
|
|
386
|
+
const lines = [];
|
|
387
|
+
if (includeHeader) lines.push(fields.map(formatCsvCell).join(","));
|
|
388
|
+
for (const row of rows) {
|
|
389
|
+
lines.push(fields.map((f) => formatCsvCell(row?.[f])).join(","));
|
|
390
|
+
}
|
|
391
|
+
return lines.join("\r\n") + (lines.length > 0 ? "\r\n" : "");
|
|
252
392
|
}
|
|
253
393
|
var RestServer = class {
|
|
254
|
-
constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider) {
|
|
394
|
+
constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider) {
|
|
255
395
|
this.protocol = protocol;
|
|
256
396
|
this.config = this.normalizeConfig(config);
|
|
257
397
|
this.routeManager = new RouteManager(server);
|
|
@@ -260,6 +400,11 @@ var RestServer = class {
|
|
|
260
400
|
this.defaultProjectIdProvider = defaultProjectIdProvider;
|
|
261
401
|
this.authServiceProvider = authServiceProvider;
|
|
262
402
|
this.objectQLProvider = objectQLProvider;
|
|
403
|
+
this.emailServiceProvider = emailServiceProvider;
|
|
404
|
+
this.sharingServiceProvider = sharingServiceProvider;
|
|
405
|
+
this.reportsServiceProvider = reportsServiceProvider;
|
|
406
|
+
this.approvalsServiceProvider = approvalsServiceProvider;
|
|
407
|
+
this.sharingRulesServiceProvider = sharingRulesServiceProvider;
|
|
263
408
|
}
|
|
264
409
|
/**
|
|
265
410
|
* Resolve the protocol for a given request. When `projectId` is present
|
|
@@ -336,6 +481,25 @@ var RestServer = class {
|
|
|
336
481
|
return void 0;
|
|
337
482
|
}
|
|
338
483
|
}
|
|
484
|
+
/**
|
|
485
|
+
* Reject anonymous requests with HTTP 401 when `api.requireAuth` is set.
|
|
486
|
+
* Returns `true` if the response was sent and the caller should stop
|
|
487
|
+
* processing. Returns `false` to continue.
|
|
488
|
+
*
|
|
489
|
+
* The check is intentionally narrow: only `context?.userId` counts as
|
|
490
|
+
* "authenticated". `isSystem` flags are never set on inbound HTTP
|
|
491
|
+
* requests (they're internal-only), so they cannot bypass this gate.
|
|
492
|
+
*/
|
|
493
|
+
enforceAuth(req, res, context) {
|
|
494
|
+
if (!this.config.api.requireAuth) return false;
|
|
495
|
+
if (context?.userId) return false;
|
|
496
|
+
if (req?.method === "OPTIONS") return false;
|
|
497
|
+
res.status(401).json({
|
|
498
|
+
error: "unauthenticated",
|
|
499
|
+
message: "Authentication is required to access this endpoint."
|
|
500
|
+
});
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
339
503
|
/**
|
|
340
504
|
* Resolve the request's execution context (RBAC/RLS/FLS) by looking up
|
|
341
505
|
* the better-auth session via the project's `auth` service. Returns
|
|
@@ -344,6 +508,26 @@ var RestServer = class {
|
|
|
344
508
|
*/
|
|
345
509
|
async resolveExecCtx(projectId, req) {
|
|
346
510
|
try {
|
|
511
|
+
if (!projectId && req && this.envRegistry && this.kernelManager) {
|
|
512
|
+
const host = this.extractHostname(req);
|
|
513
|
+
if (host) {
|
|
514
|
+
try {
|
|
515
|
+
const result = await this.envRegistry.resolveByHostname(host);
|
|
516
|
+
if (result?.projectId) projectId = result.projectId;
|
|
517
|
+
} catch {
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (!projectId && typeof this.envRegistry.resolveById === "function") {
|
|
521
|
+
const headerVal = this.extractProjectIdHeader(req);
|
|
522
|
+
if (headerVal) {
|
|
523
|
+
try {
|
|
524
|
+
const driver = await this.envRegistry.resolveById(headerVal);
|
|
525
|
+
if (driver) projectId = headerVal;
|
|
526
|
+
} catch {
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
347
531
|
let authService;
|
|
348
532
|
let kernel;
|
|
349
533
|
if (projectId && projectId !== "platform" && this.kernelManager) {
|
|
@@ -583,8 +767,10 @@ var RestServer = class {
|
|
|
583
767
|
enableUi: api.enableUi ?? true,
|
|
584
768
|
enableBatch: api.enableBatch ?? true,
|
|
585
769
|
enableDiscovery: api.enableDiscovery ?? true,
|
|
770
|
+
enableSearch: api.enableSearch ?? true,
|
|
586
771
|
enableProjectScoping: api.enableProjectScoping ?? false,
|
|
587
772
|
projectResolution: api.projectResolution ?? "auto",
|
|
773
|
+
requireAuth: api.requireAuth ?? false,
|
|
588
774
|
documentation: api.documentation,
|
|
589
775
|
responseFormat: api.responseFormat
|
|
590
776
|
},
|
|
@@ -666,9 +852,18 @@ var RestServer = class {
|
|
|
666
852
|
if (this.config.api.enableUi) {
|
|
667
853
|
this.registerUiEndpoints(bp);
|
|
668
854
|
}
|
|
855
|
+
if (this.config.api.enableSearch ?? true) {
|
|
856
|
+
this.registerSearchEndpoints(bp);
|
|
857
|
+
}
|
|
858
|
+
this.registerEmailEndpoints(bp);
|
|
859
|
+
this.registerSharingEndpoints(bp);
|
|
860
|
+
this.registerSharingRuleEndpoints(bp);
|
|
861
|
+
this.registerReportsEndpoints(bp);
|
|
862
|
+
this.registerApprovalsEndpoints(bp);
|
|
669
863
|
if (this.config.api.enableCrud) {
|
|
670
864
|
this.registerCrudEndpoints(bp);
|
|
671
865
|
}
|
|
866
|
+
this.registerDataActionEndpoints(bp);
|
|
672
867
|
if (this.config.api.enableBatch) {
|
|
673
868
|
this.registerBatchEndpoints(bp);
|
|
674
869
|
}
|
|
@@ -719,7 +914,7 @@ var RestServer = class {
|
|
|
719
914
|
res.json(discovery);
|
|
720
915
|
} catch (error) {
|
|
721
916
|
logError("[REST] Unhandled error:", error);
|
|
722
|
-
res
|
|
917
|
+
sendError(res, error);
|
|
723
918
|
}
|
|
724
919
|
};
|
|
725
920
|
this.routeManager.register({
|
|
@@ -760,7 +955,7 @@ var RestServer = class {
|
|
|
760
955
|
res.json(types);
|
|
761
956
|
} catch (error) {
|
|
762
957
|
logError("[REST] Unhandled error:", error);
|
|
763
|
-
res
|
|
958
|
+
sendError(res, error);
|
|
764
959
|
}
|
|
765
960
|
},
|
|
766
961
|
metadata: {
|
|
@@ -788,7 +983,7 @@ var RestServer = class {
|
|
|
788
983
|
res.json(translated);
|
|
789
984
|
} catch (error) {
|
|
790
985
|
logError("[REST] Unhandled error:", error);
|
|
791
|
-
res
|
|
986
|
+
sendError(res, error);
|
|
792
987
|
}
|
|
793
988
|
},
|
|
794
989
|
metadata: {
|
|
@@ -846,7 +1041,7 @@ var RestServer = class {
|
|
|
846
1041
|
}
|
|
847
1042
|
} catch (error) {
|
|
848
1043
|
logError("[REST] Unhandled error:", error);
|
|
849
|
-
res
|
|
1044
|
+
sendError(res, error);
|
|
850
1045
|
}
|
|
851
1046
|
},
|
|
852
1047
|
metadata: {
|
|
@@ -866,16 +1061,18 @@ var RestServer = class {
|
|
|
866
1061
|
res.status(501).json({ error: "Save operation not supported by protocol implementation" });
|
|
867
1062
|
return;
|
|
868
1063
|
}
|
|
1064
|
+
const body = req.body ?? {};
|
|
1065
|
+
const item = body && typeof body === "object" && "metadata" in body ? body.metadata : body && typeof body === "object" && "item" in body ? body.item : body;
|
|
869
1066
|
const result = await p.saveMetaItem({
|
|
870
1067
|
type: req.params.type,
|
|
871
1068
|
name: req.params.name,
|
|
872
|
-
item
|
|
1069
|
+
item,
|
|
873
1070
|
...projectId ? { projectId } : {}
|
|
874
1071
|
});
|
|
875
1072
|
res.json(result);
|
|
876
1073
|
} catch (error) {
|
|
877
1074
|
logError("[REST] Unhandled error:", error);
|
|
878
|
-
res
|
|
1075
|
+
sendError(res, error);
|
|
879
1076
|
}
|
|
880
1077
|
},
|
|
881
1078
|
metadata: {
|
|
@@ -883,6 +1080,92 @@ var RestServer = class {
|
|
|
883
1080
|
tags: ["metadata"]
|
|
884
1081
|
}
|
|
885
1082
|
});
|
|
1083
|
+
this.routeManager.register({
|
|
1084
|
+
method: "DELETE",
|
|
1085
|
+
path: `${metaPath}/:type/:name`,
|
|
1086
|
+
handler: async (req, res) => {
|
|
1087
|
+
try {
|
|
1088
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1089
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1090
|
+
if (!p.deleteMetaItem) {
|
|
1091
|
+
res.status(501).json({
|
|
1092
|
+
error: "Reset operation not supported by protocol implementation"
|
|
1093
|
+
});
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
const result = await p.deleteMetaItem({
|
|
1097
|
+
type: req.params.type,
|
|
1098
|
+
name: req.params.name,
|
|
1099
|
+
...projectId ? { projectId } : {}
|
|
1100
|
+
});
|
|
1101
|
+
res.json(result);
|
|
1102
|
+
} catch (error) {
|
|
1103
|
+
logError("[REST] Unhandled error:", error);
|
|
1104
|
+
sendError(res, error);
|
|
1105
|
+
}
|
|
1106
|
+
},
|
|
1107
|
+
metadata: {
|
|
1108
|
+
summary: "Reset metadata item to artifact default (deletes customization overlay)",
|
|
1109
|
+
tags: ["metadata"]
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
if (metadata.endpoints.item !== false) {
|
|
1113
|
+
this.routeManager.register({
|
|
1114
|
+
method: "GET",
|
|
1115
|
+
path: `${metaPath}/:type/:section/:name`,
|
|
1116
|
+
handler: async (req, res) => {
|
|
1117
|
+
try {
|
|
1118
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1119
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1120
|
+
const compoundName = `${req.params.section}/${req.params.name}`;
|
|
1121
|
+
const packageId = req.query?.package || void 0;
|
|
1122
|
+
const item = await p.getMetaItem({
|
|
1123
|
+
type: req.params.type,
|
|
1124
|
+
name: compoundName,
|
|
1125
|
+
packageId
|
|
1126
|
+
});
|
|
1127
|
+
res.header("Vary", "Accept-Language");
|
|
1128
|
+
res.json(await this.translateMetaItem(req, req.params.type, projectId, item));
|
|
1129
|
+
} catch (error) {
|
|
1130
|
+
logError("[REST] Unhandled error:", error);
|
|
1131
|
+
sendError(res, error);
|
|
1132
|
+
}
|
|
1133
|
+
},
|
|
1134
|
+
metadata: {
|
|
1135
|
+
summary: "Get specific metadata item by compound name",
|
|
1136
|
+
tags: ["metadata"]
|
|
1137
|
+
}
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
this.routeManager.register({
|
|
1141
|
+
method: "PUT",
|
|
1142
|
+
path: `${metaPath}/:type/:section/:name`,
|
|
1143
|
+
handler: async (req, res) => {
|
|
1144
|
+
try {
|
|
1145
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1146
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1147
|
+
if (!p.saveMetaItem) {
|
|
1148
|
+
res.status(501).json({ error: "Save operation not supported by protocol implementation" });
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
const compoundName = `${req.params.section}/${req.params.name}`;
|
|
1152
|
+
const result = await p.saveMetaItem({
|
|
1153
|
+
type: req.params.type,
|
|
1154
|
+
name: compoundName,
|
|
1155
|
+
item: req.body,
|
|
1156
|
+
...projectId ? { projectId } : {}
|
|
1157
|
+
});
|
|
1158
|
+
res.json(result);
|
|
1159
|
+
} catch (error) {
|
|
1160
|
+
logError("[REST] Unhandled error:", error);
|
|
1161
|
+
sendError(res, error);
|
|
1162
|
+
}
|
|
1163
|
+
},
|
|
1164
|
+
metadata: {
|
|
1165
|
+
summary: "Save specific metadata item by compound name",
|
|
1166
|
+
tags: ["metadata"]
|
|
1167
|
+
}
|
|
1168
|
+
});
|
|
886
1169
|
}
|
|
887
1170
|
/**
|
|
888
1171
|
* Register UI endpoints
|
|
@@ -909,7 +1192,7 @@ var RestServer = class {
|
|
|
909
1192
|
}
|
|
910
1193
|
} catch (error) {
|
|
911
1194
|
logError("[REST] Unhandled error:", error);
|
|
912
|
-
res
|
|
1195
|
+
sendError(res, error, req.params?.object);
|
|
913
1196
|
}
|
|
914
1197
|
},
|
|
915
1198
|
metadata: {
|
|
@@ -935,6 +1218,7 @@ var RestServer = class {
|
|
|
935
1218
|
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
936
1219
|
const p = await this.resolveProtocol(projectId, req);
|
|
937
1220
|
const context = await this.resolveExecCtx(projectId, req);
|
|
1221
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
938
1222
|
const result = await p.findData({
|
|
939
1223
|
object: req.params.object,
|
|
940
1224
|
query: req.query,
|
|
@@ -968,6 +1252,7 @@ var RestServer = class {
|
|
|
968
1252
|
const p = await this.resolveProtocol(projectId, req);
|
|
969
1253
|
const { select, expand } = req.query || {};
|
|
970
1254
|
const context = await this.resolveExecCtx(projectId, req);
|
|
1255
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
971
1256
|
const result = await p.getData({
|
|
972
1257
|
object: req.params.object,
|
|
973
1258
|
id: req.params.id,
|
|
@@ -979,7 +1264,7 @@ var RestServer = class {
|
|
|
979
1264
|
res.json(result);
|
|
980
1265
|
} catch (error) {
|
|
981
1266
|
const mapped = mapDataError(error, req.params?.object);
|
|
982
|
-
if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
|
|
1267
|
+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
|
|
983
1268
|
res.status(mapped.status === 400 ? 404 : mapped.status).json(mapped.body);
|
|
984
1269
|
}
|
|
985
1270
|
},
|
|
@@ -998,6 +1283,7 @@ var RestServer = class {
|
|
|
998
1283
|
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
999
1284
|
const p = await this.resolveProtocol(projectId, req);
|
|
1000
1285
|
const context = await this.resolveExecCtx(projectId, req);
|
|
1286
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1001
1287
|
const result = await p.createData({
|
|
1002
1288
|
object: req.params.object,
|
|
1003
1289
|
data: req.body,
|
|
@@ -1007,7 +1293,7 @@ var RestServer = class {
|
|
|
1007
1293
|
res.status(201).json(result);
|
|
1008
1294
|
} catch (error) {
|
|
1009
1295
|
const mapped = mapDataError(error, req.params?.object);
|
|
1010
|
-
if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
|
|
1296
|
+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
|
|
1011
1297
|
res.status(mapped.status).json(mapped.body);
|
|
1012
1298
|
}
|
|
1013
1299
|
},
|
|
@@ -1017,6 +1303,35 @@ var RestServer = class {
|
|
|
1017
1303
|
}
|
|
1018
1304
|
});
|
|
1019
1305
|
}
|
|
1306
|
+
if (operations.list) {
|
|
1307
|
+
this.routeManager.register({
|
|
1308
|
+
method: "POST",
|
|
1309
|
+
path: `${dataPath}/:object/query`,
|
|
1310
|
+
handler: async (req, res) => {
|
|
1311
|
+
try {
|
|
1312
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1313
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1314
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1315
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1316
|
+
const result = await p.findData({
|
|
1317
|
+
object: req.params.object,
|
|
1318
|
+
query: req.body || {},
|
|
1319
|
+
...projectId ? { projectId } : {},
|
|
1320
|
+
...context ? { context } : {}
|
|
1321
|
+
});
|
|
1322
|
+
res.json(result);
|
|
1323
|
+
} catch (error) {
|
|
1324
|
+
const mapped = mapDataError(error, req.params?.object);
|
|
1325
|
+
if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
|
|
1326
|
+
res.status(mapped.status).json(mapped.body);
|
|
1327
|
+
}
|
|
1328
|
+
},
|
|
1329
|
+
metadata: {
|
|
1330
|
+
summary: "Advanced query (QueryAST in body)",
|
|
1331
|
+
tags: ["data", "crud"]
|
|
1332
|
+
}
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1020
1335
|
if (operations.update) {
|
|
1021
1336
|
this.routeManager.register({
|
|
1022
1337
|
method: "PATCH",
|
|
@@ -1026,6 +1341,7 @@ var RestServer = class {
|
|
|
1026
1341
|
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1027
1342
|
const p = await this.resolveProtocol(projectId, req);
|
|
1028
1343
|
const context = await this.resolveExecCtx(projectId, req);
|
|
1344
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1029
1345
|
const result = await p.updateData({
|
|
1030
1346
|
object: req.params.object,
|
|
1031
1347
|
id: req.params.id,
|
|
@@ -1036,7 +1352,7 @@ var RestServer = class {
|
|
|
1036
1352
|
res.json(result);
|
|
1037
1353
|
} catch (error) {
|
|
1038
1354
|
const mapped = mapDataError(error, req.params?.object);
|
|
1039
|
-
if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
|
|
1355
|
+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
|
|
1040
1356
|
res.status(mapped.status).json(mapped.body);
|
|
1041
1357
|
}
|
|
1042
1358
|
},
|
|
@@ -1055,6 +1371,7 @@ var RestServer = class {
|
|
|
1055
1371
|
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1056
1372
|
const p = await this.resolveProtocol(projectId, req);
|
|
1057
1373
|
const context = await this.resolveExecCtx(projectId, req);
|
|
1374
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1058
1375
|
const result = await p.deleteData({
|
|
1059
1376
|
object: req.params.object,
|
|
1060
1377
|
id: req.params.id,
|
|
@@ -1064,7 +1381,7 @@ var RestServer = class {
|
|
|
1064
1381
|
res.json(result);
|
|
1065
1382
|
} catch (error) {
|
|
1066
1383
|
const mapped = mapDataError(error, req.params?.object);
|
|
1067
|
-
if (!isExpectedDataStatus(mapped.status)) logError("[REST] Unhandled error:", error);
|
|
1384
|
+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") logError("[REST] Unhandled error:", error);
|
|
1068
1385
|
res.status(mapped.status).json(mapped.body);
|
|
1069
1386
|
}
|
|
1070
1387
|
},
|
|
@@ -1076,88 +1393,1263 @@ var RestServer = class {
|
|
|
1076
1393
|
}
|
|
1077
1394
|
}
|
|
1078
1395
|
/**
|
|
1079
|
-
* Register
|
|
1396
|
+
* Register object-specific action endpoints that don't fit the
|
|
1397
|
+
* generic CRUD shape. These are domain operations (Salesforce
|
|
1398
|
+
* convertLead, etc.) where the protocol implementation does its own
|
|
1399
|
+
* multi-record orchestration and we just need a thin HTTP route.
|
|
1400
|
+
*
|
|
1401
|
+
* POST {basePath}/data/lead/:id/convert — M10.6 lead conversion.
|
|
1080
1402
|
*/
|
|
1081
|
-
|
|
1082
|
-
const { crud, batch } = this.config;
|
|
1083
|
-
const dataPath = `${basePath}${crud.dataPrefix}`;
|
|
1403
|
+
registerDataActionEndpoints(basePath) {
|
|
1084
1404
|
const isScoped = basePath.includes("/projects/:projectId");
|
|
1085
|
-
const
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
});
|
|
1099
|
-
|
|
1100
|
-
} catch (error) {
|
|
1101
|
-
logError("[REST] Unhandled error:", error);
|
|
1102
|
-
res.status(400).json({ error: error.message });
|
|
1405
|
+
const { crud } = this.config;
|
|
1406
|
+
const dataPath = `${basePath}${crud.dataPrefix}`;
|
|
1407
|
+
this.routeManager.register({
|
|
1408
|
+
method: "POST",
|
|
1409
|
+
path: `${dataPath}/lead/:id/convert`,
|
|
1410
|
+
handler: async (req, res) => {
|
|
1411
|
+
try {
|
|
1412
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1413
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1414
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1415
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1416
|
+
const convertLead = p.convertLead;
|
|
1417
|
+
if (typeof convertLead !== "function") {
|
|
1418
|
+
res.status(501).json({ code: "NOT_IMPLEMENTED", error: "Lead convert not supported by this protocol" });
|
|
1419
|
+
return;
|
|
1103
1420
|
}
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1421
|
+
const body = req.body ?? {};
|
|
1422
|
+
const result = await convertLead.call(p, {
|
|
1423
|
+
leadId: req.params.id,
|
|
1424
|
+
accountId: body.accountId,
|
|
1425
|
+
contactId: body.contactId,
|
|
1426
|
+
createOpportunity: body.createOpportunity,
|
|
1427
|
+
opportunity: body.opportunity,
|
|
1428
|
+
convertedStatus: body.convertedStatus,
|
|
1429
|
+
...context ? { context } : {}
|
|
1430
|
+
});
|
|
1431
|
+
res.json(result);
|
|
1432
|
+
} catch (error) {
|
|
1433
|
+
logError("[REST] Unhandled error:", error);
|
|
1434
|
+
sendError(res, error, "lead");
|
|
1108
1435
|
}
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1436
|
+
},
|
|
1437
|
+
metadata: {
|
|
1438
|
+
summary: "Convert a Lead into Account + Contact (+ optional Opportunity)",
|
|
1439
|
+
tags: ["data", "lead"]
|
|
1440
|
+
}
|
|
1441
|
+
});
|
|
1442
|
+
this.routeManager.register({
|
|
1443
|
+
method: "POST",
|
|
1444
|
+
path: `${dataPath}/:object/import`,
|
|
1445
|
+
handler: async (req, res) => {
|
|
1446
|
+
try {
|
|
1447
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1448
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1449
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1450
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1451
|
+
const objectName = String(req.params.object || "");
|
|
1452
|
+
if (!objectName) {
|
|
1453
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
const body = req.body ?? {};
|
|
1457
|
+
const dryRun = body.dryRun === true;
|
|
1458
|
+
const mapping = body.mapping ?? {};
|
|
1459
|
+
let rows = [];
|
|
1460
|
+
if (body.format === "json" && Array.isArray(body.rows)) {
|
|
1461
|
+
rows = body.rows;
|
|
1462
|
+
} else if ((body.format === "csv" || typeof body.csv === "string") && typeof body.csv === "string") {
|
|
1463
|
+
rows = parseCsvToRows(body.csv, mapping);
|
|
1464
|
+
} else if (Array.isArray(body)) {
|
|
1465
|
+
rows = body;
|
|
1466
|
+
} else {
|
|
1467
|
+
res.status(400).json({
|
|
1468
|
+
code: "INVALID_REQUEST",
|
|
1469
|
+
error: 'Provide either format:"csv" with csv text or format:"json" with rows[]'
|
|
1123
1470
|
});
|
|
1124
|
-
|
|
1125
|
-
} catch (error) {
|
|
1126
|
-
logError("[REST] Unhandled error:", error);
|
|
1127
|
-
res.status(400).json({ error: error.message });
|
|
1471
|
+
return;
|
|
1128
1472
|
}
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
});
|
|
1135
|
-
}
|
|
1136
|
-
if (operations.updateMany && this.protocol.updateManyData) {
|
|
1137
|
-
this.routeManager.register({
|
|
1138
|
-
method: "POST",
|
|
1139
|
-
path: `${dataPath}/:object/updateMany`,
|
|
1140
|
-
handler: async (req, res) => {
|
|
1141
|
-
try {
|
|
1142
|
-
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1143
|
-
const p = await this.resolveProtocol(projectId, req);
|
|
1144
|
-
const result = await p.updateManyData({
|
|
1145
|
-
object: req.params.object,
|
|
1146
|
-
...req.body,
|
|
1147
|
-
...projectId ? { projectId } : {}
|
|
1473
|
+
const max = 5e3;
|
|
1474
|
+
if (rows.length > max) {
|
|
1475
|
+
res.status(413).json({
|
|
1476
|
+
code: "PAYLOAD_TOO_LARGE",
|
|
1477
|
+
error: `Import limit is ${max} rows per request (got ${rows.length})`
|
|
1148
1478
|
});
|
|
1149
|
-
|
|
1150
|
-
} catch (error) {
|
|
1151
|
-
logError("[REST] Unhandled error:", error);
|
|
1152
|
-
res.status(400).json({ error: error.message });
|
|
1479
|
+
return;
|
|
1153
1480
|
}
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1481
|
+
const results = [];
|
|
1482
|
+
let okCount = 0;
|
|
1483
|
+
let errCount = 0;
|
|
1484
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1485
|
+
const data = rows[i];
|
|
1486
|
+
try {
|
|
1487
|
+
if (dryRun) {
|
|
1488
|
+
const validate = p.validate;
|
|
1489
|
+
if (typeof validate === "function") {
|
|
1490
|
+
await validate.call(p, { object: objectName, data, context });
|
|
1491
|
+
}
|
|
1492
|
+
results.push({ row: i + 1, ok: true });
|
|
1493
|
+
okCount++;
|
|
1494
|
+
} else {
|
|
1495
|
+
const created = await p.createData({ object: objectName, data, context });
|
|
1496
|
+
const id = created?.id ?? created?.record?.id;
|
|
1497
|
+
results.push({ row: i + 1, ok: true, id });
|
|
1498
|
+
okCount++;
|
|
1499
|
+
}
|
|
1500
|
+
} catch (err) {
|
|
1501
|
+
errCount++;
|
|
1502
|
+
const code = err?.code ?? "IMPORT_ROW_FAILED";
|
|
1503
|
+
const message = typeof err?.message === "string" ? err.message.slice(0, 300) : "Row failed";
|
|
1504
|
+
results.push({ row: i + 1, ok: false, error: message, code });
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
res.json({
|
|
1508
|
+
object: objectName,
|
|
1509
|
+
dryRun,
|
|
1510
|
+
total: rows.length,
|
|
1511
|
+
ok: okCount,
|
|
1512
|
+
errors: errCount,
|
|
1513
|
+
results
|
|
1514
|
+
});
|
|
1515
|
+
} catch (error) {
|
|
1516
|
+
logError("[REST] Unhandled error:", error);
|
|
1517
|
+
sendError(res, error, String(req.params?.object || ""));
|
|
1518
|
+
}
|
|
1519
|
+
},
|
|
1520
|
+
metadata: {
|
|
1521
|
+
summary: "Bulk-import rows into an object (CSV or JSON, with optional dry-run)",
|
|
1522
|
+
tags: ["data", "import"]
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
this.routeManager.register({
|
|
1526
|
+
method: "GET",
|
|
1527
|
+
path: `${dataPath}/:object/export`,
|
|
1528
|
+
handler: async (req, res) => {
|
|
1529
|
+
try {
|
|
1530
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1531
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1532
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1533
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1534
|
+
const objectName = String(req.params.object || "");
|
|
1535
|
+
if (!objectName) {
|
|
1536
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "object is required" });
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
const q = req.query ?? {};
|
|
1540
|
+
const format = String(q.format ?? "csv").toLowerCase() === "json" ? "json" : "csv";
|
|
1541
|
+
const HARD_CAP = 5e4;
|
|
1542
|
+
const MAX_CHUNK = 5e3;
|
|
1543
|
+
const requestedLimit = q.limit != null ? Math.max(1, Number(q.limit) || 0) : 1e4;
|
|
1544
|
+
const limit = Math.min(requestedLimit, HARD_CAP);
|
|
1545
|
+
const chunkSize = Math.min(MAX_CHUNK, Math.max(50, q.page != null ? Number(q.page) || 500 : 500));
|
|
1546
|
+
let filter = void 0;
|
|
1547
|
+
if (typeof q.filter === "string" && q.filter.length > 0) {
|
|
1548
|
+
try {
|
|
1549
|
+
filter = JSON.parse(q.filter);
|
|
1550
|
+
} catch {
|
|
1551
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "filter must be JSON" });
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
} else if (q.filter && typeof q.filter === "object") {
|
|
1555
|
+
filter = q.filter;
|
|
1556
|
+
}
|
|
1557
|
+
let orderby = void 0;
|
|
1558
|
+
if (typeof q.orderby === "string" && q.orderby.length > 0) {
|
|
1559
|
+
if (q.orderby.startsWith("{") || q.orderby.startsWith("[")) {
|
|
1560
|
+
try {
|
|
1561
|
+
orderby = JSON.parse(q.orderby);
|
|
1562
|
+
} catch {
|
|
1563
|
+
}
|
|
1564
|
+
} else {
|
|
1565
|
+
const obj = {};
|
|
1566
|
+
for (const part of q.orderby.split(",")) {
|
|
1567
|
+
const [field, dir] = part.split(":").map((s) => s.trim());
|
|
1568
|
+
if (field) obj[field] = dir?.toLowerCase() === "desc" ? "desc" : "asc";
|
|
1569
|
+
}
|
|
1570
|
+
if (Object.keys(obj).length > 0) orderby = obj;
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
let fields;
|
|
1574
|
+
if (typeof q.fields === "string" && q.fields.length > 0) {
|
|
1575
|
+
fields = q.fields.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1576
|
+
} else if (Array.isArray(q.fields)) {
|
|
1577
|
+
fields = q.fields.filter((s) => typeof s === "string" && s.length > 0);
|
|
1578
|
+
}
|
|
1579
|
+
if (!fields || fields.length === 0) {
|
|
1580
|
+
try {
|
|
1581
|
+
const schema = await p.getObjectSchema?.(objectName, projectId);
|
|
1582
|
+
const schemaFields = schema?.fields;
|
|
1583
|
+
if (Array.isArray(schemaFields)) {
|
|
1584
|
+
fields = schemaFields.map((f) => f.name).filter((n) => typeof n === "string");
|
|
1585
|
+
}
|
|
1586
|
+
} catch {
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1590
|
+
const safeObj = objectName.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
1591
|
+
if (format === "csv") {
|
|
1592
|
+
res.header("Content-Type", "text/csv; charset=utf-8");
|
|
1593
|
+
res.header("Content-Disposition", `attachment; filename="${safeObj}-${stamp}.csv"`);
|
|
1594
|
+
} else {
|
|
1595
|
+
res.header("Content-Type", "application/json; charset=utf-8");
|
|
1596
|
+
res.header("Content-Disposition", `attachment; filename="${safeObj}-${stamp}.json"`);
|
|
1597
|
+
}
|
|
1598
|
+
res.header("X-Export-Format", format);
|
|
1599
|
+
res.header("X-Export-Limit", String(limit));
|
|
1600
|
+
res.header("Cache-Control", "no-store");
|
|
1601
|
+
let exported = 0;
|
|
1602
|
+
let firstChunk = true;
|
|
1603
|
+
let skip = 0;
|
|
1604
|
+
if (format === "json") res.write("[");
|
|
1605
|
+
while (exported < limit) {
|
|
1606
|
+
const take = Math.min(chunkSize, limit - exported);
|
|
1607
|
+
const findArgs = {
|
|
1608
|
+
object: objectName,
|
|
1609
|
+
query: {
|
|
1610
|
+
...filter ? { $filter: filter } : {},
|
|
1611
|
+
...orderby ? { $orderby: orderby } : {},
|
|
1612
|
+
$top: take,
|
|
1613
|
+
$skip: skip
|
|
1614
|
+
},
|
|
1615
|
+
...projectId ? { projectId } : {},
|
|
1616
|
+
...context ? { context } : {}
|
|
1617
|
+
};
|
|
1618
|
+
const result = await p.findData(findArgs);
|
|
1619
|
+
const rows = Array.isArray(result?.data) ? result.data : Array.isArray(result?.rows) ? result.rows : Array.isArray(result) ? result : [];
|
|
1620
|
+
if (rows.length === 0) break;
|
|
1621
|
+
if (format === "csv") {
|
|
1622
|
+
if ((!fields || fields.length === 0) && firstChunk) {
|
|
1623
|
+
fields = Object.keys(rows[0] ?? {});
|
|
1624
|
+
}
|
|
1625
|
+
const text = rowsToCsv(fields ?? [], rows, firstChunk);
|
|
1626
|
+
res.write(text);
|
|
1627
|
+
} else {
|
|
1628
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1629
|
+
const prefix = firstChunk && i === 0 ? "" : ",";
|
|
1630
|
+
res.write(prefix + JSON.stringify(rows[i]));
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
firstChunk = false;
|
|
1634
|
+
exported += rows.length;
|
|
1635
|
+
skip += rows.length;
|
|
1636
|
+
if (rows.length < take) break;
|
|
1637
|
+
}
|
|
1638
|
+
if (format === "json") res.write("]");
|
|
1639
|
+
res.end();
|
|
1640
|
+
} catch (error) {
|
|
1641
|
+
logError("[REST] Unhandled error:", error);
|
|
1642
|
+
try {
|
|
1643
|
+
sendError(res, error, String(req.params?.object || ""));
|
|
1644
|
+
} catch {
|
|
1645
|
+
try {
|
|
1646
|
+
res.end();
|
|
1647
|
+
} catch {
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
},
|
|
1652
|
+
metadata: {
|
|
1653
|
+
summary: "Streaming export of object rows (CSV or JSON)",
|
|
1654
|
+
tags: ["data", "export"]
|
|
1655
|
+
}
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
/**
|
|
1659
|
+
* Register global cross-object search endpoint (M10.5).
|
|
1660
|
+
* GET {basePath}/search?q=acme&objects=lead,account&limit=20&perObject=5
|
|
1661
|
+
*/
|
|
1662
|
+
registerSearchEndpoints(basePath) {
|
|
1663
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
1664
|
+
this.routeManager.register({
|
|
1665
|
+
method: "GET",
|
|
1666
|
+
path: `${basePath}/search`,
|
|
1667
|
+
handler: async (req, res) => {
|
|
1668
|
+
try {
|
|
1669
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1670
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1671
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1672
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1673
|
+
const searchAll = p.searchAll;
|
|
1674
|
+
if (typeof searchAll !== "function") {
|
|
1675
|
+
res.status(501).json({ code: "NOT_IMPLEMENTED", message: "Search not supported by this protocol" });
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
const q = String(req.query?.q ?? req.query?.query ?? "");
|
|
1679
|
+
const objectsParam = req.query?.objects;
|
|
1680
|
+
const objects = typeof objectsParam === "string" ? objectsParam.split(",").map((s) => s.trim()).filter(Boolean) : Array.isArray(objectsParam) ? objectsParam : void 0;
|
|
1681
|
+
const result = await searchAll.call(p, {
|
|
1682
|
+
q,
|
|
1683
|
+
objects,
|
|
1684
|
+
limit: req.query?.limit ? Number(req.query.limit) : void 0,
|
|
1685
|
+
perObject: req.query?.perObject ? Number(req.query.perObject) : void 0,
|
|
1686
|
+
...context ? { context } : {}
|
|
1687
|
+
});
|
|
1688
|
+
res.json(result);
|
|
1689
|
+
} catch (error) {
|
|
1690
|
+
const mapped = mapDataError(error);
|
|
1691
|
+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") {
|
|
1692
|
+
logError("[REST] Unhandled error:", error);
|
|
1693
|
+
}
|
|
1694
|
+
res.status(mapped.status).json(mapped.body);
|
|
1695
|
+
}
|
|
1696
|
+
},
|
|
1697
|
+
metadata: {
|
|
1698
|
+
summary: "Global cross-object search",
|
|
1699
|
+
tags: ["search"]
|
|
1700
|
+
}
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
/**
|
|
1704
|
+
* Register email endpoints (M11.B1 / M10.7).
|
|
1705
|
+
*
|
|
1706
|
+
* POST {basePath}/email/send — send a transactional email via the
|
|
1707
|
+
* `IEmailService` provider registered by EmailServicePlugin. Returns
|
|
1708
|
+
* 501 when no provider is wired so deployments without email
|
|
1709
|
+
* configured fail cleanly.
|
|
1710
|
+
*
|
|
1711
|
+
* Request body:
|
|
1712
|
+
* {
|
|
1713
|
+
* to: "a@b.com" | ["a@b.com", { name, address }],
|
|
1714
|
+
* from?: ..., cc?: ..., bcc?: ..., replyTo?: ...,
|
|
1715
|
+
* subject: string,
|
|
1716
|
+
* text?: string, html?: string, // at least one required
|
|
1717
|
+
* attachments?: [{ filename, content, contentType?, cid? }],
|
|
1718
|
+
* headers?: { [name]: value },
|
|
1719
|
+
* relatedObject?: string, relatedId?: string,
|
|
1720
|
+
* }
|
|
1721
|
+
*/
|
|
1722
|
+
registerEmailEndpoints(basePath) {
|
|
1723
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
1724
|
+
this.routeManager.register({
|
|
1725
|
+
method: "POST",
|
|
1726
|
+
path: `${basePath}/email/send`,
|
|
1727
|
+
handler: async (req, res) => {
|
|
1728
|
+
try {
|
|
1729
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1730
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1731
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1732
|
+
if (!this.emailServiceProvider) {
|
|
1733
|
+
res.status(501).json({
|
|
1734
|
+
code: "NOT_IMPLEMENTED",
|
|
1735
|
+
message: "Email service is not configured on this deployment"
|
|
1736
|
+
});
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
const emailService = await this.emailServiceProvider(projectId).catch(() => void 0);
|
|
1740
|
+
if (!emailService || typeof emailService.send !== "function") {
|
|
1741
|
+
res.status(501).json({
|
|
1742
|
+
code: "NOT_IMPLEMENTED",
|
|
1743
|
+
message: "Email service is not configured on this deployment"
|
|
1744
|
+
});
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
const body = req.body ?? {};
|
|
1748
|
+
if (!body || typeof body !== "object") {
|
|
1749
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "JSON body required" });
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
const input = {
|
|
1753
|
+
...body,
|
|
1754
|
+
...body.sentBy === void 0 && context?.userId ? { sentBy: context.userId } : {}
|
|
1755
|
+
};
|
|
1756
|
+
try {
|
|
1757
|
+
const result = await emailService.send(input);
|
|
1758
|
+
if (result?.status === "sent") {
|
|
1759
|
+
res.status(200).json(result);
|
|
1760
|
+
} else {
|
|
1761
|
+
res.status(200).json(result);
|
|
1762
|
+
}
|
|
1763
|
+
} catch (err) {
|
|
1764
|
+
const message = String(err?.message ?? err ?? "send failed");
|
|
1765
|
+
if (message.startsWith("VALIDATION_FAILED")) {
|
|
1766
|
+
res.status(400).json({
|
|
1767
|
+
code: "VALIDATION_FAILED",
|
|
1768
|
+
error: message.replace(/^VALIDATION_FAILED:\s*/, "")
|
|
1769
|
+
});
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
throw err;
|
|
1773
|
+
}
|
|
1774
|
+
} catch (error) {
|
|
1775
|
+
logError("[REST] Email send unhandled error:", error);
|
|
1776
|
+
res.status(500).json({
|
|
1777
|
+
code: "EMAIL_SEND_FAILED",
|
|
1778
|
+
error: String(error?.message ?? error ?? "send failed").slice(0, 500)
|
|
1779
|
+
});
|
|
1780
|
+
}
|
|
1781
|
+
},
|
|
1782
|
+
metadata: {
|
|
1783
|
+
summary: "Send a transactional email via the configured EmailService",
|
|
1784
|
+
tags: ["email"]
|
|
1785
|
+
}
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* Register record-level sharing endpoints (M11.C17).
|
|
1790
|
+
*
|
|
1791
|
+
* Surfaces `ISharingService` over HTTP so the UI can list, create
|
|
1792
|
+
* and revoke per-record grants without going through ObjectQL. The
|
|
1793
|
+
* three routes mirror the share-management drawer in Salesforce /
|
|
1794
|
+
* ServiceNow:
|
|
1795
|
+
*
|
|
1796
|
+
* GET {basePath}/data/:object/:id/shares
|
|
1797
|
+
* POST {basePath}/data/:object/:id/shares
|
|
1798
|
+
* DELETE {basePath}/data/:object/:id/shares/:shareId
|
|
1799
|
+
*
|
|
1800
|
+
* All three resolve via `sharingServiceProvider`; routes return 501
|
|
1801
|
+
* when no sharing service is configured so a deployment without the
|
|
1802
|
+
* `@objectstack/plugin-sharing` plugin fails cleanly.
|
|
1803
|
+
*/
|
|
1804
|
+
registerSharingEndpoints(basePath) {
|
|
1805
|
+
const { crud } = this.config;
|
|
1806
|
+
const dataPath = `${basePath}${crud.dataPrefix}`;
|
|
1807
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
1808
|
+
const resolveService = async (projectId) => {
|
|
1809
|
+
if (!this.sharingServiceProvider) return void 0;
|
|
1810
|
+
try {
|
|
1811
|
+
return await this.sharingServiceProvider(projectId);
|
|
1812
|
+
} catch {
|
|
1813
|
+
return void 0;
|
|
1814
|
+
}
|
|
1815
|
+
};
|
|
1816
|
+
const respond501 = (res) => res.status(501).json({
|
|
1817
|
+
code: "NOT_IMPLEMENTED",
|
|
1818
|
+
message: "Sharing service is not configured on this deployment"
|
|
1819
|
+
});
|
|
1820
|
+
this.routeManager.register({
|
|
1821
|
+
method: "GET",
|
|
1822
|
+
path: `${dataPath}/:object/:id/shares`,
|
|
1823
|
+
handler: async (req, res) => {
|
|
1824
|
+
try {
|
|
1825
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1826
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1827
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1828
|
+
const svc = await resolveService(projectId);
|
|
1829
|
+
if (!svc) return respond501(res);
|
|
1830
|
+
const rows = await svc.listShares(req.params.object, req.params.id, context ?? {});
|
|
1831
|
+
res.json({ data: rows });
|
|
1832
|
+
} catch (error) {
|
|
1833
|
+
logError("[REST] List shares error:", error);
|
|
1834
|
+
res.status(500).json({ code: "SHARES_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
1835
|
+
}
|
|
1836
|
+
},
|
|
1837
|
+
metadata: { summary: "List per-record sharing grants", tags: ["sharing"] }
|
|
1838
|
+
});
|
|
1839
|
+
this.routeManager.register({
|
|
1840
|
+
method: "POST",
|
|
1841
|
+
path: `${dataPath}/:object/:id/shares`,
|
|
1842
|
+
handler: async (req, res) => {
|
|
1843
|
+
try {
|
|
1844
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1845
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1846
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1847
|
+
const svc = await resolveService(projectId);
|
|
1848
|
+
if (!svc) return respond501(res);
|
|
1849
|
+
const body = req.body ?? {};
|
|
1850
|
+
const input = {
|
|
1851
|
+
object: req.params.object,
|
|
1852
|
+
recordId: req.params.id,
|
|
1853
|
+
recipientType: body.recipientType ?? body.recipient_type,
|
|
1854
|
+
recipientId: body.recipientId ?? body.recipient_id,
|
|
1855
|
+
accessLevel: body.accessLevel ?? body.access_level,
|
|
1856
|
+
source: body.source,
|
|
1857
|
+
sourceId: body.sourceId ?? body.source_id,
|
|
1858
|
+
reason: body.reason
|
|
1859
|
+
};
|
|
1860
|
+
try {
|
|
1861
|
+
const row = await svc.grant(input, context ?? {});
|
|
1862
|
+
res.status(201).json(row);
|
|
1863
|
+
} catch (err) {
|
|
1864
|
+
const msg = String(err?.message ?? err ?? "");
|
|
1865
|
+
if (msg.startsWith("VALIDATION_FAILED")) {
|
|
1866
|
+
res.status(400).json({
|
|
1867
|
+
code: "VALIDATION_FAILED",
|
|
1868
|
+
error: msg.replace(/^VALIDATION_FAILED:\s*/, "")
|
|
1869
|
+
});
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
throw err;
|
|
1873
|
+
}
|
|
1874
|
+
} catch (error) {
|
|
1875
|
+
logError("[REST] Grant share error:", error);
|
|
1876
|
+
res.status(500).json({ code: "SHARE_GRANT_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
1877
|
+
}
|
|
1878
|
+
},
|
|
1879
|
+
metadata: { summary: "Grant a per-record share to a principal", tags: ["sharing"] }
|
|
1880
|
+
});
|
|
1881
|
+
this.routeManager.register({
|
|
1882
|
+
method: "DELETE",
|
|
1883
|
+
path: `${dataPath}/:object/:id/shares/:shareId`,
|
|
1884
|
+
handler: async (req, res) => {
|
|
1885
|
+
try {
|
|
1886
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1887
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1888
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1889
|
+
const svc = await resolveService(projectId);
|
|
1890
|
+
if (!svc) return respond501(res);
|
|
1891
|
+
await svc.revoke(req.params.shareId, context ?? {});
|
|
1892
|
+
res.status(204).end();
|
|
1893
|
+
} catch (error) {
|
|
1894
|
+
logError("[REST] Revoke share error:", error);
|
|
1895
|
+
res.status(500).json({ code: "SHARE_REVOKE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
1896
|
+
}
|
|
1897
|
+
},
|
|
1898
|
+
metadata: { summary: "Revoke a per-record share by id", tags: ["sharing"] }
|
|
1899
|
+
});
|
|
1900
|
+
}
|
|
1901
|
+
/**
|
|
1902
|
+
* Register sharing-rule endpoints (M10.17). Mirrors the existing
|
|
1903
|
+
* sharing endpoints but operates on `sys_sharing_rule` rows.
|
|
1904
|
+
*
|
|
1905
|
+
* GET {basePath}/sharing/rules?object=&activeOnly=
|
|
1906
|
+
* POST {basePath}/sharing/rules
|
|
1907
|
+
* GET {basePath}/sharing/rules/:idOrName
|
|
1908
|
+
* DELETE {basePath}/sharing/rules/:idOrName
|
|
1909
|
+
* POST {basePath}/sharing/rules/:idOrName/evaluate
|
|
1910
|
+
*
|
|
1911
|
+
* Returns 501 when no sharing-rule service is configured.
|
|
1912
|
+
*/
|
|
1913
|
+
registerSharingRuleEndpoints(basePath) {
|
|
1914
|
+
const dataPath = basePath;
|
|
1915
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
1916
|
+
const resolveService = async (projectId) => {
|
|
1917
|
+
if (!this.sharingRulesServiceProvider) return void 0;
|
|
1918
|
+
try {
|
|
1919
|
+
return await this.sharingRulesServiceProvider(projectId);
|
|
1920
|
+
} catch {
|
|
1921
|
+
return void 0;
|
|
1922
|
+
}
|
|
1923
|
+
};
|
|
1924
|
+
const respond501 = (res) => res.status(501).json({
|
|
1925
|
+
code: "NOT_IMPLEMENTED",
|
|
1926
|
+
message: "Sharing-rule service is not configured on this deployment"
|
|
1927
|
+
});
|
|
1928
|
+
const handleError = (err, res, defaultCode) => {
|
|
1929
|
+
const msg = String(err?.message ?? err ?? "");
|
|
1930
|
+
if (msg.startsWith("VALIDATION_FAILED")) {
|
|
1931
|
+
return res.status(400).json({ code: "VALIDATION_FAILED", error: msg.replace(/^VALIDATION_FAILED:\s*/, "") });
|
|
1932
|
+
}
|
|
1933
|
+
if (msg.startsWith("RULE_NOT_FOUND")) {
|
|
1934
|
+
return res.status(404).json({ code: "RULE_NOT_FOUND", error: msg.replace(/^RULE_NOT_FOUND:?\s*/, "") });
|
|
1935
|
+
}
|
|
1936
|
+
logError(`[REST] sharing-rule ${defaultCode}:`, err);
|
|
1937
|
+
return res.status(500).json({ code: defaultCode, error: msg.slice(0, 500) });
|
|
1938
|
+
};
|
|
1939
|
+
this.routeManager.register({
|
|
1940
|
+
method: "GET",
|
|
1941
|
+
path: `${dataPath}/sharing/rules`,
|
|
1942
|
+
handler: async (req, res) => {
|
|
1943
|
+
try {
|
|
1944
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1945
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1946
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1947
|
+
const svc = await resolveService(projectId);
|
|
1948
|
+
if (!svc) return respond501(res);
|
|
1949
|
+
const rows = await svc.listRules({
|
|
1950
|
+
object: req.query?.object,
|
|
1951
|
+
activeOnly: req.query?.activeOnly === "true" || req.query?.activeOnly === true
|
|
1952
|
+
}, context ?? {});
|
|
1953
|
+
res.json({ data: rows });
|
|
1954
|
+
} catch (err) {
|
|
1955
|
+
handleError(err, res, "RULE_LIST_FAILED");
|
|
1956
|
+
}
|
|
1957
|
+
},
|
|
1958
|
+
metadata: { summary: "List sharing rules", tags: ["sharing"] }
|
|
1959
|
+
});
|
|
1960
|
+
this.routeManager.register({
|
|
1961
|
+
method: "POST",
|
|
1962
|
+
path: `${dataPath}/sharing/rules`,
|
|
1963
|
+
handler: async (req, res) => {
|
|
1964
|
+
try {
|
|
1965
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1966
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1967
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1968
|
+
const svc = await resolveService(projectId);
|
|
1969
|
+
if (!svc) return respond501(res);
|
|
1970
|
+
const body = req.body ?? {};
|
|
1971
|
+
const input = {
|
|
1972
|
+
name: body.name,
|
|
1973
|
+
label: body.label,
|
|
1974
|
+
description: body.description,
|
|
1975
|
+
object: body.object ?? body.object_name,
|
|
1976
|
+
criteria: body.criteria,
|
|
1977
|
+
recipientType: body.recipientType ?? body.recipient_type,
|
|
1978
|
+
recipientId: body.recipientId ?? body.recipient_id,
|
|
1979
|
+
accessLevel: body.accessLevel ?? body.access_level,
|
|
1980
|
+
active: body.active
|
|
1981
|
+
};
|
|
1982
|
+
const row = await svc.defineRule(input, context ?? {});
|
|
1983
|
+
res.status(201).json(row);
|
|
1984
|
+
} catch (err) {
|
|
1985
|
+
handleError(err, res, "RULE_DEFINE_FAILED");
|
|
1986
|
+
}
|
|
1987
|
+
},
|
|
1988
|
+
metadata: { summary: "Create or upsert a sharing rule", tags: ["sharing"] }
|
|
1989
|
+
});
|
|
1990
|
+
this.routeManager.register({
|
|
1991
|
+
method: "GET",
|
|
1992
|
+
path: `${dataPath}/sharing/rules/:idOrName`,
|
|
1993
|
+
handler: async (req, res) => {
|
|
1994
|
+
try {
|
|
1995
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1996
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
1997
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1998
|
+
const svc = await resolveService(projectId);
|
|
1999
|
+
if (!svc) return respond501(res);
|
|
2000
|
+
const row = await svc.getRule(req.params.idOrName, context ?? {});
|
|
2001
|
+
if (!row) return res.status(404).json({ code: "RULE_NOT_FOUND" });
|
|
2002
|
+
res.json(row);
|
|
2003
|
+
} catch (err) {
|
|
2004
|
+
handleError(err, res, "RULE_GET_FAILED");
|
|
2005
|
+
}
|
|
2006
|
+
},
|
|
2007
|
+
metadata: { summary: "Get a sharing rule by id or name", tags: ["sharing"] }
|
|
2008
|
+
});
|
|
2009
|
+
this.routeManager.register({
|
|
2010
|
+
method: "DELETE",
|
|
2011
|
+
path: `${dataPath}/sharing/rules/:idOrName`,
|
|
2012
|
+
handler: async (req, res) => {
|
|
2013
|
+
try {
|
|
2014
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2015
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2016
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2017
|
+
const svc = await resolveService(projectId);
|
|
2018
|
+
if (!svc) return respond501(res);
|
|
2019
|
+
await svc.deleteRule(req.params.idOrName, context ?? {});
|
|
2020
|
+
res.status(204).end();
|
|
2021
|
+
} catch (err) {
|
|
2022
|
+
handleError(err, res, "RULE_DELETE_FAILED");
|
|
2023
|
+
}
|
|
2024
|
+
},
|
|
2025
|
+
metadata: { summary: "Delete a sharing rule and its materialised grants", tags: ["sharing"] }
|
|
2026
|
+
});
|
|
2027
|
+
this.routeManager.register({
|
|
2028
|
+
method: "POST",
|
|
2029
|
+
path: `${dataPath}/sharing/rules/:idOrName/evaluate`,
|
|
2030
|
+
handler: async (req, res) => {
|
|
2031
|
+
try {
|
|
2032
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2033
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2034
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2035
|
+
const svc = await resolveService(projectId);
|
|
2036
|
+
if (!svc) return respond501(res);
|
|
2037
|
+
const result = await svc.evaluateRule(req.params.idOrName, context ?? {});
|
|
2038
|
+
res.json(result);
|
|
2039
|
+
} catch (err) {
|
|
2040
|
+
handleError(err, res, "RULE_EVALUATE_FAILED");
|
|
2041
|
+
}
|
|
2042
|
+
},
|
|
2043
|
+
metadata: { summary: "Re-evaluate a sharing rule and reconcile grants", tags: ["sharing"] }
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
/**
|
|
2047
|
+
* Register saved-report + scheduled-digest endpoints (M11.C16).
|
|
2048
|
+
*
|
|
2049
|
+
* Surfaces `IReportService` over HTTP so the UI can build,
|
|
2050
|
+
* run, and schedule reports without dropping to ObjectQL. Routes
|
|
2051
|
+
* live at the top of the API surface (alongside `/approvals` and
|
|
2052
|
+
* `/sharing`) — reports are a tenant-wide capability, not a record
|
|
2053
|
+
* on a specific CRUD object:
|
|
2054
|
+
*
|
|
2055
|
+
* GET {basePath}/reports?object=&ownerId=
|
|
2056
|
+
* POST {basePath}/reports
|
|
2057
|
+
* GET {basePath}/reports/:id
|
|
2058
|
+
* DELETE {basePath}/reports/:id
|
|
2059
|
+
* POST {basePath}/reports/:id/run
|
|
2060
|
+
* POST {basePath}/reports/:id/schedule
|
|
2061
|
+
* GET {basePath}/reports/:id/schedules
|
|
2062
|
+
* DELETE {basePath}/reports/schedules/:scheduleId
|
|
2063
|
+
*
|
|
2064
|
+
* All routes return 501 when `reportsServiceProvider` is unset so
|
|
2065
|
+
* a deployment without `@objectstack/plugin-reports` fails cleanly.
|
|
2066
|
+
*/
|
|
2067
|
+
registerReportsEndpoints(basePath) {
|
|
2068
|
+
const dataPath = basePath;
|
|
2069
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
2070
|
+
const resolveService = async (projectId) => {
|
|
2071
|
+
if (!this.reportsServiceProvider) return void 0;
|
|
2072
|
+
try {
|
|
2073
|
+
return await this.reportsServiceProvider(projectId);
|
|
2074
|
+
} catch {
|
|
2075
|
+
return void 0;
|
|
2076
|
+
}
|
|
2077
|
+
};
|
|
2078
|
+
const respond501 = (res) => res.status(501).json({
|
|
2079
|
+
code: "NOT_IMPLEMENTED",
|
|
2080
|
+
message: "Reports service is not configured on this deployment"
|
|
2081
|
+
});
|
|
2082
|
+
const handleValidation = (res, err) => {
|
|
2083
|
+
const msg = String(err?.message ?? err ?? "");
|
|
2084
|
+
if (msg.startsWith("VALIDATION_FAILED")) {
|
|
2085
|
+
res.status(400).json({
|
|
2086
|
+
code: "VALIDATION_FAILED",
|
|
2087
|
+
error: msg.replace(/^VALIDATION_FAILED:\s*/, "")
|
|
2088
|
+
});
|
|
2089
|
+
return true;
|
|
2090
|
+
}
|
|
2091
|
+
if (msg.startsWith("REPORT_NOT_FOUND")) {
|
|
2092
|
+
res.status(404).json({ code: "REPORT_NOT_FOUND", error: msg });
|
|
2093
|
+
return true;
|
|
2094
|
+
}
|
|
2095
|
+
return false;
|
|
2096
|
+
};
|
|
2097
|
+
this.routeManager.register({
|
|
2098
|
+
method: "GET",
|
|
2099
|
+
path: `${dataPath}/reports`,
|
|
2100
|
+
handler: async (req, res) => {
|
|
2101
|
+
try {
|
|
2102
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2103
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2104
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2105
|
+
const svc = await resolveService(projectId);
|
|
2106
|
+
if (!svc) return respond501(res);
|
|
2107
|
+
const q = req.query ?? {};
|
|
2108
|
+
const rows = await svc.listReports({ object: q.object, ownerId: q.ownerId }, context ?? {});
|
|
2109
|
+
res.json({ data: rows });
|
|
2110
|
+
} catch (error) {
|
|
2111
|
+
logError("[REST] List reports error:", error);
|
|
2112
|
+
res.status(500).json({ code: "REPORTS_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2113
|
+
}
|
|
2114
|
+
},
|
|
2115
|
+
metadata: { summary: "List saved reports", tags: ["reports"] }
|
|
2116
|
+
});
|
|
2117
|
+
this.routeManager.register({
|
|
2118
|
+
method: "POST",
|
|
2119
|
+
path: `${dataPath}/reports`,
|
|
2120
|
+
handler: async (req, res) => {
|
|
2121
|
+
try {
|
|
2122
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2123
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2124
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2125
|
+
const svc = await resolveService(projectId);
|
|
2126
|
+
if (!svc) return respond501(res);
|
|
2127
|
+
try {
|
|
2128
|
+
const row = await svc.saveReport(req.body ?? {}, context ?? {});
|
|
2129
|
+
res.status(201).json(row);
|
|
2130
|
+
} catch (err) {
|
|
2131
|
+
if (handleValidation(res, err)) return;
|
|
2132
|
+
throw err;
|
|
2133
|
+
}
|
|
2134
|
+
} catch (error) {
|
|
2135
|
+
logError("[REST] Save report error:", error);
|
|
2136
|
+
res.status(500).json({ code: "REPORT_SAVE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2137
|
+
}
|
|
2138
|
+
},
|
|
2139
|
+
metadata: { summary: "Create or update a saved report", tags: ["reports"] }
|
|
2140
|
+
});
|
|
2141
|
+
this.routeManager.register({
|
|
2142
|
+
method: "GET",
|
|
2143
|
+
path: `${dataPath}/reports/:id`,
|
|
2144
|
+
handler: async (req, res) => {
|
|
2145
|
+
try {
|
|
2146
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2147
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2148
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2149
|
+
const svc = await resolveService(projectId);
|
|
2150
|
+
if (!svc) return respond501(res);
|
|
2151
|
+
const row = await svc.getReport(req.params.id, context ?? {});
|
|
2152
|
+
if (!row) {
|
|
2153
|
+
res.status(404).json({ code: "REPORT_NOT_FOUND", error: `Report ${req.params.id} not found` });
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2156
|
+
res.json(row);
|
|
2157
|
+
} catch (error) {
|
|
2158
|
+
logError("[REST] Get report error:", error);
|
|
2159
|
+
res.status(500).json({ code: "REPORT_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2160
|
+
}
|
|
2161
|
+
},
|
|
2162
|
+
metadata: { summary: "Get a saved report by id", tags: ["reports"] }
|
|
2163
|
+
});
|
|
2164
|
+
this.routeManager.register({
|
|
2165
|
+
method: "DELETE",
|
|
2166
|
+
path: `${dataPath}/reports/:id`,
|
|
2167
|
+
handler: async (req, res) => {
|
|
2168
|
+
try {
|
|
2169
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2170
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2171
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2172
|
+
const svc = await resolveService(projectId);
|
|
2173
|
+
if (!svc) return respond501(res);
|
|
2174
|
+
await svc.deleteReport(req.params.id, context ?? {});
|
|
2175
|
+
res.status(204).end();
|
|
2176
|
+
} catch (error) {
|
|
2177
|
+
logError("[REST] Delete report error:", error);
|
|
2178
|
+
res.status(500).json({ code: "REPORT_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2179
|
+
}
|
|
2180
|
+
},
|
|
2181
|
+
metadata: { summary: "Delete a saved report (cascades schedules)", tags: ["reports"] }
|
|
2182
|
+
});
|
|
2183
|
+
this.routeManager.register({
|
|
2184
|
+
method: "POST",
|
|
2185
|
+
path: `${dataPath}/reports/:id/run`,
|
|
2186
|
+
handler: async (req, res) => {
|
|
2187
|
+
try {
|
|
2188
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2189
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2190
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2191
|
+
const svc = await resolveService(projectId);
|
|
2192
|
+
if (!svc) return respond501(res);
|
|
2193
|
+
try {
|
|
2194
|
+
const result = await svc.run(req.params.id, context ?? {});
|
|
2195
|
+
res.json(result);
|
|
2196
|
+
} catch (err) {
|
|
2197
|
+
if (handleValidation(res, err)) return;
|
|
2198
|
+
throw err;
|
|
2199
|
+
}
|
|
2200
|
+
} catch (error) {
|
|
2201
|
+
logError("[REST] Run report error:", error);
|
|
2202
|
+
res.status(500).json({ code: "REPORT_RUN_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2203
|
+
}
|
|
2204
|
+
},
|
|
2205
|
+
metadata: { summary: "Execute a saved report and return rendered output", tags: ["reports"] }
|
|
2206
|
+
});
|
|
2207
|
+
this.routeManager.register({
|
|
2208
|
+
method: "POST",
|
|
2209
|
+
path: `${dataPath}/reports/:id/schedule`,
|
|
2210
|
+
handler: async (req, res) => {
|
|
2211
|
+
try {
|
|
2212
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2213
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2214
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2215
|
+
const svc = await resolveService(projectId);
|
|
2216
|
+
if (!svc) return respond501(res);
|
|
2217
|
+
const body = req.body ?? {};
|
|
2218
|
+
try {
|
|
2219
|
+
const row = await svc.scheduleReport({
|
|
2220
|
+
reportId: req.params.id,
|
|
2221
|
+
recipients: body.recipients ?? [],
|
|
2222
|
+
name: body.name,
|
|
2223
|
+
intervalMinutes: body.intervalMinutes ?? body.interval_minutes,
|
|
2224
|
+
cronExpression: body.cronExpression ?? body.cron_expression,
|
|
2225
|
+
timezone: body.timezone,
|
|
2226
|
+
format: body.format,
|
|
2227
|
+
subjectTemplate: body.subjectTemplate ?? body.subject_template,
|
|
2228
|
+
ownerId: body.ownerId ?? body.owner_id,
|
|
2229
|
+
active: body.active
|
|
2230
|
+
}, context ?? {});
|
|
2231
|
+
res.status(201).json(row);
|
|
2232
|
+
} catch (err) {
|
|
2233
|
+
if (handleValidation(res, err)) return;
|
|
2234
|
+
throw err;
|
|
2235
|
+
}
|
|
2236
|
+
} catch (error) {
|
|
2237
|
+
logError("[REST] Schedule report error:", error);
|
|
2238
|
+
res.status(500).json({ code: "REPORT_SCHEDULE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2239
|
+
}
|
|
2240
|
+
},
|
|
2241
|
+
metadata: { summary: "Create a recurring email schedule for a report", tags: ["reports"] }
|
|
2242
|
+
});
|
|
2243
|
+
this.routeManager.register({
|
|
2244
|
+
method: "GET",
|
|
2245
|
+
path: `${dataPath}/reports/:id/schedules`,
|
|
2246
|
+
handler: async (req, res) => {
|
|
2247
|
+
try {
|
|
2248
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2249
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2250
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2251
|
+
const svc = await resolveService(projectId);
|
|
2252
|
+
if (!svc) return respond501(res);
|
|
2253
|
+
const rows = await svc.listSchedules({ reportId: req.params.id }, context ?? {});
|
|
2254
|
+
res.json({ data: rows });
|
|
2255
|
+
} catch (error) {
|
|
2256
|
+
logError("[REST] List schedules error:", error);
|
|
2257
|
+
res.status(500).json({ code: "SCHEDULES_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2258
|
+
}
|
|
2259
|
+
},
|
|
2260
|
+
metadata: { summary: "List schedules for a report", tags: ["reports"] }
|
|
2261
|
+
});
|
|
2262
|
+
this.routeManager.register({
|
|
2263
|
+
method: "DELETE",
|
|
2264
|
+
path: `${dataPath}/reports/schedules/:scheduleId`,
|
|
2265
|
+
handler: async (req, res) => {
|
|
2266
|
+
try {
|
|
2267
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2268
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2269
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2270
|
+
const svc = await resolveService(projectId);
|
|
2271
|
+
if (!svc) return respond501(res);
|
|
2272
|
+
await svc.unscheduleReport(req.params.scheduleId, context ?? {});
|
|
2273
|
+
res.status(204).end();
|
|
2274
|
+
} catch (error) {
|
|
2275
|
+
logError("[REST] Unschedule report error:", error);
|
|
2276
|
+
res.status(500).json({ code: "SCHEDULE_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2277
|
+
}
|
|
2278
|
+
},
|
|
2279
|
+
metadata: { summary: "Delete a report schedule by id", tags: ["reports"] }
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
/**
|
|
2283
|
+
* Register approval engine endpoints.
|
|
2284
|
+
*
|
|
2285
|
+
* Routes (all under {basePath}/approvals):
|
|
2286
|
+
* GET /processes — list approval processes
|
|
2287
|
+
* POST /processes — upsert (defineProcess)
|
|
2288
|
+
* GET /processes/:id — get by id or name
|
|
2289
|
+
* DELETE /processes/:id — delete process
|
|
2290
|
+
* POST /requests — submit
|
|
2291
|
+
* GET /requests — list (filters: status, object, recordId, approverId, submitterId)
|
|
2292
|
+
* GET /requests/:id — get request
|
|
2293
|
+
* POST /requests/:id/approve — approve current step
|
|
2294
|
+
* POST /requests/:id/reject — reject current step
|
|
2295
|
+
* POST /requests/:id/recall — recall (submitter only)
|
|
2296
|
+
* GET /requests/:id/actions — audit trail
|
|
2297
|
+
*
|
|
2298
|
+
* Returns 501 when `approvalsServiceProvider` is unset so deployments
|
|
2299
|
+
* without `@objectstack/plugin-approvals` fail cleanly.
|
|
2300
|
+
*/
|
|
2301
|
+
registerApprovalsEndpoints(basePath) {
|
|
2302
|
+
const dataPath = basePath;
|
|
2303
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
2304
|
+
const resolveService = async (projectId) => {
|
|
2305
|
+
if (!this.approvalsServiceProvider) return void 0;
|
|
2306
|
+
try {
|
|
2307
|
+
return await this.approvalsServiceProvider(projectId);
|
|
2308
|
+
} catch {
|
|
2309
|
+
return void 0;
|
|
2310
|
+
}
|
|
2311
|
+
};
|
|
2312
|
+
const respond501 = (res) => res.status(501).json({
|
|
2313
|
+
code: "NOT_IMPLEMENTED",
|
|
2314
|
+
message: "Approvals service is not configured on this deployment"
|
|
2315
|
+
});
|
|
2316
|
+
const handleApprovalError = (res, err) => {
|
|
2317
|
+
const msg = String(err?.message ?? err ?? "");
|
|
2318
|
+
const mapping = [
|
|
2319
|
+
[/^VALIDATION_FAILED/, 400, "VALIDATION_FAILED"],
|
|
2320
|
+
[/^DUPLICATE_REQUEST/, 409, "DUPLICATE_REQUEST"],
|
|
2321
|
+
[/^INVALID_STATE/, 409, "INVALID_STATE"],
|
|
2322
|
+
[/^FORBIDDEN/, 403, "FORBIDDEN"],
|
|
2323
|
+
[/^NO_ACTIVE_PROCESS/, 404, "NO_ACTIVE_PROCESS"],
|
|
2324
|
+
[/^PROCESS_NOT_FOUND/, 404, "PROCESS_NOT_FOUND"],
|
|
2325
|
+
[/^REQUEST_NOT_FOUND/, 404, "REQUEST_NOT_FOUND"]
|
|
2326
|
+
];
|
|
2327
|
+
for (const [re, status, code] of mapping) {
|
|
2328
|
+
if (re.test(msg)) {
|
|
2329
|
+
res.status(status).json({ code, error: msg.replace(/^[A-Z_]+:\s*/, "") });
|
|
2330
|
+
return true;
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
return false;
|
|
2334
|
+
};
|
|
2335
|
+
this.routeManager.register({
|
|
2336
|
+
method: "GET",
|
|
2337
|
+
path: `${dataPath}/approvals/processes`,
|
|
2338
|
+
handler: async (req, res) => {
|
|
2339
|
+
try {
|
|
2340
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2341
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2342
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2343
|
+
const svc = await resolveService(projectId);
|
|
2344
|
+
if (!svc) return respond501(res);
|
|
2345
|
+
const q = req.query ?? {};
|
|
2346
|
+
const rows = await svc.listProcesses({
|
|
2347
|
+
object: q.object,
|
|
2348
|
+
activeOnly: q.activeOnly === "true" || q.activeOnly === true
|
|
2349
|
+
}, context ?? {});
|
|
2350
|
+
res.json({ data: rows });
|
|
2351
|
+
} catch (error) {
|
|
2352
|
+
logError("[REST] List approval processes error:", error);
|
|
2353
|
+
res.status(500).json({ code: "APPROVAL_PROCESS_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2354
|
+
}
|
|
2355
|
+
},
|
|
2356
|
+
metadata: { summary: "List approval processes", tags: ["approvals"] }
|
|
2357
|
+
});
|
|
2358
|
+
this.routeManager.register({
|
|
2359
|
+
method: "POST",
|
|
2360
|
+
path: `${dataPath}/approvals/processes`,
|
|
2361
|
+
handler: async (req, res) => {
|
|
2362
|
+
try {
|
|
2363
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2364
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2365
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2366
|
+
const svc = await resolveService(projectId);
|
|
2367
|
+
if (!svc) return respond501(res);
|
|
2368
|
+
try {
|
|
2369
|
+
const row = await svc.defineProcess(req.body ?? {}, context ?? {});
|
|
2370
|
+
res.status(201).json(row);
|
|
2371
|
+
} catch (err) {
|
|
2372
|
+
if (handleApprovalError(res, err)) return;
|
|
2373
|
+
throw err;
|
|
2374
|
+
}
|
|
2375
|
+
} catch (error) {
|
|
2376
|
+
logError("[REST] Define approval process error:", error);
|
|
2377
|
+
res.status(500).json({ code: "APPROVAL_PROCESS_DEFINE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2378
|
+
}
|
|
2379
|
+
},
|
|
2380
|
+
metadata: { summary: "Define (upsert) an approval process", tags: ["approvals"] }
|
|
2381
|
+
});
|
|
2382
|
+
this.routeManager.register({
|
|
2383
|
+
method: "GET",
|
|
2384
|
+
path: `${dataPath}/approvals/processes/:id`,
|
|
2385
|
+
handler: async (req, res) => {
|
|
2386
|
+
try {
|
|
2387
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2388
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2389
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2390
|
+
const svc = await resolveService(projectId);
|
|
2391
|
+
if (!svc) return respond501(res);
|
|
2392
|
+
const row = await svc.getProcess(req.params.id, context ?? {});
|
|
2393
|
+
if (!row) {
|
|
2394
|
+
res.status(404).json({ code: "PROCESS_NOT_FOUND", error: `Approval process '${req.params.id}' not found` });
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
2397
|
+
res.json(row);
|
|
2398
|
+
} catch (error) {
|
|
2399
|
+
logError("[REST] Get approval process error:", error);
|
|
2400
|
+
res.status(500).json({ code: "APPROVAL_PROCESS_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2401
|
+
}
|
|
2402
|
+
},
|
|
2403
|
+
metadata: { summary: "Get an approval process by id or name", tags: ["approvals"] }
|
|
2404
|
+
});
|
|
2405
|
+
this.routeManager.register({
|
|
2406
|
+
method: "DELETE",
|
|
2407
|
+
path: `${dataPath}/approvals/processes/:id`,
|
|
2408
|
+
handler: async (req, res) => {
|
|
2409
|
+
try {
|
|
2410
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2411
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2412
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2413
|
+
const svc = await resolveService(projectId);
|
|
2414
|
+
if (!svc) return respond501(res);
|
|
2415
|
+
await svc.deleteProcess(req.params.id, context ?? {});
|
|
2416
|
+
res.status(204).end();
|
|
2417
|
+
} catch (error) {
|
|
2418
|
+
logError("[REST] Delete approval process error:", error);
|
|
2419
|
+
res.status(500).json({ code: "APPROVAL_PROCESS_DELETE_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2420
|
+
}
|
|
2421
|
+
},
|
|
2422
|
+
metadata: { summary: "Delete an approval process", tags: ["approvals"] }
|
|
2423
|
+
});
|
|
2424
|
+
this.routeManager.register({
|
|
2425
|
+
method: "POST",
|
|
2426
|
+
path: `${dataPath}/approvals/requests`,
|
|
2427
|
+
handler: async (req, res) => {
|
|
2428
|
+
try {
|
|
2429
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2430
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2431
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2432
|
+
const svc = await resolveService(projectId);
|
|
2433
|
+
if (!svc) return respond501(res);
|
|
2434
|
+
const body = req.body ?? {};
|
|
2435
|
+
try {
|
|
2436
|
+
const row = await svc.submit({
|
|
2437
|
+
object: body.object,
|
|
2438
|
+
recordId: body.recordId ?? body.record_id,
|
|
2439
|
+
processName: body.processName ?? body.process_name,
|
|
2440
|
+
submitterId: body.submitterId ?? body.submitter_id ?? context?.userId,
|
|
2441
|
+
comment: body.comment,
|
|
2442
|
+
payload: body.payload
|
|
2443
|
+
}, context ?? {});
|
|
2444
|
+
res.status(201).json(row);
|
|
2445
|
+
} catch (err) {
|
|
2446
|
+
if (handleApprovalError(res, err)) return;
|
|
2447
|
+
throw err;
|
|
2448
|
+
}
|
|
2449
|
+
} catch (error) {
|
|
2450
|
+
logError("[REST] Submit approval error:", error);
|
|
2451
|
+
res.status(500).json({ code: "APPROVAL_SUBMIT_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2452
|
+
}
|
|
2453
|
+
},
|
|
2454
|
+
metadata: { summary: "Submit a record for approval", tags: ["approvals"] }
|
|
2455
|
+
});
|
|
2456
|
+
this.routeManager.register({
|
|
2457
|
+
method: "GET",
|
|
2458
|
+
path: `${dataPath}/approvals/requests`,
|
|
2459
|
+
handler: async (req, res) => {
|
|
2460
|
+
try {
|
|
2461
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2462
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2463
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2464
|
+
const svc = await resolveService(projectId);
|
|
2465
|
+
if (!svc) {
|
|
2466
|
+
res.json({ data: [] });
|
|
2467
|
+
return;
|
|
2468
|
+
}
|
|
2469
|
+
const q = req.query ?? {};
|
|
2470
|
+
const rows = await svc.listRequests({
|
|
2471
|
+
object: q.object,
|
|
2472
|
+
recordId: q.recordId ?? q.record_id,
|
|
2473
|
+
status: q.status,
|
|
2474
|
+
approverId: q.approverId ?? q.approver_id,
|
|
2475
|
+
submitterId: q.submitterId ?? q.submitter_id
|
|
2476
|
+
}, context ?? {});
|
|
2477
|
+
res.json({ data: rows });
|
|
2478
|
+
} catch (error) {
|
|
2479
|
+
logError("[REST] List approval requests error:", error);
|
|
2480
|
+
res.status(500).json({ code: "APPROVAL_REQUEST_LIST_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2481
|
+
}
|
|
2482
|
+
},
|
|
2483
|
+
metadata: { summary: "List approval requests", tags: ["approvals"] }
|
|
2484
|
+
});
|
|
2485
|
+
this.routeManager.register({
|
|
2486
|
+
method: "GET",
|
|
2487
|
+
path: `${dataPath}/approvals/requests/:id`,
|
|
2488
|
+
handler: async (req, res) => {
|
|
2489
|
+
try {
|
|
2490
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2491
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2492
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2493
|
+
const svc = await resolveService(projectId);
|
|
2494
|
+
if (!svc) return respond501(res);
|
|
2495
|
+
const row = await svc.getRequest(req.params.id, context ?? {});
|
|
2496
|
+
if (!row) {
|
|
2497
|
+
res.status(404).json({ code: "REQUEST_NOT_FOUND", error: `Approval request '${req.params.id}' not found` });
|
|
2498
|
+
return;
|
|
2499
|
+
}
|
|
2500
|
+
res.json(row);
|
|
2501
|
+
} catch (error) {
|
|
2502
|
+
logError("[REST] Get approval request error:", error);
|
|
2503
|
+
res.status(500).json({ code: "APPROVAL_REQUEST_GET_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2504
|
+
}
|
|
2505
|
+
},
|
|
2506
|
+
metadata: { summary: "Get an approval request by id", tags: ["approvals"] }
|
|
2507
|
+
});
|
|
2508
|
+
const decisionRoute = (suffix, method) => {
|
|
2509
|
+
this.routeManager.register({
|
|
2510
|
+
method: "POST",
|
|
2511
|
+
path: `${dataPath}/approvals/requests/:id/${suffix}`,
|
|
2512
|
+
handler: async (req, res) => {
|
|
2513
|
+
try {
|
|
2514
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2515
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2516
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2517
|
+
const svc = await resolveService(projectId);
|
|
2518
|
+
if (!svc) return respond501(res);
|
|
2519
|
+
const body = req.body ?? {};
|
|
2520
|
+
try {
|
|
2521
|
+
const out = await svc[method](req.params.id, {
|
|
2522
|
+
actorId: body.actorId ?? body.actor_id ?? context?.userId,
|
|
2523
|
+
comment: body.comment
|
|
2524
|
+
}, context ?? {});
|
|
2525
|
+
res.json(out);
|
|
2526
|
+
} catch (err) {
|
|
2527
|
+
if (handleApprovalError(res, err)) return;
|
|
2528
|
+
throw err;
|
|
2529
|
+
}
|
|
2530
|
+
} catch (error) {
|
|
2531
|
+
logError(`[REST] ${suffix} approval error:`, error);
|
|
2532
|
+
res.status(500).json({ code: `APPROVAL_${suffix.toUpperCase()}_FAILED`, error: String(error?.message ?? error).slice(0, 500) });
|
|
2533
|
+
}
|
|
2534
|
+
},
|
|
2535
|
+
metadata: { summary: `${suffix[0].toUpperCase()}${suffix.slice(1)} an approval request`, tags: ["approvals"] }
|
|
2536
|
+
});
|
|
2537
|
+
};
|
|
2538
|
+
decisionRoute("approve", "approve");
|
|
2539
|
+
decisionRoute("reject", "reject");
|
|
2540
|
+
decisionRoute("recall", "recall");
|
|
2541
|
+
this.routeManager.register({
|
|
2542
|
+
method: "GET",
|
|
2543
|
+
path: `${dataPath}/approvals/requests/:id/actions`,
|
|
2544
|
+
handler: async (req, res) => {
|
|
2545
|
+
try {
|
|
2546
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2547
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2548
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2549
|
+
const svc = await resolveService(projectId);
|
|
2550
|
+
if (!svc) return respond501(res);
|
|
2551
|
+
const rows = await svc.listActions(req.params.id, context ?? {});
|
|
2552
|
+
res.json({ data: rows });
|
|
2553
|
+
} catch (error) {
|
|
2554
|
+
logError("[REST] List approval actions error:", error);
|
|
2555
|
+
res.status(500).json({ code: "APPROVAL_ACTIONS_FAILED", error: String(error?.message ?? error).slice(0, 500) });
|
|
2556
|
+
}
|
|
2557
|
+
},
|
|
2558
|
+
metadata: { summary: "List actions (audit trail) for an approval request", tags: ["approvals"] }
|
|
2559
|
+
});
|
|
2560
|
+
}
|
|
2561
|
+
/**
|
|
2562
|
+
* Register batch operation endpoints
|
|
2563
|
+
*/
|
|
2564
|
+
registerBatchEndpoints(basePath) {
|
|
2565
|
+
const { crud, batch } = this.config;
|
|
2566
|
+
const dataPath = `${basePath}${crud.dataPrefix}`;
|
|
2567
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
2568
|
+
const operations = batch.operations;
|
|
2569
|
+
if (batch.enableBatchEndpoint && this.protocol.batchData) {
|
|
2570
|
+
this.routeManager.register({
|
|
2571
|
+
method: "POST",
|
|
2572
|
+
path: `${dataPath}/:object/batch`,
|
|
2573
|
+
handler: async (req, res) => {
|
|
2574
|
+
try {
|
|
2575
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2576
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
2577
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2578
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2579
|
+
const result = await p.batchData({
|
|
2580
|
+
object: req.params.object,
|
|
2581
|
+
request: req.body,
|
|
2582
|
+
...projectId ? { projectId } : {},
|
|
2583
|
+
...context ? { context } : {}
|
|
2584
|
+
});
|
|
2585
|
+
res.json(result);
|
|
2586
|
+
} catch (error) {
|
|
2587
|
+
logError("[REST] Unhandled error:", error);
|
|
2588
|
+
sendError(res, error, req.params?.object);
|
|
2589
|
+
}
|
|
2590
|
+
},
|
|
2591
|
+
metadata: {
|
|
2592
|
+
summary: "Batch operations",
|
|
2593
|
+
tags: ["data", "batch"]
|
|
2594
|
+
}
|
|
2595
|
+
});
|
|
2596
|
+
}
|
|
2597
|
+
if (operations.createMany && this.protocol.createManyData) {
|
|
2598
|
+
this.routeManager.register({
|
|
2599
|
+
method: "POST",
|
|
2600
|
+
path: `${dataPath}/:object/createMany`,
|
|
2601
|
+
handler: async (req, res) => {
|
|
2602
|
+
try {
|
|
2603
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2604
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
2605
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2606
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2607
|
+
const result = await p.createManyData({
|
|
2608
|
+
object: req.params.object,
|
|
2609
|
+
records: req.body || [],
|
|
2610
|
+
...projectId ? { projectId } : {},
|
|
2611
|
+
...context ? { context } : {}
|
|
2612
|
+
});
|
|
2613
|
+
res.status(201).json(result);
|
|
2614
|
+
} catch (error) {
|
|
2615
|
+
logError("[REST] Unhandled error:", error);
|
|
2616
|
+
sendError(res, error, req.params?.object);
|
|
2617
|
+
}
|
|
2618
|
+
},
|
|
2619
|
+
metadata: {
|
|
2620
|
+
summary: "Create multiple records",
|
|
2621
|
+
tags: ["data", "batch"]
|
|
2622
|
+
}
|
|
2623
|
+
});
|
|
2624
|
+
}
|
|
2625
|
+
if (operations.updateMany && this.protocol.updateManyData) {
|
|
2626
|
+
this.routeManager.register({
|
|
2627
|
+
method: "POST",
|
|
2628
|
+
path: `${dataPath}/:object/updateMany`,
|
|
2629
|
+
handler: async (req, res) => {
|
|
2630
|
+
try {
|
|
2631
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2632
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
2633
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2634
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
2635
|
+
const result = await p.updateManyData({
|
|
2636
|
+
object: req.params.object,
|
|
2637
|
+
...req.body,
|
|
2638
|
+
...projectId ? { projectId } : {},
|
|
2639
|
+
...context ? { context } : {}
|
|
2640
|
+
});
|
|
2641
|
+
res.json(result);
|
|
2642
|
+
} catch (error) {
|
|
2643
|
+
logError("[REST] Unhandled error:", error);
|
|
2644
|
+
sendError(res, error, req.params?.object);
|
|
2645
|
+
}
|
|
2646
|
+
},
|
|
2647
|
+
metadata: {
|
|
2648
|
+
summary: "Update multiple records",
|
|
2649
|
+
tags: ["data", "batch"]
|
|
2650
|
+
}
|
|
2651
|
+
});
|
|
2652
|
+
}
|
|
1161
2653
|
if (operations.deleteMany && this.protocol.deleteManyData) {
|
|
1162
2654
|
this.routeManager.register({
|
|
1163
2655
|
method: "POST",
|
|
@@ -1166,15 +2658,18 @@ var RestServer = class {
|
|
|
1166
2658
|
try {
|
|
1167
2659
|
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1168
2660
|
const p = await this.resolveProtocol(projectId, req);
|
|
2661
|
+
const context = await this.resolveExecCtx(projectId, req);
|
|
2662
|
+
if (this.enforceAuth(req, res, context)) return;
|
|
1169
2663
|
const result = await p.deleteManyData({
|
|
1170
2664
|
object: req.params.object,
|
|
1171
2665
|
...req.body,
|
|
1172
|
-
...projectId ? { projectId } : {}
|
|
2666
|
+
...projectId ? { projectId } : {},
|
|
2667
|
+
...context ? { context } : {}
|
|
1173
2668
|
});
|
|
1174
2669
|
res.json(result);
|
|
1175
2670
|
} catch (error) {
|
|
1176
2671
|
logError("[REST] Unhandled error:", error);
|
|
1177
|
-
res
|
|
2672
|
+
sendError(res, error, req.params?.object);
|
|
1178
2673
|
}
|
|
1179
2674
|
},
|
|
1180
2675
|
metadata: {
|
|
@@ -1368,6 +2863,41 @@ function createRestApiPlugin(config = {}) {
|
|
|
1368
2863
|
return void 0;
|
|
1369
2864
|
}
|
|
1370
2865
|
};
|
|
2866
|
+
const emailServiceProvider = async (_projectId) => {
|
|
2867
|
+
try {
|
|
2868
|
+
return ctx.getService("email");
|
|
2869
|
+
} catch {
|
|
2870
|
+
return void 0;
|
|
2871
|
+
}
|
|
2872
|
+
};
|
|
2873
|
+
const sharingServiceProvider = async (_projectId) => {
|
|
2874
|
+
try {
|
|
2875
|
+
return ctx.getService("sharing");
|
|
2876
|
+
} catch {
|
|
2877
|
+
return void 0;
|
|
2878
|
+
}
|
|
2879
|
+
};
|
|
2880
|
+
const reportsServiceProvider = async (_projectId) => {
|
|
2881
|
+
try {
|
|
2882
|
+
return ctx.getService("reports");
|
|
2883
|
+
} catch {
|
|
2884
|
+
return void 0;
|
|
2885
|
+
}
|
|
2886
|
+
};
|
|
2887
|
+
const approvalsServiceProvider = async (_projectId) => {
|
|
2888
|
+
try {
|
|
2889
|
+
return ctx.getService("approvals");
|
|
2890
|
+
} catch {
|
|
2891
|
+
return void 0;
|
|
2892
|
+
}
|
|
2893
|
+
};
|
|
2894
|
+
const sharingRulesServiceProvider = async (_projectId) => {
|
|
2895
|
+
try {
|
|
2896
|
+
return ctx.getService("sharingRules");
|
|
2897
|
+
} catch {
|
|
2898
|
+
return void 0;
|
|
2899
|
+
}
|
|
2900
|
+
};
|
|
1371
2901
|
if (!server) {
|
|
1372
2902
|
ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
|
|
1373
2903
|
return;
|
|
@@ -1378,7 +2908,7 @@ function createRestApiPlugin(config = {}) {
|
|
|
1378
2908
|
}
|
|
1379
2909
|
ctx.logger.info("Hydrating REST API from Protocol...");
|
|
1380
2910
|
try {
|
|
1381
|
-
const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider);
|
|
2911
|
+
const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider);
|
|
1382
2912
|
restServer.registerRoutes();
|
|
1383
2913
|
ctx.logger.info("REST API successfully registered");
|
|
1384
2914
|
} catch (err) {
|