@objectstack/rest 4.1.0 → 4.2.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 +427 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +32 -1
- package/dist/index.d.ts +32 -1
- package/dist/index.js +427 -14
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.cjs
CHANGED
|
@@ -250,6 +250,18 @@ 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 === "CONCURRENT_UPDATE" || error?.name === "ConcurrentUpdateError") {
|
|
254
|
+
return {
|
|
255
|
+
status: 409,
|
|
256
|
+
body: {
|
|
257
|
+
error: error?.message ?? "Record was modified by another user",
|
|
258
|
+
code: "CONCURRENT_UPDATE",
|
|
259
|
+
...error?.currentVersion ? { currentVersion: error.currentVersion } : {},
|
|
260
|
+
...error?.currentRecord ? { currentRecord: error.currentRecord } : {},
|
|
261
|
+
...object ? { object } : {}
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
}
|
|
253
265
|
if (error?.code === "VALIDATION_FAILED" || error?.name === "ValidationError") {
|
|
254
266
|
return {
|
|
255
267
|
status: 400,
|
|
@@ -430,7 +442,7 @@ function rowsToCsv(fields, rows, includeHeader) {
|
|
|
430
442
|
return lines.join("\r\n") + (lines.length > 0 ? "\r\n" : "");
|
|
431
443
|
}
|
|
432
444
|
var RestServer = class {
|
|
433
|
-
constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider) {
|
|
445
|
+
constructor(server, protocol, config = {}, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider) {
|
|
434
446
|
this.protocol = protocol;
|
|
435
447
|
this.config = this.normalizeConfig(config);
|
|
436
448
|
this.routeManager = new RouteManager(server);
|
|
@@ -444,6 +456,7 @@ var RestServer = class {
|
|
|
444
456
|
this.reportsServiceProvider = reportsServiceProvider;
|
|
445
457
|
this.approvalsServiceProvider = approvalsServiceProvider;
|
|
446
458
|
this.sharingRulesServiceProvider = sharingRulesServiceProvider;
|
|
459
|
+
this.i18nServiceProvider = i18nServiceProvider;
|
|
447
460
|
}
|
|
448
461
|
/**
|
|
449
462
|
* Resolve the protocol for a given request. When `projectId` is present
|
|
@@ -511,14 +524,51 @@ var RestServer = class {
|
|
|
511
524
|
* requests intentionally return `undefined` because the platform kernel
|
|
512
525
|
* does not own per-app translation bundles.
|
|
513
526
|
*/
|
|
514
|
-
async resolveI18nService(projectId) {
|
|
515
|
-
if (
|
|
516
|
-
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
527
|
+
async resolveI18nService(projectId, req) {
|
|
528
|
+
if (projectId === "platform") return void 0;
|
|
529
|
+
if (!projectId && req && this.envRegistry && this.kernelManager) {
|
|
530
|
+
const host = this.extractHostname(req);
|
|
531
|
+
if (host) {
|
|
532
|
+
try {
|
|
533
|
+
const result = await this.envRegistry.resolveByHostname(host);
|
|
534
|
+
if (result?.projectId) projectId = result.projectId;
|
|
535
|
+
} catch {
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (!projectId && typeof this.envRegistry.resolveById === "function") {
|
|
539
|
+
const headerVal = this.extractProjectIdHeader(req);
|
|
540
|
+
if (headerVal) {
|
|
541
|
+
try {
|
|
542
|
+
const driver = await this.envRegistry.resolveById(headerVal);
|
|
543
|
+
if (driver) projectId = headerVal;
|
|
544
|
+
} catch {
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
521
548
|
}
|
|
549
|
+
if (!projectId && this.defaultProjectIdProvider) {
|
|
550
|
+
try {
|
|
551
|
+
const def = this.defaultProjectIdProvider();
|
|
552
|
+
if (def) projectId = def;
|
|
553
|
+
} catch {
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (projectId && this.kernelManager) {
|
|
557
|
+
try {
|
|
558
|
+
const kernel = await this.kernelManager.getOrCreate(projectId);
|
|
559
|
+
const svc = await kernel.getServiceAsync("i18n");
|
|
560
|
+
if (svc) return svc;
|
|
561
|
+
} catch {
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if (this.i18nServiceProvider) {
|
|
565
|
+
try {
|
|
566
|
+
return await this.i18nServiceProvider(projectId);
|
|
567
|
+
} catch {
|
|
568
|
+
return void 0;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return void 0;
|
|
522
572
|
}
|
|
523
573
|
/**
|
|
524
574
|
* Reject anonymous requests with HTTP 401 when `api.requireAuth` is set.
|
|
@@ -720,8 +770,8 @@ var RestServer = class {
|
|
|
720
770
|
*/
|
|
721
771
|
async translateMetaItem(req, type, projectId, item) {
|
|
722
772
|
if (!item || typeof item !== "object") return item;
|
|
723
|
-
if (type !== "view" && type !== "action") return item;
|
|
724
|
-
const i18n = await this.resolveI18nService(projectId);
|
|
773
|
+
if (type !== "view" && type !== "action" && type !== "object") return item;
|
|
774
|
+
const i18n = await this.resolveI18nService(projectId, req);
|
|
725
775
|
const bundle = this.buildTranslationBundle(i18n);
|
|
726
776
|
if (!bundle) return item;
|
|
727
777
|
const locale = this.extractLocale(req, i18n);
|
|
@@ -734,8 +784,8 @@ var RestServer = class {
|
|
|
734
784
|
*/
|
|
735
785
|
async translateMetaItems(req, type, projectId, items) {
|
|
736
786
|
if (!Array.isArray(items)) return items;
|
|
737
|
-
if (type !== "view" && type !== "action") return items;
|
|
738
|
-
const i18n = await this.resolveI18nService(projectId);
|
|
787
|
+
if (type !== "view" && type !== "action" && type !== "object") return items;
|
|
788
|
+
const i18n = await this.resolveI18nService(projectId, req);
|
|
739
789
|
const bundle = this.buildTranslationBundle(i18n);
|
|
740
790
|
if (!bundle) return items;
|
|
741
791
|
const locale = this.extractLocale(req, i18n);
|
|
@@ -895,6 +945,7 @@ var RestServer = class {
|
|
|
895
945
|
this.registerSearchEndpoints(bp);
|
|
896
946
|
}
|
|
897
947
|
this.registerEmailEndpoints(bp);
|
|
948
|
+
this.registerFormEndpoints(bp);
|
|
898
949
|
this.registerSharingEndpoints(bp);
|
|
899
950
|
this.registerSharingRuleEndpoints(bp);
|
|
900
951
|
this.registerReportsEndpoints(bp);
|
|
@@ -1381,10 +1432,19 @@ var RestServer = class {
|
|
|
1381
1432
|
const p = await this.resolveProtocol(projectId, req);
|
|
1382
1433
|
const context = await this.resolveExecCtx(projectId, req);
|
|
1383
1434
|
if (this.enforceAuth(req, res, context)) return;
|
|
1435
|
+
const ifMatchHeader = req.headers?.["if-match"] ?? req.headers?.["If-Match"];
|
|
1436
|
+
const bodyVersion = req.body && typeof req.body === "object" ? req.body.expectedVersion : void 0;
|
|
1437
|
+
const expectedVersion = bodyVersion ?? ifMatchHeader;
|
|
1438
|
+
let data = req.body;
|
|
1439
|
+
if (data && typeof data === "object" && "expectedVersion" in data) {
|
|
1440
|
+
const { expectedVersion: _drop, ...rest } = data;
|
|
1441
|
+
data = rest;
|
|
1442
|
+
}
|
|
1384
1443
|
const result = await p.updateData({
|
|
1385
1444
|
object: req.params.object,
|
|
1386
1445
|
id: req.params.id,
|
|
1387
|
-
data
|
|
1446
|
+
data,
|
|
1447
|
+
...expectedVersion ? { expectedVersion: String(expectedVersion) } : {},
|
|
1388
1448
|
...projectId ? { projectId } : {},
|
|
1389
1449
|
...context ? { context } : {}
|
|
1390
1450
|
});
|
|
@@ -1411,9 +1471,13 @@ var RestServer = class {
|
|
|
1411
1471
|
const p = await this.resolveProtocol(projectId, req);
|
|
1412
1472
|
const context = await this.resolveExecCtx(projectId, req);
|
|
1413
1473
|
if (this.enforceAuth(req, res, context)) return;
|
|
1474
|
+
const ifMatchHeader = req.headers?.["if-match"] ?? req.headers?.["If-Match"];
|
|
1475
|
+
const queryVersion = req.query && typeof req.query === "object" ? req.query.expectedVersion : void 0;
|
|
1476
|
+
const expectedVersion = queryVersion ?? ifMatchHeader;
|
|
1414
1477
|
const result = await p.deleteData({
|
|
1415
1478
|
object: req.params.object,
|
|
1416
1479
|
id: req.params.id,
|
|
1480
|
+
...expectedVersion ? { expectedVersion: String(expectedVersion) } : {},
|
|
1417
1481
|
...projectId ? { projectId } : {},
|
|
1418
1482
|
...context ? { context } : {}
|
|
1419
1483
|
});
|
|
@@ -1824,6 +1888,348 @@ var RestServer = class {
|
|
|
1824
1888
|
}
|
|
1825
1889
|
});
|
|
1826
1890
|
}
|
|
1891
|
+
/**
|
|
1892
|
+
* Register public (anonymous) form endpoints.
|
|
1893
|
+
*
|
|
1894
|
+
* Public forms are opt-in: a `FormView` becomes accessible to anonymous
|
|
1895
|
+
* visitors only when `sharing.allowAnonymous === true` AND a
|
|
1896
|
+
* `sharing.publicLink` slug is configured. Two routes are registered:
|
|
1897
|
+
*
|
|
1898
|
+
* GET {basePath}/forms/:slug → resolved form spec
|
|
1899
|
+
* POST {basePath}/forms/:slug/submit → INSERT record (no auth required)
|
|
1900
|
+
*
|
|
1901
|
+
* Both routes bypass `enforceAuth` even when `requireAuth=true` on the
|
|
1902
|
+
* deployment (e.g. ObjectOS multi-tenant). Security is delegated to the
|
|
1903
|
+
* `guest_portal` permission set carried on the execution context — the
|
|
1904
|
+
* SecurityPlugin enforces INSERT-only access to the target object. If
|
|
1905
|
+
* the deployment hasn't registered a `guest_portal` profile, the
|
|
1906
|
+
* security middleware falls open with `permissions: []` (no userId),
|
|
1907
|
+
* matching the existing anonymous-access semantics; deployers must
|
|
1908
|
+
* keep `requireAuth=true` deployments paired with a `guest_portal`
|
|
1909
|
+
* profile (the CRM example does this) to enforce the INSERT-only
|
|
1910
|
+
* contract.
|
|
1911
|
+
*
|
|
1912
|
+
* The matched FormView's parent ViewSchema is found by scanning
|
|
1913
|
+
* `protocol.getMetaItems({ type: 'view' })`. For each entry we inspect
|
|
1914
|
+
* `form.sharing` and every entry in `formViews`; the first FormView
|
|
1915
|
+
* whose `sharing.publicLink` matches `/forms/:slug` (or just `:slug`)
|
|
1916
|
+
* wins. The response carries the matched form view under `form` and
|
|
1917
|
+
* the inferred target object, matching what the frontend's
|
|
1918
|
+
* `mapViewSpecToEmbeddableConfig` expects.
|
|
1919
|
+
*/
|
|
1920
|
+
registerFormEndpoints(basePath) {
|
|
1921
|
+
const isScoped = basePath.includes("/projects/:projectId");
|
|
1922
|
+
const slugMatchesPublicLink = (publicLink, slug) => {
|
|
1923
|
+
if (!publicLink || typeof publicLink !== "string") return false;
|
|
1924
|
+
const normalized = publicLink.replace(/^\/+/, "").replace(/^forms\//, "");
|
|
1925
|
+
return normalized === slug;
|
|
1926
|
+
};
|
|
1927
|
+
const findPublicFormView = (views, slug) => {
|
|
1928
|
+
for (const view of views ?? []) {
|
|
1929
|
+
if (!view || typeof view !== "object") continue;
|
|
1930
|
+
const candidates = [];
|
|
1931
|
+
if (view.form && view.form.sharing) candidates.push({ form: view.form });
|
|
1932
|
+
const formViews = view.formViews;
|
|
1933
|
+
if (formViews && typeof formViews === "object") {
|
|
1934
|
+
for (const [key, fv] of Object.entries(formViews)) {
|
|
1935
|
+
if (fv && typeof fv === "object" && fv.sharing) {
|
|
1936
|
+
candidates.push({ form: fv, key });
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
for (const c of candidates) {
|
|
1941
|
+
const sharing = c.form?.sharing;
|
|
1942
|
+
if (!sharing || sharing.allowAnonymous !== true) continue;
|
|
1943
|
+
if (!slugMatchesPublicLink(sharing.publicLink, slug)) continue;
|
|
1944
|
+
const objectName = c.form?.data?.object ?? view?.list?.data?.object ?? view?.form?.data?.object ?? view?.object;
|
|
1945
|
+
if (!objectName) continue;
|
|
1946
|
+
return { view, form: c.form, object: objectName };
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
return null;
|
|
1950
|
+
};
|
|
1951
|
+
const resolveFormBySlug = async (projectId, req, slug) => {
|
|
1952
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1953
|
+
if (typeof p.getMetaItems !== "function") return null;
|
|
1954
|
+
const result = await p.getMetaItems({
|
|
1955
|
+
type: "view",
|
|
1956
|
+
...projectId ? { projectId } : {}
|
|
1957
|
+
});
|
|
1958
|
+
const items = Array.isArray(result?.items) ? result.items : Array.isArray(result) ? result : [];
|
|
1959
|
+
return findPublicFormView(items, slug);
|
|
1960
|
+
};
|
|
1961
|
+
this.routeManager.register({
|
|
1962
|
+
method: "GET",
|
|
1963
|
+
path: `${basePath}/forms/:slug`,
|
|
1964
|
+
handler: async (req, res) => {
|
|
1965
|
+
try {
|
|
1966
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
1967
|
+
const slug = String(req.params?.slug ?? "").trim();
|
|
1968
|
+
if (!slug) {
|
|
1969
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "slug is required" });
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
const match = await resolveFormBySlug(projectId, req, slug);
|
|
1973
|
+
if (!match) {
|
|
1974
|
+
res.status(404).json({
|
|
1975
|
+
code: "FORM_NOT_FOUND",
|
|
1976
|
+
error: `No public form configured at /forms/${slug}`
|
|
1977
|
+
});
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
let objectSchema = null;
|
|
1981
|
+
try {
|
|
1982
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
1983
|
+
if (typeof p.getMetaItems === "function") {
|
|
1984
|
+
const r = await p.getMetaItems({
|
|
1985
|
+
type: "object",
|
|
1986
|
+
...projectId ? { projectId } : {}
|
|
1987
|
+
});
|
|
1988
|
+
const items = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
|
|
1989
|
+
const obj = items.find((o) => o?.name === match.object);
|
|
1990
|
+
if (obj && obj.fields && typeof obj.fields === "object") {
|
|
1991
|
+
const allowed = /* @__PURE__ */ new Set();
|
|
1992
|
+
for (const sec of match.form?.sections ?? []) {
|
|
1993
|
+
for (const f of sec?.fields ?? []) {
|
|
1994
|
+
if (typeof f === "string") allowed.add(f);
|
|
1995
|
+
else if (f?.field) allowed.add(f.field);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
const fields = {};
|
|
1999
|
+
for (const [name, def] of Object.entries(obj.fields)) {
|
|
2000
|
+
if (allowed.size === 0 || allowed.has(name)) {
|
|
2001
|
+
fields[name] = def;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
objectSchema = { name: obj.name, label: obj.label, fields };
|
|
2005
|
+
try {
|
|
2006
|
+
const i18n = await this.resolveI18nService(projectId, req);
|
|
2007
|
+
const bundle = this.buildTranslationBundle(i18n);
|
|
2008
|
+
const locale = this.extractLocale(req, i18n);
|
|
2009
|
+
if (bundle && locale) {
|
|
2010
|
+
const { translateMetadataDocument } = await import("@objectstack/spec/system");
|
|
2011
|
+
objectSchema = translateMetadataDocument("object", objectSchema, bundle, { locale });
|
|
2012
|
+
}
|
|
2013
|
+
} catch (e) {
|
|
2014
|
+
logError("[REST] Public form schema translation failed:", e);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
} catch (e) {
|
|
2019
|
+
logError("[REST] Public form schema load failed:", e);
|
|
2020
|
+
}
|
|
2021
|
+
const safeForm = (() => {
|
|
2022
|
+
if (!match.form || !Array.isArray(match.form.sections)) return match.form;
|
|
2023
|
+
const allow = (name, cfg) => {
|
|
2024
|
+
const def = objectSchema?.fields?.[name];
|
|
2025
|
+
const t = def?.type;
|
|
2026
|
+
if (t !== "lookup" && t !== "master_detail") return true;
|
|
2027
|
+
return !!cfg?.publicPicker;
|
|
2028
|
+
};
|
|
2029
|
+
const sections = match.form.sections.map((sec) => {
|
|
2030
|
+
const fields = (sec?.fields ?? []).filter((f) => {
|
|
2031
|
+
const name = typeof f === "string" ? f : f?.field;
|
|
2032
|
+
if (!name) return false;
|
|
2033
|
+
const cfg = typeof f === "string" ? {} : f;
|
|
2034
|
+
return allow(name, cfg);
|
|
2035
|
+
});
|
|
2036
|
+
return { ...sec, fields };
|
|
2037
|
+
});
|
|
2038
|
+
return { ...match.form, sections };
|
|
2039
|
+
})();
|
|
2040
|
+
res.header("Vary", "Accept-Language");
|
|
2041
|
+
res.json({
|
|
2042
|
+
slug,
|
|
2043
|
+
object: match.object,
|
|
2044
|
+
label: match.view?.label ?? match.form?.label,
|
|
2045
|
+
form: safeForm,
|
|
2046
|
+
objectSchema
|
|
2047
|
+
});
|
|
2048
|
+
} catch (error) {
|
|
2049
|
+
logError("[REST] Public form resolve error:", error);
|
|
2050
|
+
res.status(500).json({
|
|
2051
|
+
code: "FORM_RESOLVE_FAILED",
|
|
2052
|
+
error: String(error?.message ?? error ?? "resolve failed").slice(0, 500)
|
|
2053
|
+
});
|
|
2054
|
+
}
|
|
2055
|
+
},
|
|
2056
|
+
metadata: {
|
|
2057
|
+
summary: "Resolve a public form spec by slug (anonymous)",
|
|
2058
|
+
tags: ["forms", "public"]
|
|
2059
|
+
}
|
|
2060
|
+
});
|
|
2061
|
+
this.routeManager.register({
|
|
2062
|
+
method: "POST",
|
|
2063
|
+
path: `${basePath}/forms/:slug/submit`,
|
|
2064
|
+
handler: async (req, res) => {
|
|
2065
|
+
try {
|
|
2066
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2067
|
+
const slug = String(req.params?.slug ?? "").trim();
|
|
2068
|
+
if (!slug) {
|
|
2069
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "slug is required" });
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
const match = await resolveFormBySlug(projectId, req, slug);
|
|
2073
|
+
if (!match) {
|
|
2074
|
+
res.status(404).json({
|
|
2075
|
+
code: "FORM_NOT_FOUND",
|
|
2076
|
+
error: `No public form configured at /forms/${slug}`
|
|
2077
|
+
});
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
2080
|
+
const allowedFields = /* @__PURE__ */ new Set();
|
|
2081
|
+
for (const section of match.form?.sections ?? []) {
|
|
2082
|
+
for (const f of section?.fields ?? []) {
|
|
2083
|
+
if (typeof f === "string") allowedFields.add(f);
|
|
2084
|
+
else if (f?.field) allowedFields.add(f.field);
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
const rawBody = req.body && typeof req.body === "object" ? req.body : {};
|
|
2088
|
+
const filteredData = {};
|
|
2089
|
+
if (allowedFields.size > 0) {
|
|
2090
|
+
for (const [k, v] of Object.entries(rawBody)) {
|
|
2091
|
+
if (allowedFields.has(k)) filteredData[k] = v;
|
|
2092
|
+
}
|
|
2093
|
+
} else {
|
|
2094
|
+
Object.assign(filteredData, rawBody);
|
|
2095
|
+
}
|
|
2096
|
+
const context = {
|
|
2097
|
+
permissions: ["guest_portal"],
|
|
2098
|
+
anonymous: true
|
|
2099
|
+
};
|
|
2100
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
2101
|
+
const result = await p.createData({
|
|
2102
|
+
object: match.object,
|
|
2103
|
+
data: filteredData,
|
|
2104
|
+
...projectId ? { projectId } : {},
|
|
2105
|
+
context
|
|
2106
|
+
});
|
|
2107
|
+
res.status(201).json(result);
|
|
2108
|
+
} catch (error) {
|
|
2109
|
+
const mapped = mapDataError(error);
|
|
2110
|
+
if (!isExpectedDataStatus(mapped.status) && mapped.body?.code !== "VALIDATION_FAILED") {
|
|
2111
|
+
logError("[REST] Public form submit error:", error);
|
|
2112
|
+
}
|
|
2113
|
+
res.status(mapped.status).json(mapped.body);
|
|
2114
|
+
}
|
|
2115
|
+
},
|
|
2116
|
+
metadata: {
|
|
2117
|
+
summary: "Submit an anonymous public form",
|
|
2118
|
+
tags: ["forms", "public"]
|
|
2119
|
+
}
|
|
2120
|
+
});
|
|
2121
|
+
this.routeManager.register({
|
|
2122
|
+
method: "GET",
|
|
2123
|
+
path: `${basePath}/forms/:slug/lookup/:field`,
|
|
2124
|
+
handler: async (req, res) => {
|
|
2125
|
+
try {
|
|
2126
|
+
const projectId = isScoped ? req.params?.projectId : void 0;
|
|
2127
|
+
const slug = String(req.params?.slug ?? "").trim();
|
|
2128
|
+
const fieldName = String(req.params?.field ?? "").trim();
|
|
2129
|
+
if (!slug || !fieldName) {
|
|
2130
|
+
res.status(400).json({ code: "INVALID_REQUEST", error: "slug and field are required" });
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
const match = await resolveFormBySlug(projectId, req, slug);
|
|
2134
|
+
if (!match) {
|
|
2135
|
+
res.status(404).json({
|
|
2136
|
+
code: "FORM_NOT_FOUND",
|
|
2137
|
+
error: `No public form configured at /forms/${slug}`
|
|
2138
|
+
});
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
let fieldCfg = null;
|
|
2142
|
+
for (const sec of match.form?.sections ?? []) {
|
|
2143
|
+
for (const f of sec?.fields ?? []) {
|
|
2144
|
+
const name = typeof f === "string" ? f : f?.field;
|
|
2145
|
+
if (name === fieldName) {
|
|
2146
|
+
fieldCfg = typeof f === "string" ? {} : f;
|
|
2147
|
+
break;
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
if (fieldCfg) break;
|
|
2151
|
+
}
|
|
2152
|
+
const picker = fieldCfg?.publicPicker;
|
|
2153
|
+
if (!picker) {
|
|
2154
|
+
res.status(403).json({
|
|
2155
|
+
code: "LOOKUP_NOT_PUBLIC",
|
|
2156
|
+
error: `Field "${fieldName}" is not enabled for public lookup on this form`
|
|
2157
|
+
});
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
const p = await this.resolveProtocol(projectId, req);
|
|
2161
|
+
let referenceTo = picker.object;
|
|
2162
|
+
if (!referenceTo && typeof p.getMetaItems === "function") {
|
|
2163
|
+
try {
|
|
2164
|
+
const r = await p.getMetaItems({
|
|
2165
|
+
type: "object",
|
|
2166
|
+
...projectId ? { projectId } : {}
|
|
2167
|
+
});
|
|
2168
|
+
const items = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
|
|
2169
|
+
const obj = items.find((o) => o?.name === match.object);
|
|
2170
|
+
const def = obj?.fields?.[fieldName];
|
|
2171
|
+
referenceTo = def?.referenceTo ?? def?.target ?? def?.options?.objectName;
|
|
2172
|
+
} catch {
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
if (!referenceTo) {
|
|
2176
|
+
res.status(500).json({
|
|
2177
|
+
code: "LOOKUP_TARGET_MISSING",
|
|
2178
|
+
error: `Could not resolve referenced object for "${fieldName}"`
|
|
2179
|
+
});
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
const displayFields = Array.isArray(picker.displayFields) && picker.displayFields.length > 0 ? picker.displayFields.slice(0, 5) : ["name"];
|
|
2183
|
+
const hardCap = 50;
|
|
2184
|
+
const maxResults = Math.min(Math.max(1, Number(picker.maxResults) || 20), hardCap);
|
|
2185
|
+
const q = String(req.query?.q ?? "").trim().slice(0, 100);
|
|
2186
|
+
const filters = [];
|
|
2187
|
+
if (Array.isArray(picker.filter)) filters.push(...picker.filter);
|
|
2188
|
+
if (q) filters.push({ field: displayFields[0], operator: "contains", value: q });
|
|
2189
|
+
const context = {
|
|
2190
|
+
permissions: ["guest_portal"],
|
|
2191
|
+
anonymous: true
|
|
2192
|
+
};
|
|
2193
|
+
const result = await p.findData({
|
|
2194
|
+
object: referenceTo,
|
|
2195
|
+
query: {
|
|
2196
|
+
limit: maxResults,
|
|
2197
|
+
offset: 0,
|
|
2198
|
+
filters,
|
|
2199
|
+
select: ["id", ...displayFields],
|
|
2200
|
+
sort: picker.sort ?? [{ field: displayFields[0], order: "asc" }]
|
|
2201
|
+
},
|
|
2202
|
+
...projectId ? { projectId } : {},
|
|
2203
|
+
context
|
|
2204
|
+
});
|
|
2205
|
+
const rows = Array.isArray(result?.data) ? result.data : Array.isArray(result?.items) ? result.items : [];
|
|
2206
|
+
const projected = rows.slice(0, maxResults).map((row) => {
|
|
2207
|
+
const out = { id: row?.id };
|
|
2208
|
+
for (const f of displayFields) {
|
|
2209
|
+
if (row && Object.prototype.hasOwnProperty.call(row, f)) out[f] = row[f];
|
|
2210
|
+
}
|
|
2211
|
+
return out;
|
|
2212
|
+
});
|
|
2213
|
+
res.json({
|
|
2214
|
+
data: projected,
|
|
2215
|
+
total: projected.length,
|
|
2216
|
+
truncated: rows.length >= maxResults,
|
|
2217
|
+
displayFields
|
|
2218
|
+
});
|
|
2219
|
+
} catch (error) {
|
|
2220
|
+
const mapped = mapDataError(error);
|
|
2221
|
+
if (!isExpectedDataStatus(mapped.status)) {
|
|
2222
|
+
logError("[REST] Public form lookup error:", error);
|
|
2223
|
+
}
|
|
2224
|
+
res.status(mapped.status).json(mapped.body);
|
|
2225
|
+
}
|
|
2226
|
+
},
|
|
2227
|
+
metadata: {
|
|
2228
|
+
summary: "Scoped lookup picker for a public form field (anonymous)",
|
|
2229
|
+
tags: ["forms", "public"]
|
|
2230
|
+
}
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
1827
2233
|
/**
|
|
1828
2234
|
* Register record-level sharing endpoints (M11.C17).
|
|
1829
2235
|
*
|
|
@@ -2937,6 +3343,13 @@ function createRestApiPlugin(config = {}) {
|
|
|
2937
3343
|
return void 0;
|
|
2938
3344
|
}
|
|
2939
3345
|
};
|
|
3346
|
+
const i18nServiceProvider = async (_projectId) => {
|
|
3347
|
+
try {
|
|
3348
|
+
return ctx.getService("i18n");
|
|
3349
|
+
} catch {
|
|
3350
|
+
return void 0;
|
|
3351
|
+
}
|
|
3352
|
+
};
|
|
2940
3353
|
if (!server) {
|
|
2941
3354
|
ctx.logger.warn(`RestApiPlugin: HTTP Server service '${serverService}' not found. REST routes skipped.`);
|
|
2942
3355
|
return;
|
|
@@ -2947,7 +3360,7 @@ function createRestApiPlugin(config = {}) {
|
|
|
2947
3360
|
}
|
|
2948
3361
|
ctx.logger.info("Hydrating REST API from Protocol...");
|
|
2949
3362
|
try {
|
|
2950
|
-
const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider);
|
|
3363
|
+
const restServer = new RestServer(server, protocol, config.api, kernelManager, envRegistry, defaultProjectIdProvider, authServiceProvider, objectQLProvider, emailServiceProvider, sharingServiceProvider, reportsServiceProvider, approvalsServiceProvider, sharingRulesServiceProvider, i18nServiceProvider);
|
|
2951
3364
|
restServer.registerRoutes();
|
|
2952
3365
|
ctx.logger.info("REST API successfully registered");
|
|
2953
3366
|
} catch (err) {
|