@galadrim/galadmin 0.2.12 → 0.3.0-alpha.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/.agents/report/blockers.json +1 -0
  2. package/.agents/report/failures/2026-09-23-api-user-filter-contract.md +31 -0
  3. package/.agents/report/failures/2026-09-23-api-values-contract.md +25 -0
  4. package/.agents/report/failures/2026-10-01-cycle-02-create-callback-contract.md +17 -0
  5. package/.agents/report/failures/2026-10-01-cycle-06-delete-callback-id-contract.md +17 -0
  6. package/.agents/report/failures/2026-10-01-cycle-07-panel-delete-callback-id-contract.md +24 -0
  7. package/.agents/report/failures/2026-10-01-cycle-13-update-callback-id-contract.md +17 -0
  8. package/.agents/report/failures/2026-10-01-cycle-15-rich-text-upload-boundary-contract.md +24 -0
  9. package/.agents/report/failures/2026-10-03-cycle-06-record-import-parser-contract.md +11 -0
  10. package/.agents/report/failures/2026-10-03-cycle-07-panel-import-parser-contract.md +24 -0
  11. package/.agents/report/failures/2026-10-07-resume-cycle-02-api-assembly-contract.md +46 -0
  12. package/.agents/report/port/2026-09-23-api-config-enrichment.md +13 -0
  13. package/.agents/report/port/2026-09-23-api-file-upload.md +11 -0
  14. package/.agents/report/port/2026-09-23-api-query-authorization.md +14 -0
  15. package/.agents/report/port/2026-09-23-api-request-configuration.md +11 -0
  16. package/.agents/report/port/2026-09-23-database-manager.md +13 -0
  17. package/.agents/report/port/2026-10-01-api-adjacent-record-id.md +9 -0
  18. package/.agents/report/port/2026-10-01-cycle-01-api-config-route.md +9 -0
  19. package/.agents/report/port/2026-10-01-cycle-03-custom-action.md +9 -0
  20. package/.agents/report/port/2026-10-01-cycle-04-custom-component.md +7 -0
  21. package/.agents/report/port/2026-10-01-cycle-05-custom-query.md +7 -0
  22. package/.agents/report/port/2026-10-01-cycle-08-bulk-delete.md +9 -0
  23. package/.agents/report/port/2026-10-01-cycle-09-duplicate-record.md +9 -0
  24. package/.agents/report/port/2026-10-01-cycle-10-notification-badges.md +9 -0
  25. package/.agents/report/port/2026-10-01-cycle-11-record.md +7 -0
  26. package/.agents/report/port/2026-10-01-cycle-12-search-companies.md +11 -0
  27. package/.agents/report/port/2026-10-01-cycle-14-upload-file.md +7 -0
  28. package/.agents/report/port/2026-10-03-cycle-01-create-record.md +11 -0
  29. package/.agents/report/port/2026-10-03-cycle-02-delete-record.md +11 -0
  30. package/.agents/report/port/2026-10-03-cycle-03-panel-delete-record.md +11 -0
  31. package/.agents/report/port/2026-10-03-cycle-04-update-record.md +11 -0
  32. package/.agents/report/port/2026-10-03-cycle-05-api-values.md +11 -0
  33. package/.agents/report/port/2026-10-06-cycle-01-record-import.md +9 -0
  34. package/.agents/report/port/2026-10-06-cycle-03-rich-text-upload.md +7 -0
  35. package/.agents/report/port/2026-10-06-cycle-04-user-filters.md +11 -0
  36. package/.agents/report/port/2026-10-06-resume-cycle-01-panel-import.md +9 -0
  37. package/.agents/report/port/2026-10-07-cycle-01-api-assembly.md +16 -0
  38. package/.agents/report/port/2026-10-07-cycle-02-index-entry-point.md +13 -0
  39. package/.agents/report/remediation/2026-10-02-api-user-filter-remediation.md +71 -0
  40. package/.agents/report/remediation/2026-10-02-api-values-remediation.md +17 -0
  41. package/.agents/report/remediation/2026-10-03-callback-id-contract-remediation.md +36 -0
  42. package/.agents/report/remediation/2026-10-06-import-parser-remediation.md +41 -0
  43. package/.agents/report/remediation/2026-10-06-rich-text-upload-remediation.md +33 -0
  44. package/.agents/report/remediation/2026-10-07-api-assembly-remediation.md +32 -0
  45. package/.agents/skills/port-javascript-to-typescript/SKILL.md +42 -0
  46. package/.agents/skills/port-javascript-to-typescript/agents/openai.yaml +3 -0
  47. package/.agents/skills/port-javascript-to-typescript/references/blockers.md +31 -0
  48. package/.agents/skills/port-javascript-to-typescript/references/planning.md +43 -0
  49. package/.agents/skills/port-javascript-to-typescript/references/porting.md +9 -0
  50. package/.agents/skills/port-javascript-to-typescript/references/reporting.md +24 -0
  51. package/.agents/skills/port-javascript-to-typescript/references/rules.md +8 -0
  52. package/.agents/skills/port-javascript-to-typescript/references/selection.md +16 -0
  53. package/.agents/skills/port-javascript-to-typescript/references/tools.md +20 -0
  54. package/.agents/skills/port-javascript-to-typescript/scripts/check.sh +17 -0
  55. package/.agents/skills/port-javascript-to-typescript/scripts/graph.sh +30 -0
  56. package/.agents/skills/port-javascript-to-typescript/scripts/madge.json +12 -0
  57. package/.agents/skills/port-javascript-to-typescript/scripts/oxlint.json +14 -0
  58. package/.agents/skills/port-javascript-to-typescript/scripts/test-tools.cjs +76 -0
  59. package/AGENTS.md +5 -0
  60. package/dist/api/authorization.d.ts +38 -0
  61. package/dist/api/authorization.js +74 -0
  62. package/dist/api/authorization.js.map +1 -0
  63. package/dist/api/config.d.ts +42 -0
  64. package/dist/api/config.js +105 -0
  65. package/dist/api/config.js.map +1 -0
  66. package/dist/api/filterUser.d.ts +2 -0
  67. package/dist/api/filterUser.js +24 -0
  68. package/dist/api/filterUser.js.map +1 -0
  69. package/dist/api/importRecords.d.ts +1 -0
  70. package/dist/api/importRecords.js +16 -0
  71. package/dist/api/importRecords.js.map +1 -0
  72. package/dist/api/queries.d.ts +48 -0
  73. package/dist/api/queries.js +139 -0
  74. package/dist/api/queries.js.map +1 -0
  75. package/dist/api/request.d.ts +29 -0
  76. package/dist/api/request.js +3 -0
  77. package/dist/api/request.js.map +1 -0
  78. package/dist/api/routes/adjacentRecordId.d.ts +10 -0
  79. package/dist/api/routes/adjacentRecordId.js +13 -0
  80. package/dist/api/routes/adjacentRecordId.js.map +1 -0
  81. package/dist/api/routes/config.d.ts +3 -0
  82. package/dist/api/routes/config.js +27 -0
  83. package/dist/api/routes/config.js.map +1 -0
  84. package/dist/api/routes/createRecord.d.ts +19 -0
  85. package/dist/api/routes/createRecord.js +93 -0
  86. package/dist/api/routes/createRecord.js.map +1 -0
  87. package/dist/api/routes/customAction.d.ts +17 -0
  88. package/dist/api/routes/customAction.js +35 -0
  89. package/dist/api/routes/customAction.js.map +1 -0
  90. package/dist/api/routes/customComponent.d.ts +3 -0
  91. package/dist/api/routes/customComponent.js +34 -0
  92. package/dist/api/routes/customComponent.js.map +1 -0
  93. package/dist/api/routes/customQuery.d.ts +7 -0
  94. package/dist/api/routes/customQuery.js +33 -0
  95. package/dist/api/routes/customQuery.js.map +1 -0
  96. package/dist/api/routes/deleteRecord.d.ts +9 -0
  97. package/dist/api/routes/deleteRecord.js +46 -0
  98. package/dist/api/routes/deleteRecord.js.map +1 -0
  99. package/dist/api/routes/deleteRecordFromPanel.d.ts +10 -0
  100. package/dist/api/routes/deleteRecordFromPanel.js +36 -0
  101. package/dist/api/routes/deleteRecordFromPanel.js.map +1 -0
  102. package/dist/api/routes/deleteRecords.d.ts +9 -0
  103. package/dist/api/routes/deleteRecords.js +12 -0
  104. package/dist/api/routes/deleteRecords.js.map +1 -0
  105. package/dist/api/routes/duplicateRecord.d.ts +17 -0
  106. package/dist/api/routes/duplicateRecord.js +55 -0
  107. package/dist/api/routes/duplicateRecord.js.map +1 -0
  108. package/dist/api/routes/foreignSelectRecords.d.ts +10 -0
  109. package/dist/api/routes/foreignSelectRecords.js +61 -0
  110. package/dist/api/routes/foreignSelectRecords.js.map +1 -0
  111. package/dist/api/routes/notificationBadges.d.ts +6 -0
  112. package/dist/api/routes/notificationBadges.js +31 -0
  113. package/dist/api/routes/notificationBadges.js.map +1 -0
  114. package/dist/api/routes/record.d.ts +7 -0
  115. package/dist/api/routes/record.js +32 -0
  116. package/dist/api/routes/record.js.map +1 -0
  117. package/dist/api/routes/records.d.ts +14 -0
  118. package/dist/api/routes/records.js +57 -0
  119. package/dist/api/routes/records.js.map +1 -0
  120. package/dist/api/routes/recordsExport.d.ts +13 -0
  121. package/dist/api/routes/recordsExport.js +67 -0
  122. package/dist/api/routes/recordsExport.js.map +1 -0
  123. package/dist/api/routes/recordsImport.d.ts +4 -0
  124. package/dist/api/routes/recordsImport.js +100 -0
  125. package/dist/api/routes/recordsImport.js.map +1 -0
  126. package/dist/api/routes/searchCompanies.d.ts +17 -0
  127. package/dist/api/routes/searchCompanies.js +72 -0
  128. package/dist/api/routes/searchCompanies.js.map +1 -0
  129. package/dist/api/routes/subrecordsImport.d.ts +4 -0
  130. package/dist/api/routes/subrecordsImport.js +94 -0
  131. package/dist/api/routes/subrecordsImport.js.map +1 -0
  132. package/dist/api/routes/updateRecord.d.ts +13 -0
  133. package/dist/api/routes/updateRecord.js +55 -0
  134. package/dist/api/routes/updateRecord.js.map +1 -0
  135. package/dist/api/routes/uploadFile.d.ts +3 -0
  136. package/dist/api/routes/uploadFile.js +13 -0
  137. package/dist/api/routes/uploadFile.js.map +1 -0
  138. package/dist/api/routes/uploadFileFromBetterRichTextInput.d.ts +3 -0
  139. package/dist/api/routes/uploadFileFromBetterRichTextInput.js +33 -0
  140. package/dist/api/routes/uploadFileFromBetterRichTextInput.js.map +1 -0
  141. package/dist/api/uploadFile.d.ts +4 -0
  142. package/dist/api/uploadFile.js +209 -0
  143. package/dist/api/uploadFile.js.map +1 -0
  144. package/dist/api/values.d.ts +6 -0
  145. package/dist/api/values.js +88 -0
  146. package/dist/api/values.js.map +1 -0
  147. package/dist/api.d.ts +8 -27
  148. package/dist/api.js +80 -1387
  149. package/dist/api.js.map +1 -1
  150. package/dist/index.d.ts +2 -2
  151. package/dist/index.js +8 -2
  152. package/dist/index.js.map +1 -1
  153. package/dist/tsconfig.strict.tsbuildinfo +1 -0
  154. package/dist/types/config.d.ts +15 -5
  155. package/dist/types/config.js +4 -0
  156. package/dist/types/config.js.map +1 -1
  157. package/dist/types/database.d.ts +1 -0
  158. package/dist/types/database.js +3 -0
  159. package/dist/types/database.js.map +1 -0
  160. package/dist/utils/dbManager.d.ts +32 -7
  161. package/dist/utils/dbManager.js +59 -7
  162. package/dist/utils/dbManager.js.map +1 -1
  163. package/package.json +12 -3
  164. package/src/api/authorization.ts +129 -0
  165. package/src/api/config.ts +152 -0
  166. package/src/api/filterUser.ts +28 -0
  167. package/src/api/importRecords.ts +16 -0
  168. package/src/api/queries.ts +194 -0
  169. package/src/api/request.ts +31 -0
  170. package/src/api/routes/adjacentRecordId.ts +20 -0
  171. package/src/api/routes/config.ts +15 -0
  172. package/src/api/routes/createRecord.ts +131 -0
  173. package/src/api/routes/customAction.ts +60 -0
  174. package/src/api/routes/customComponent.ts +44 -0
  175. package/src/api/routes/customQuery.ts +53 -0
  176. package/src/api/routes/deleteRecord.ts +59 -0
  177. package/src/api/routes/deleteRecordFromPanel.ts +58 -0
  178. package/src/api/routes/deleteRecords.ts +28 -0
  179. package/src/api/routes/duplicateRecord.ts +73 -0
  180. package/src/api/routes/foreignSelectRecords.ts +80 -0
  181. package/src/api/routes/notificationBadges.ts +47 -0
  182. package/src/api/routes/record.ts +48 -0
  183. package/src/api/routes/records.ts +91 -0
  184. package/src/api/routes/recordsExport.ts +92 -0
  185. package/src/api/routes/recordsImport.ts +115 -0
  186. package/src/api/routes/searchCompanies.ts +96 -0
  187. package/src/api/routes/subrecordsImport.ts +114 -0
  188. package/src/api/routes/updateRecord.ts +74 -0
  189. package/src/api/routes/uploadFile.ts +16 -0
  190. package/src/api/routes/uploadFileFromBetterRichTextInput.ts +41 -0
  191. package/src/api/uploadFile.ts +249 -0
  192. package/src/api/values.ts +96 -0
  193. package/src/api.ts +94 -1580
  194. package/src/index.ts +42 -25
  195. package/src/types/config.ts +15 -5
  196. package/src/types/database.ts +2 -0
  197. package/src/utils/dbManager.ts +99 -14
  198. package/tests/api-helpers.test.ts +59 -0
  199. package/tests/dbManager.test.ts +16 -1
  200. package/tests/helpers/fakeDb.ts +9 -7
  201. package/tests/integration/authorizationBoundary.integration.test.ts +57 -0
  202. package/tests/integration/configEnrichment.integration.test.ts +79 -0
  203. package/tests/integration/customComponentBoundary.integration.test.ts +58 -0
  204. package/tests/integration/dbManager.integration.test.ts +105 -3
  205. package/tests/integration/importParserChild.cjs +55 -0
  206. package/tests/integration/indexRuntime.integration.test.ts +22 -0
  207. package/tests/integration/indexRuntimeChild.cjs +110 -0
  208. package/tests/integration/queries.integration.test.ts +88 -0
  209. package/tests/integration/requestConfig.integration.test.ts +118 -0
  210. package/tests/integration/routes.authorization.integration.test.ts +1 -0
  211. package/tests/integration/routes.config.integration.test.ts +39 -1
  212. package/tests/integration/routes.crud.integration.test.ts +233 -1
  213. package/tests/integration/routes.custom.integration.test.ts +119 -0
  214. package/tests/integration/routes.foreign.integration.test.ts +3 -2
  215. package/tests/integration/routes.importExport.integration.test.ts +308 -6
  216. package/tests/integration/routes.integration.test.ts +4 -0
  217. package/tests/integration/routes.records.integration.test.ts +82 -6
  218. package/tests/integration/routes.upload.integration.test.ts +123 -0
  219. package/tests/integration/userFilters.integration.test.ts +225 -0
  220. package/tsconfig.json +1 -1
  221. package/tsconfig.strict.json +45 -0
  222. package/dist/front/ckeditor5-build/src/ckeditor.d.ts +0 -43
  223. package/dist/front/ckeditor5-build/src/ckeditor.js +0 -130
  224. package/dist/front/ckeditor5-build/src/ckeditor.js.map +0 -1
  225. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -83,6 +83,7 @@ describeIntegration("authorization against real databases", () => {
83
83
 
84
84
  const fruitCount = async () => {
85
85
  const { rows } = await harness.query("SELECT * FROM fruits");
86
+ if (!Array.isArray(rows)) throw new Error("Expected a row set");
86
87
  return rows.length;
87
88
  };
88
89
 
@@ -1,4 +1,6 @@
1
1
  import * as request from "supertest";
2
+ import * as express from "express";
3
+ import configRoute from "../../src/api/routes/config";
2
4
  import { buildAppForManager } from "../helpers/app";
3
5
  import { baseConfig, fruitView, VIEWER } from "../helpers/fixtures";
4
6
  import {
@@ -13,6 +15,41 @@ import {
13
15
 
14
16
  jest.mock("axios");
15
17
 
18
+ describeIntegration("config route error handling", () => {
19
+ afterEach(() => jest.restoreAllMocks());
20
+
21
+ it("logs the destructuring error and sends 500 when config middleware is absent", async () => {
22
+ const errorLog = jest.spyOn(console, "error").mockImplementation(() => {});
23
+ const app = express();
24
+ app.get("/config", configRoute());
25
+
26
+ const response = await request(app).get("/config").expect(500);
27
+
28
+ expect(response.text).toBe("Internal Server Error");
29
+ expect(errorLog).toHaveBeenCalledTimes(1);
30
+ expect(errorLog).toHaveBeenCalledWith(expect.any(TypeError));
31
+ });
32
+
33
+ it("logs serialization failures and sends 500", async () => {
34
+ const errorLog = jest.spyOn(console, "error").mockImplementation(() => {});
35
+ const app = express();
36
+ app.use((req, _res, next) => {
37
+ req.config = baseConfig({
38
+ views: [fruitView({ filtersFromUser: String })],
39
+ });
40
+ next();
41
+ });
42
+ app.get("/config", configRoute());
43
+
44
+ const response = await request(app).get("/config").expect(500);
45
+
46
+ expect(response.text).toBe("Internal Server Error");
47
+ expect(errorLog).toHaveBeenCalledTimes(1);
48
+ expect(errorLog).toHaveBeenCalledWith(expect.any(TypeError));
49
+ expect(errorLog.mock.calls[0][0].message).toContain("Serializing native function");
50
+ });
51
+ });
52
+
16
53
  /**
17
54
  * GET /api/config against real engines. Serialization and role filtering are
18
55
  * pure JS; the real database is what feeds enrichConfig (information_schema
@@ -95,7 +132,8 @@ describeIntegration("config route against real databases", () => {
95
132
  label: "Clients only",
96
133
  tableName: "orders",
97
134
  roles: ["client"],
98
- filtersFromUser: (id) => `client_id = ${id}`,
135
+ filtersFromUser: (user: { role: string; userId?: string | number }) =>
136
+ user.role === "root" ? "" : typeof user.userId === "number" ? `client_id = ${user.userId}` : "1 = 0",
99
137
  columns: [],
100
138
  },
101
139
  fruitView(),
@@ -1,4 +1,5 @@
1
1
  import * as request from "supertest";
2
+ import type { DatabaseRow } from "../../src/utils/dbManager";
2
3
  import { generateUniqueIdForObject } from "../../src/api";
3
4
  import { buildAppForManager } from "../helpers/app";
4
5
  import { baseConfig, fruitView, EDITOR, VIEWER } from "../helpers/fixtures";
@@ -68,13 +69,48 @@ describeIntegration("crud routes against real databases", () => {
68
69
  return res;
69
70
  };
70
71
 
71
- const fruits = async () => (await harness.query("SELECT * FROM fruits")).rows;
72
+ const fruits = async () => {
73
+ const { rows } = await harness.query("SELECT * FROM fruits");
74
+ if (!Array.isArray(rows) || !rows.every((row): row is DatabaseRow => !Array.isArray(row))) {
75
+ throw new Error("Expected fruit rows");
76
+ }
77
+ return rows;
78
+ };
72
79
 
73
80
  // MySQL/mysql2 drivers expose the AUTO_INCREMENT id; pg results carry no
74
81
  // insertId, so created ids stay undefined in responses (GAP 11).
75
82
  const insertIdIsExposed = () => harness.dbType !== "POSTGRESQL";
76
83
 
77
84
  describe("POST /api/updateRecord", () => {
85
+ it("preserves UUID callback IDs, normalized data and HTTP previousData", async () => {
86
+ const id = "00000000-0000-4000-8000-000000000003";
87
+ expect((await harness.query("INSERT INTO uuid_fruits (id, fruitName) VALUES (?, ?)", [id, "pomme"])).err).toBeNull();
88
+ const received: { id: DatabaseRow[string]; data: DatabaseRow }[] = [];
89
+ const receivers: string[] = [];
90
+ const instance = app({
91
+ tableName: "uuid_fruits",
92
+ editCallback: "https://api.example.com/edited",
93
+ editCallbackFunction: function (
94
+ this: { tableName: string },
95
+ payload: { id: DatabaseRow[string]; data: DatabaseRow },
96
+ ) {
97
+ receivers.push(this.tableName);
98
+ received.push(payload);
99
+ },
100
+ });
101
+ mockedAxios.post.mockResolvedValue({});
102
+ await request(instance).post("/api/updateRecord").send({
103
+ viewIndex: "0", primaryValue: id, data: { fruitName: "" },
104
+ previousData: { id, fruitName: "pomme" },
105
+ }).expect(200);
106
+ expect(mockedAxios.post).toHaveBeenCalledWith("https://api.example.com/edited", {
107
+ id, data: { fruitName: null }, previousData: { id, fruitName: "pomme" },
108
+ });
109
+ expect(received).toEqual([{ id, data: { fruitName: null } }]);
110
+ expect(receivers).toEqual(["uuid_fruits"]);
111
+ expect((await harness.query("SELECT * FROM uuid_fruits")).rows).toEqual([{ id, fruitName: null }]);
112
+ });
113
+
78
114
  it("updates the record and persists the new values", async () => {
79
115
  await createFruit({ fruitName: "pomme", acidity: 3 });
80
116
  await request(app())
@@ -455,9 +491,70 @@ describeIntegration("crud routes against real databases", () => {
455
491
  });
456
492
  expect(await fruits()).toHaveLength(1);
457
493
  });
494
+
495
+ it("keeps a panel insert committed when its callback rejects and passes the unfiltered data", async () => {
496
+ const received: { id: number | undefined; data: DatabaseRow }[] = [];
497
+ const config = panelConfig();
498
+ const panel = config.views[0].recordViewPanels[0];
499
+ Object.assign(panel.createConfig, {
500
+ columns: [...panel.createConfig.columns, {
501
+ label: "Excluded",
502
+ name: "excluded",
503
+ includedInCreatePayload: false,
504
+ }],
505
+ });
506
+ Object.assign(panel, {
507
+ createCallbackFunction: async (payload: { id: number | undefined; data: DatabaseRow }) => {
508
+ received.push(payload);
509
+ throw new Error("panel callback rejected after insertion");
510
+ },
511
+ });
512
+ const res = await request(buildAppForManager(harness.manager, baseConfig(config)))
513
+ .post("/api/createRecord")
514
+ .send({
515
+ viewIndex: 0,
516
+ panelIndex: 0,
517
+ recordId: 7,
518
+ data: { text: "hello", excluded: "callback only" },
519
+ })
520
+ .expect(200);
521
+ expect(received).toEqual([{
522
+ id: insertIdIsExposed() ? 1 : undefined,
523
+ data: { text: "hello", excluded: "callback only", fruitId: 7 },
524
+ }]);
525
+ expect(res.body).toEqual(insertIdIsExposed() ? { id: 1 } : {});
526
+ const { rows } = await harness.query("SELECT * FROM fruit_notes");
527
+ expect(rows).toEqual([{ id: 1, text: "hello", fruitId: 7 }]);
528
+ expect(await fruits()).toHaveLength(0);
529
+ });
458
530
  });
459
531
 
460
532
  describe("POST /api/deleteRecord", () => {
533
+ it("preserves UUID callback IDs and the deleted row when the HTTP callback fails", async () => {
534
+ const id = "00000000-0000-4000-8000-000000000001";
535
+ expect((await harness.query("INSERT INTO uuid_fruits (id, fruitName) VALUES (?, ?)", [id, "pomme"])).err).toBeNull();
536
+ const received: { id: DatabaseRow[string]; deletedRecord?: DatabaseRow }[] = [];
537
+ const receivers: string[] = [];
538
+ const instance = app({
539
+ tableName: "uuid_fruits",
540
+ deleteCallback: "https://api.example.com/deleted",
541
+ deleteCallbackFunction: function (
542
+ this: { tableName: string },
543
+ payload: { id: DatabaseRow[string]; deletedRecord?: DatabaseRow },
544
+ ) {
545
+ receivers.push(this.tableName);
546
+ received.push(payload);
547
+ },
548
+ });
549
+ mockedAxios.post.mockRejectedValue(new Error("delete callback unavailable"));
550
+ await request(instance).post("/api/deleteRecord")
551
+ .send({ viewIndex: "0", primaryValue: id }).expect(200);
552
+ expect(mockedAxios.post).toHaveBeenCalledWith("https://api.example.com/deleted", { id });
553
+ expect(received).toEqual([{ id, deletedRecord: { id, fruitName: "pomme" } }]);
554
+ expect(receivers).toEqual(["uuid_fruits"]);
555
+ expect((await harness.query("SELECT * FROM uuid_fruits")).rows).toEqual([]);
556
+ });
557
+
461
558
  it("selects then deletes the record, passing deletedRecord to callbacks", async () => {
462
559
  await createFruit({ fruitName: "pomme", acidity: 3 });
463
560
  const callbackFn = jest.fn();
@@ -576,9 +673,63 @@ describeIntegration("crud routes against real databases", () => {
576
673
  deletedRecord: expect.objectContaining({ text: "hello" }),
577
674
  });
578
675
  });
676
+
677
+ it.each(["table", "gallery"])("preserves UUID and missing-row callback payloads for %s panels", async (type) => {
678
+ const id = "00000000-0000-4000-8000-000000000002";
679
+ expect((await harness.query("INSERT INTO uuid_fruits (id, fruitName) VALUES (?, ?)", [id, "poire"])).err).toBeNull();
680
+ const received: { id: DatabaseRow[string]; deletedRecord?: DatabaseRow }[] = [];
681
+ const receivers: string[] = [];
682
+ const config = panelView({
683
+ type,
684
+ tableName: "uuid_fruits",
685
+ deleteCallback: "https://api.example.com/panel-deleted",
686
+ deleteCallbackFunction: function (
687
+ this: { tableName: string },
688
+ payload: { id: DatabaseRow[string]; deletedRecord?: DatabaseRow },
689
+ ) {
690
+ receivers.push(this.tableName);
691
+ received.push(payload);
692
+ },
693
+ });
694
+ const panelId = generateUniqueIdForObject(config.views[0].recordViewPanels[0]);
695
+ const instance = buildAppForManager(harness.manager, baseConfig(config));
696
+ mockedAxios.post.mockRejectedValue(new Error("panel callback unavailable"));
697
+ for (let attempt = 0; attempt < 2; attempt++) {
698
+ await request(instance).post("/api/deleteRecordFromPanel")
699
+ .send({ viewIndex: "0", panelId, primaryValue: id }).expect(200);
700
+ }
701
+ expect(mockedAxios.post).toHaveBeenLastCalledWith("https://api.example.com/panel-deleted", { id });
702
+ expect(received).toEqual([
703
+ { id, deletedRecord: { id, fruitName: "poire" } },
704
+ { id, deletedRecord: undefined },
705
+ ]);
706
+ expect(receivers).toEqual(["uuid_fruits", "uuid_fruits"]);
707
+ expect((await harness.query("SELECT * FROM uuid_fruits")).rows).toEqual([]);
708
+ });
579
709
  });
580
710
 
581
711
  describe("POST /api/deleteRecords", () => {
712
+ it("preserves the engine error for an empty selection", async () => {
713
+ await createFruit({ fruitName: "survivor" });
714
+ await request(app()).post("/api/deleteRecords")
715
+ .send({ viewIndex: 0, primaryValues: [] }).expect(500);
716
+ expect((await fruits()).map((row: DatabaseRow) => row.fruitName)).toEqual(["survivor"]);
717
+ });
718
+
719
+ it("binds UUID values including duplicates and leaves unselected rows", async () => {
720
+ const ids = [
721
+ "d28c64a3-f0e8-4df8-a3c8-a78655d212fd",
722
+ "d28c64a3-f0e8-4df8-a3c8-a78655d212fe",
723
+ "d28c64a3-f0e8-4df8-a3c8-a78655d212ff",
724
+ ];
725
+ for (const id of ids) {
726
+ expect((await harness.query("INSERT INTO uuid_fruits (id, fruitName) VALUES (?, ?)", [id, "fruit"])).err).toBeFalsy();
727
+ }
728
+ await request(app({ tableName: "uuid_fruits" })).post("/api/deleteRecords")
729
+ .send({ viewIndex: "0", primaryValues: [ids[1], ids[0], ids[1]] }).expect(200);
730
+ expect((await harness.query("SELECT id FROM uuid_fruits")).rows).toEqual([{ id: ids[2] }]);
731
+ });
732
+
582
733
  it("bulk-deletes by primary values", async () => {
583
734
  const instance = app();
584
735
  for (const name of ["a", "b", "c"]) {
@@ -621,6 +772,46 @@ describeIntegration("crud routes against real databases", () => {
621
772
  });
622
773
  });
623
774
 
775
+ describe("POST /api/duplicateRecord boundaries", () => {
776
+ it("sends the original null error when insertion fails", async () => {
777
+ await createFruit({ fruitName: "original" });
778
+ const result = await request(app()).post("/api/duplicateRecord").send({
779
+ table: "fruits", primaryValue: 1,
780
+ duplicateConfig: { primaryId: "id", fields: ["missing_column"], foreignTables: [] },
781
+ }).expect(500);
782
+ expect(result.text).toBe("");
783
+ expect(await fruits()).toHaveLength(1);
784
+ });
785
+
786
+ it("preserves foreign copy traversal and the PostgreSQL INSERT IGNORE failure", async () => {
787
+ await createFruit({ fruitName: "original" });
788
+ expect((await harness.query("INSERT INTO fruit_tags (tag, fruitId) VALUES (?, ?)", ["tag", 1])).err).toBeFalsy();
789
+ const result = await request(app({ recordViewPanels: [{ label: "Tags", tableName: "fruit_tags" }] }))
790
+ .post("/api/duplicateRecord").send({
791
+ table: "fruits", primaryValue: 1,
792
+ duplicateConfig: {
793
+ primaryId: "id", fields: ["fruitName"],
794
+ foreignTables: [
795
+ { table: "fruit_tags", fields: ["missing_column"] },
796
+ { table: "fruit_tags", fields: ["tag", "fruitId"] },
797
+ ],
798
+ },
799
+ }).expect(harness.dbType === "POSTGRESQL" ? 500 : 200);
800
+ const { rows } = await harness.query("SELECT * FROM fruit_tags ORDER BY id");
801
+ if (harness.dbType === "POSTGRESQL") {
802
+ expect(result.text).toBe("");
803
+ expect(await fruits()).toHaveLength(1);
804
+ expect(rows).toEqual([{ id: 1, tag: "tag", fruitId: 1 }]);
805
+ } else {
806
+ expect(await fruits()).toHaveLength(2);
807
+ expect(rows).toEqual([
808
+ { id: 1, tag: "tag", fruitId: 1 },
809
+ { id: 2, tag: "tag", fruitId: 1 },
810
+ ]);
811
+ }
812
+ });
813
+ });
814
+
624
815
  // INSERT IGNORE ... SET ? is MySQL-specific syntax; the PG conversion
625
816
  // pipeline does not translate it, so duplicateRecord only works on the
626
817
  // MySQL backends today.
@@ -723,6 +914,47 @@ describeIntegration("crud routes against real databases", () => {
723
914
  );
724
915
 
725
916
  describe("POST /api/customAction", () => {
917
+ it("uses the configured message without invoking an action for no selected ids", async () => {
918
+ const action = jest.fn();
919
+ const res = await request(app({
920
+ customActions: [{ label: "Run", action, successMessage: "nothing selected" }],
921
+ })).post("/api/customAction")
922
+ .send({ viewIndex: "0", actionIndex: "0", selectedRecordIds: [] })
923
+ .expect(200);
924
+ expect(res.body).toEqual({ success: true, message: "nothing selected" });
925
+ expect(action).not.toHaveBeenCalled();
926
+ });
927
+
928
+ it("passes absent records and root user ids through and continues after an empty result", async () => {
929
+ const action = jest.fn().mockResolvedValue("");
930
+ const instance = app({ customActions: [{ label: "Run", action }] }, { role: "root" });
931
+ const res = await request(instance).post("/api/customAction")
932
+ .send({ viewIndex: 0, actionIndex: 0, selectedRecordIds: [98, 99] })
933
+ .expect(200);
934
+ expect(res.body).toEqual({ success: true });
935
+ expect(action).toHaveBeenCalledTimes(2);
936
+ expect(action).toHaveBeenNthCalledWith(1, undefined, undefined);
937
+ expect(action).toHaveBeenNthCalledWith(2, undefined, undefined);
938
+ });
939
+
940
+ it("catches the missing-row-result error after a failed query and stops actions", async () => {
941
+ const action = jest.fn();
942
+ const errorLog = jest.spyOn(console, "error").mockImplementation(() => {});
943
+ try {
944
+ const res = await request(app({
945
+ selectExpression: "column_that_does_not_exist",
946
+ customActions: [{ label: "Run", action }],
947
+ })).post("/api/customAction")
948
+ .send({ viewIndex: 0, actionIndex: 0, selectedRecordIds: [1, 2] })
949
+ .expect(200);
950
+ expect(res.body).toEqual({ success: false, error: {} });
951
+ expect(action).not.toHaveBeenCalled();
952
+ expect(errorLog).toHaveBeenCalledWith(expect.any(TypeError));
953
+ } finally {
954
+ errorLog.mockRestore();
955
+ }
956
+ });
957
+
726
958
  it("runs the action for every selected record", async () => {
727
959
  const action = jest.fn().mockResolvedValue(null);
728
960
  const config = {
@@ -1,4 +1,5 @@
1
1
  import * as request from "supertest";
2
+ import type { DatabaseRow } from "../../src/utils/dbManager";
2
3
  import { buildAppForManager } from "../helpers/app";
3
4
  import { baseConfig, fruitView } from "../helpers/fixtures";
4
5
  import {
@@ -208,6 +209,33 @@ describeIntegration("custom routes against real databases", () => {
208
209
  expect(res.body[0].text).toBe("HELLO");
209
210
  });
210
211
 
212
+ it("binds every placeholder and formats all rows in key order with the formatter receiver", async () => {
213
+ await seedNote(7, "first");
214
+ await seedNote(7, "second");
215
+ const query = "SELECT * FROM fruit_notes WHERE fruitId = ? OR fruitId = ? ORDER BY id";
216
+ const formatValue = {
217
+ text: async (row: DatabaseRow) => String(row.text).toUpperCase(),
218
+ summary(row: DatabaseRow) {
219
+ return `${row.text}:${this === formatValue ? "receiver" : "wrong"}`;
220
+ },
221
+ };
222
+ const instance = app({ recordViewPanels: [{ label: "Notes", query, formatValue }] });
223
+ const result = await request(instance).get("/api/customQuery")
224
+ .query({ query, primaryValue: 7, viewIndex: "0", panelIndex: "0" }).expect(200);
225
+ expect(result.body.map((row: { text: string; summary: string }) => [row.text, row.summary]))
226
+ .toEqual([["FIRST", "FIRST:receiver"], ["SECOND", "SECOND:receiver"]]);
227
+ });
228
+
229
+ it("returns unformatted rows when panel selection is omitted", async () => {
230
+ await seedNote(7, "hello");
231
+ const formatValue = { text: jest.fn(() => "formatted") };
232
+ const instance = app({ recordViewPanels: [{ label: "Notes", query: PANEL_QUERY, formatValue }] });
233
+ const result = await request(instance).get("/api/customQuery")
234
+ .query({ query: PANEL_QUERY, primaryValue: 7, viewIndex: "0" }).expect(200);
235
+ expect(result.body[0].text).toBe("hello");
236
+ expect(formatValue.text).not.toHaveBeenCalled();
237
+ });
238
+
211
239
  it("returns 500 on engine error", async () => {
212
240
  const config = baseConfig({
213
241
  views: [
@@ -224,6 +252,57 @@ describeIntegration("custom routes against real databases", () => {
224
252
  });
225
253
 
226
254
  describe("GET /api/notificationBadges", () => {
255
+ it("retains PostgreSQL's truthy string zero count", async () => {
256
+ const result = await request(app({ notificationBadge: {
257
+ query: "SELECT COUNT(*) AS count FROM fruits", attribute: "count",
258
+ } })).get("/api/notificationBadges").expect(200);
259
+ expect(Object.values(result.body)).toEqual(harness.dbType === "POSTGRESQL" ? ["0"] : []);
260
+ });
261
+
262
+ it("continues after a mutation query and overwrites missing ids in view/subview order", async () => {
263
+ const config = baseConfig({ enriched: true, views: [
264
+ fruitView({
265
+ notificationBadge: { query: "UPDATE fruits SET note = 'badge'", attribute: "affectedRows" },
266
+ subviews: [{ label: "First", filters: "", notificationBadge: { query: "SELECT 2 AS count", attribute: "count" } }],
267
+ }),
268
+ fruitView({ notificationBadge: { query: "SELECT 3 AS count", attribute: "count" } }),
269
+ ] });
270
+ const result = await request(buildAppForManager(harness.manager, config))
271
+ .get("/api/notificationBadges").expect(200);
272
+ expect(result.body).toEqual({ undefined: 3 });
273
+ });
274
+
275
+ it("retains repeated badge property reads and the condition receiver", async () => {
276
+ let attributeReads = 0;
277
+ let conditionReads = 0;
278
+ let receivedBadge: DatabaseRow[string] | undefined;
279
+ let receivedOwner: object | undefined;
280
+ const badge = {
281
+ query: "SELECT 1 AS firstBadge, 7 AS secondBadge",
282
+ get attribute() { return ++attributeReads === 1 ? "firstBadge" : "secondBadge"; },
283
+ get displayCondition() {
284
+ conditionReads++;
285
+ return function (this: object, value: DatabaseRow[string]) {
286
+ receivedBadge = value;
287
+ receivedOwner = this;
288
+ return true;
289
+ };
290
+ },
291
+ };
292
+ const config = baseConfig({ enriched: true, views: [fruitView({ notificationBadge: badge })] });
293
+ const instance = buildAppForManager(harness.manager, config);
294
+ // API construction scans configured query properties before requests.
295
+ attributeReads = 0;
296
+ conditionReads = 0;
297
+ const result = await request(instance)
298
+ .get("/api/notificationBadges").expect(200);
299
+ expect(result.body).toEqual({ undefined: 7 });
300
+ expect(attributeReads).toBe(2);
301
+ expect(conditionReads).toBe(2);
302
+ expect(receivedBadge).toBe(7);
303
+ expect(receivedOwner).toBe(badge);
304
+ });
305
+
227
306
  it("computes badges per view and subview", async () => {
228
307
  const view = fruitView({
229
308
  notificationBadge: {
@@ -283,6 +362,46 @@ describeIntegration("custom routes against real databases", () => {
283
362
  const opendatasoft = () =>
284
363
  jest.requireMock("axios") as { get: jest.Mock; post: jest.Mock };
285
364
 
365
+ it("ignores primitive entries and preserves raw optional field values and address coercion", async () => {
366
+ opendatasoft().get.mockResolvedValueOnce({ data: { results: [
367
+ 0, false, "primitive", [],
368
+ { etatadministratifetablissement: "Actif", denominationunitelegale: null,
369
+ siret: 123, numerovoieetablissement: 0, typevoieetablissement: false,
370
+ libellevoieetablissement: null },
371
+ ] } });
372
+ const result = await request(app()).get("/api/searchCompanies").query({ query: "test" }).expect(200);
373
+ expect(result.body).toEqual([{ id: expect.any(String), companyName: null, siret: 123, address: " " }]);
374
+ });
375
+
376
+ it("preserves active results with deeply nested unused JSON fields", async () => {
377
+ const depth = 10_000;
378
+ const data: unknown = JSON.parse(
379
+ '{"results":[{"etatadministratifetablissement":"Actif","denominationunitelegale":"keep","unused":' +
380
+ '{"nested":'.repeat(depth) + '0' + '}'.repeat(depth) + '}]}'
381
+ );
382
+ opendatasoft().get.mockResolvedValueOnce({ data });
383
+ const result = await request(app()).get("/api/searchCompanies").query({ query: "test" }).expect(200);
384
+ expect(result.body).toEqual([{ id: expect.any(String), companyName: "keep", address: " " }]);
385
+ });
386
+
387
+ it.each([{ results: [null] }, { results: {} }, {}])("returns [] for malformed results %j", async (data) => {
388
+ opendatasoft().get.mockResolvedValueOnce({ data });
389
+ const result = await request(app()).get("/api/searchCompanies").query({ query: "test" }).expect(200);
390
+ expect(result.body).toEqual([]);
391
+ });
392
+
393
+ it("keeps the first thirty active establishments in original order", async () => {
394
+ opendatasoft().get.mockResolvedValueOnce({ data: { results: [
395
+ { etatadministratifetablissement: "Fermé", denominationunitelegale: "closed" },
396
+ ...Array.from({ length: 35 }, (_, index) => ({
397
+ etatadministratifetablissement: "Actif", denominationunitelegale: `company-${index}`,
398
+ })),
399
+ ] } });
400
+ const result = await request(app()).get("/api/searchCompanies").query({ query: "test" }).expect(200);
401
+ expect(result.body.map((company: { companyName: string }) => company.companyName))
402
+ .toEqual(Array.from({ length: 30 }, (_, index) => `company-${index}`));
403
+ });
404
+
286
405
  it("proxies the SIRENE search and maps active establishments", async () => {
287
406
  const axiosMock = opendatasoft();
288
407
  axiosMock.get.mockResolvedValueOnce({
@@ -188,7 +188,7 @@ describeIntegration("foreignSelectRecords against real databases", () => {
188
188
  expect(limited.body).toHaveLength(5);
189
189
  });
190
190
 
191
- it("applies column.filters and column.filtersFromUser", async () => {
191
+ it("FILTER USER POLICY: applies column.filters and column.filtersFromUser", async () => {
192
192
  await insertFamilies([
193
193
  { name: "rose", familyId: 21, active: 1, ownerId: 2 },
194
194
  { name: "apple", familyId: 22, active: 0, ownerId: 2 },
@@ -196,7 +196,8 @@ describeIntegration("foreignSelectRecords against real databases", () => {
196
196
  ]);
197
197
  const overrides = {
198
198
  filters: "active = 1",
199
- filtersFromUser: (userId: number) => `ownerId = ${userId}`,
199
+ filtersFromUser: (user: { role: string; userId?: string | number }) =>
200
+ user.role === "root" ? "" : typeof user.userId === "number" ? `ownerId = ${user.userId}` : "1 = 0",
200
201
  };
201
202
  // EDITOR.userId === 2: only the active family owned by user 2.
202
203
  const res = await postForeign(