@aruvili/api 0.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.
Files changed (158) hide show
  1. package/dist/config.d.ts +22 -0
  2. package/dist/config.d.ts.map +1 -0
  3. package/dist/config.js +34 -0
  4. package/dist/config.js.map +1 -0
  5. package/dist/context.d.ts +7 -0
  6. package/dist/context.d.ts.map +1 -0
  7. package/dist/context.js +3 -0
  8. package/dist/context.js.map +1 -0
  9. package/dist/controllers/index.d.ts +39 -0
  10. package/dist/controllers/index.d.ts.map +1 -0
  11. package/dist/controllers/index.js +39 -0
  12. package/dist/controllers/index.js.map +1 -0
  13. package/dist/db.d.ts +6 -0
  14. package/dist/db.d.ts.map +1 -0
  15. package/dist/db.js +74 -0
  16. package/dist/db.js.map +1 -0
  17. package/dist/index.d.ts +17 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +154 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/middleware/auth.d.ts +15 -0
  22. package/dist/middleware/auth.d.ts.map +1 -0
  23. package/dist/middleware/auth.js +93 -0
  24. package/dist/middleware/auth.js.map +1 -0
  25. package/dist/middleware/body-limit.d.ts +9 -0
  26. package/dist/middleware/body-limit.d.ts.map +1 -0
  27. package/dist/middleware/body-limit.js +15 -0
  28. package/dist/middleware/body-limit.js.map +1 -0
  29. package/dist/middleware/rate-limit.d.ts +6 -0
  30. package/dist/middleware/rate-limit.d.ts.map +1 -0
  31. package/dist/middleware/rate-limit.js +40 -0
  32. package/dist/middleware/rate-limit.js.map +1 -0
  33. package/dist/middleware/rbac.d.ts +10 -0
  34. package/dist/middleware/rbac.d.ts.map +1 -0
  35. package/dist/middleware/rbac.js +61 -0
  36. package/dist/middleware/rbac.js.map +1 -0
  37. package/dist/middleware/tenant.d.ts +3 -0
  38. package/dist/middleware/tenant.d.ts.map +1 -0
  39. package/dist/middleware/tenant.js +19 -0
  40. package/dist/middleware/tenant.js.map +1 -0
  41. package/dist/registry.d.ts +26 -0
  42. package/dist/registry.d.ts.map +1 -0
  43. package/dist/registry.js +112 -0
  44. package/dist/registry.js.map +1 -0
  45. package/dist/routes/auth.d.ts +3 -0
  46. package/dist/routes/auth.d.ts.map +1 -0
  47. package/dist/routes/auth.js +141 -0
  48. package/dist/routes/auth.js.map +1 -0
  49. package/dist/routes/crud.d.ts +7 -0
  50. package/dist/routes/crud.d.ts.map +1 -0
  51. package/dist/routes/crud.js +845 -0
  52. package/dist/routes/crud.js.map +1 -0
  53. package/dist/routes/files.d.ts +7 -0
  54. package/dist/routes/files.d.ts.map +1 -0
  55. package/dist/routes/files.js +123 -0
  56. package/dist/routes/files.js.map +1 -0
  57. package/dist/routes/meta.d.ts +3 -0
  58. package/dist/routes/meta.d.ts.map +1 -0
  59. package/dist/routes/meta.js +352 -0
  60. package/dist/routes/meta.js.map +1 -0
  61. package/dist/scheduler.d.ts +33 -0
  62. package/dist/scheduler.d.ts.map +1 -0
  63. package/dist/scheduler.js +97 -0
  64. package/dist/scheduler.js.map +1 -0
  65. package/dist/utils/link-validator.d.ts +7 -0
  66. package/dist/utils/link-validator.d.ts.map +1 -0
  67. package/dist/utils/link-validator.js +33 -0
  68. package/dist/utils/link-validator.js.map +1 -0
  69. package/dist/utils/resolver.d.ts +5 -0
  70. package/dist/utils/resolver.d.ts.map +1 -0
  71. package/dist/utils/resolver.js +58 -0
  72. package/dist/utils/resolver.js.map +1 -0
  73. package/package.json +24 -0
  74. package/src/api.test.ts +362 -0
  75. package/src/config.d.ts +22 -0
  76. package/src/config.d.ts.map +1 -0
  77. package/src/config.js +34 -0
  78. package/src/config.js.map +1 -0
  79. package/src/config.ts +38 -0
  80. package/src/context.d.ts +7 -0
  81. package/src/context.d.ts.map +1 -0
  82. package/src/context.js +3 -0
  83. package/src/context.js.map +1 -0
  84. package/src/context.ts +8 -0
  85. package/src/controllers/index.d.ts +39 -0
  86. package/src/controllers/index.d.ts.map +1 -0
  87. package/src/controllers/index.js +39 -0
  88. package/src/controllers/index.js.map +1 -0
  89. package/src/controllers/index.ts +51 -0
  90. package/src/db.d.ts +6 -0
  91. package/src/db.d.ts.map +1 -0
  92. package/src/db.js +74 -0
  93. package/src/db.js.map +1 -0
  94. package/src/db.ts +73 -0
  95. package/src/index.ts +178 -0
  96. package/src/integration.test.ts +453 -0
  97. package/src/middleware/auth.d.ts +15 -0
  98. package/src/middleware/auth.d.ts.map +1 -0
  99. package/src/middleware/auth.js +93 -0
  100. package/src/middleware/auth.js.map +1 -0
  101. package/src/middleware/auth.ts +109 -0
  102. package/src/middleware/body-limit.d.ts +9 -0
  103. package/src/middleware/body-limit.d.ts.map +1 -0
  104. package/src/middleware/body-limit.js +15 -0
  105. package/src/middleware/body-limit.js.map +1 -0
  106. package/src/middleware/body-limit.ts +16 -0
  107. package/src/middleware/rate-limit.d.ts +6 -0
  108. package/src/middleware/rate-limit.d.ts.map +1 -0
  109. package/src/middleware/rate-limit.js +40 -0
  110. package/src/middleware/rate-limit.js.map +1 -0
  111. package/src/middleware/rate-limit.ts +47 -0
  112. package/src/middleware/rbac.d.ts +10 -0
  113. package/src/middleware/rbac.d.ts.map +1 -0
  114. package/src/middleware/rbac.js +61 -0
  115. package/src/middleware/rbac.js.map +1 -0
  116. package/src/middleware/rbac.ts +71 -0
  117. package/src/middleware/tenant.d.ts +3 -0
  118. package/src/middleware/tenant.d.ts.map +1 -0
  119. package/src/middleware/tenant.js +19 -0
  120. package/src/middleware/tenant.js.map +1 -0
  121. package/src/middleware/tenant.ts +24 -0
  122. package/src/registry.d.ts +26 -0
  123. package/src/registry.d.ts.map +1 -0
  124. package/src/registry.js +112 -0
  125. package/src/registry.js.map +1 -0
  126. package/src/registry.ts +123 -0
  127. package/src/routes/auth.d.ts +3 -0
  128. package/src/routes/auth.d.ts.map +1 -0
  129. package/src/routes/auth.js +141 -0
  130. package/src/routes/auth.js.map +1 -0
  131. package/src/routes/auth.ts +164 -0
  132. package/src/routes/crud.d.ts +7 -0
  133. package/src/routes/crud.d.ts.map +1 -0
  134. package/src/routes/crud.js +845 -0
  135. package/src/routes/crud.js.map +1 -0
  136. package/src/routes/crud.ts +1029 -0
  137. package/src/routes/files.d.ts +7 -0
  138. package/src/routes/files.d.ts.map +1 -0
  139. package/src/routes/files.js +123 -0
  140. package/src/routes/files.js.map +1 -0
  141. package/src/routes/files.ts +143 -0
  142. package/src/routes/meta.d.ts +3 -0
  143. package/src/routes/meta.d.ts.map +1 -0
  144. package/src/routes/meta.js +352 -0
  145. package/src/routes/meta.js.map +1 -0
  146. package/src/routes/meta.ts +448 -0
  147. package/src/scheduler.ts +118 -0
  148. package/src/utils/link-validator.d.ts +7 -0
  149. package/src/utils/link-validator.d.ts.map +1 -0
  150. package/src/utils/link-validator.js +33 -0
  151. package/src/utils/link-validator.js.map +1 -0
  152. package/src/utils/link-validator.ts +45 -0
  153. package/src/utils/resolver.d.ts +5 -0
  154. package/src/utils/resolver.d.ts.map +1 -0
  155. package/src/utils/resolver.js +58 -0
  156. package/src/utils/resolver.js.map +1 -0
  157. package/src/utils/resolver.ts +65 -0
  158. package/tsconfig.json +9 -0
@@ -0,0 +1,1029 @@
1
+ import { Hono } from 'hono';
2
+ import { registry } from '../registry.js';
3
+ import { query, withTransaction } from '../db.js';
4
+ import { getTableName } from '@aruvili/core';
5
+ import { controllerRegistry, ControllerContext } from '../controllers/index.js';
6
+ import { resolveLinkFields } from '../utils/resolver.js';
7
+ import { validateLinks } from '../utils/link-validator.js';
8
+ import crypto from 'crypto';
9
+
10
+ export const crudRouter = new Hono<{ Variables: { user: any } }>();
11
+
12
+ /**
13
+ * Helper to compute diff between old and new JSON objects for audit trail.
14
+ */
15
+ function computeDiff(oldVal: any, newVal: any) {
16
+ const diff: Record<string, any> = {};
17
+ const keys = new Set([...Object.keys(oldVal || {}), ...Object.keys(newVal || {})]);
18
+ for (const key of keys) {
19
+ if (key === 'updated_at' || key === 'modified_by' || key === 'version') continue;
20
+ if (Array.isArray(oldVal?.[key]) || Array.isArray(newVal?.[key])) continue;
21
+ if (oldVal?.[key] !== newVal?.[key]) {
22
+ diff[key] = [oldVal?.[key] ?? null, newVal?.[key] ?? null];
23
+ }
24
+ }
25
+ return diff;
26
+ }
27
+
28
+ /**
29
+ * Dynamic naming generator pipeline.
30
+ */
31
+ async function generateDocumentName(client: any, doctype: any, body: any): Promise<string> {
32
+ const rule = doctype.naming_rule || 'UUID';
33
+
34
+ if (rule === 'UUID') return crypto.randomUUID();
35
+
36
+ if (rule === 'Autoincrement') {
37
+ const table = getTableName(doctype.name);
38
+ const res = await client.query(`SELECT COALESCE(MAX(CAST(name AS INTEGER)), 0) + 1 AS next FROM ${table}`);
39
+ return String(res.rows[0].next);
40
+ }
41
+
42
+ if (rule === 'NamingSeries') {
43
+ const seriesFormat = doctype.naming_series || 'DOC-.#####';
44
+ const prefix = seriesFormat.split('.')[0] || 'DOC-';
45
+ const res = await client.query(
46
+ `INSERT INTO _naming_series (prefix, current_value) VALUES ($1, 1)
47
+ ON CONFLICT (prefix) DO UPDATE SET current_value = _naming_series.current_value + 1
48
+ RETURNING current_value`, [prefix]
49
+ );
50
+ const val = res.rows[0].current_value;
51
+ const padMatch = seriesFormat.match(/#+/);
52
+ const padSize = padMatch ? padMatch[0].length : 5;
53
+ const paddedNum = String(val).padStart(padSize, '0');
54
+ let name = seriesFormat.replace(/#+/, paddedNum).replace(/\./g, '');
55
+ const now = new Date();
56
+ name = name.replace(/YYYY/g, String(now.getFullYear()));
57
+ name = name.replace(/MM/g, String(now.getMonth() + 1).padStart(2, '0'));
58
+ return name;
59
+ }
60
+
61
+ if (rule === 'Expression') {
62
+ const expr = doctype.naming_series || 'DOC-{name}';
63
+ let name = expr;
64
+ const matches = expr.match(/\{([^}]+)\}/g);
65
+ if (matches) {
66
+ for (const m of matches) {
67
+ name = name.replace(m, body[m.replace(/[{}]/g, '')] || '');
68
+ }
69
+ }
70
+ return name.trim() !== '' ? name : crypto.randomUUID();
71
+ }
72
+
73
+ return crypto.randomUUID();
74
+ }
75
+
76
+ /**
77
+ * POST /api/doctype/:doctype — Create document
78
+ */
79
+ crudRouter.post('/', async (c) => {
80
+ const doctypeName = c.req.param('doctype') as string;
81
+ const body = await c.req.json();
82
+ const user = c.get('user');
83
+
84
+ const definition = await registry.get(doctypeName);
85
+ if (!definition) return c.json({ error: `DocType '${doctypeName}' not found` }, 404);
86
+
87
+ const validator = await registry.getValidator(doctypeName);
88
+ if (!validator) return c.json({ error: 'Failed to compile schema validator' }, 500);
89
+
90
+ const parsed = validator.safeParse(body);
91
+ if (!parsed.success) return c.json({ error: 'Validation failed', details: parsed.error.format() }, 400);
92
+
93
+ const table = getTableName(doctypeName);
94
+
95
+ try {
96
+ const document = await withTransaction(async (client) => {
97
+ const ctx: ControllerContext = { db: client, user };
98
+ const docName = body.name || await generateDocumentName(client, definition, body);
99
+
100
+ const record: Record<string, any> = {
101
+ ...parsed.data, name: docName, docstatus: 0,
102
+ created_by: user.email, modified_by: user.email
103
+ };
104
+
105
+ // Set workflow initial state default if missing
106
+ if (definition.workflow && record[definition.workflow.fieldname] === undefined) {
107
+ record[definition.workflow.fieldname] = definition.workflow.initial_state;
108
+ }
109
+
110
+ // Set field-level defaults if missing
111
+ for (const field of definition.fields) {
112
+ if (record[field.fieldname] === undefined && field.default !== undefined) {
113
+ record[field.fieldname] = field.default;
114
+ }
115
+ }
116
+
117
+ // Link validation
118
+ const linkErrors = await validateLinks(definition, record, client);
119
+ if (linkErrors.length > 0) throw new Error(`Link validation failed: ${linkErrors.join('; ')}`);
120
+
121
+ await controllerRegistry.triggerBeforeInsert(doctypeName, record, ctx);
122
+ await controllerRegistry.triggerBeforeSave(doctypeName, record, ctx);
123
+
124
+ const parentCols: string[] = ['name', 'created_by', 'modified_by', 'docstatus'];
125
+ const parentVals: any[] = [record.name, record.created_by, record.modified_by, record.docstatus];
126
+
127
+ for (const field of definition.fields) {
128
+ if (field.fieldtype === 'Table' || field.fieldtype === 'Table MultiSelect') {
129
+ continue; // Handled separately
130
+ }
131
+ parentCols.push(field.fieldname);
132
+ parentVals.push(record[field.fieldname] ?? null);
133
+ }
134
+
135
+ // Insert parent record
136
+ const colPlaceholders = parentVals.map((_, i) => `$${i + 1}`).join(', ');
137
+ const sql = `INSERT INTO ${table} (${parentCols.join(', ')}) VALUES (${colPlaceholders}) RETURNING *`;
138
+ const insertRes = await client.query(sql, parentVals);
139
+ const savedParent = insertRes.rows[0];
140
+
141
+ // Process and insert Child Tables nested collections
142
+ const childData: Record<string, any[]> = {};
143
+
144
+ for (const field of definition.fields) {
145
+ if (field.fieldtype === 'Table' && Array.isArray(body[field.fieldname])) {
146
+ const childDocTypeName = field.options!;
147
+ const childTable = getTableName(childDocTypeName);
148
+ const childRowsInput = body[field.fieldname] as any[];
149
+
150
+ const childDefinition = await registry.get(childDocTypeName);
151
+ if (!childDefinition) {
152
+ throw new Error(`Child DocType '${childDocTypeName}' definition missing.`);
153
+ }
154
+
155
+ const childValidator = await registry.getValidator(childDocTypeName);
156
+ if (!childValidator) {
157
+ throw new Error(`Failed to compile validator for child '${childDocTypeName}'.`);
158
+ }
159
+
160
+ const savedChildren: any[] = [];
161
+ let idx = 1;
162
+
163
+ for (const row of childRowsInput) {
164
+ const childParsed = childValidator.safeParse(row);
165
+ if (!childParsed.success) {
166
+ throw new Error(`Child '${childDocTypeName}' validation failed: ${JSON.stringify(childParsed.error.format())}`);
167
+ }
168
+
169
+ const rowName = row.name || crypto.randomUUID();
170
+ const childRecord: Record<string, any> = {
171
+ ...childParsed.data,
172
+ name: rowName,
173
+ parent: record.name,
174
+ parenttype: doctypeName,
175
+ parentfield: field.fieldname,
176
+ idx: idx++,
177
+ created_by: user.email,
178
+ modified_by: user.email
179
+ };
180
+
181
+ const childCols = ['name', 'parent', 'parenttype', 'parentfield', 'idx', 'created_by', 'modified_by'];
182
+ const childVals = [
183
+ childRecord.name,
184
+ childRecord.parent,
185
+ childRecord.parenttype,
186
+ childRecord.parentfield,
187
+ childRecord.idx,
188
+ childRecord.created_by,
189
+ childRecord.modified_by
190
+ ];
191
+
192
+ for (const cf of childDefinition.fields) {
193
+ if (cf.fieldtype === 'Table' || cf.fieldtype === 'Table MultiSelect') continue;
194
+ childCols.push(cf.fieldname);
195
+ childVals.push(childRecord[cf.fieldname] ?? null);
196
+ }
197
+
198
+ const childPlaceholder = childVals.map((_, i) => `$${i + 1}`).join(', ');
199
+ const childSql = `INSERT INTO ${childTable} (${childCols.join(', ')}) VALUES (${childPlaceholder}) RETURNING *`;
200
+ const childRes = await client.query(childSql, childVals);
201
+ savedChildren.push(childRes.rows[0]);
202
+ }
203
+
204
+ childData[field.fieldname] = savedChildren;
205
+ }
206
+ }
207
+
208
+ const finalRecord = { ...savedParent, ...childData };
209
+
210
+ // Trigger after_insert hooks
211
+ await controllerRegistry.triggerAfterInsert(doctypeName, finalRecord, ctx);
212
+
213
+ // Audit Log
214
+ await client.query(
215
+ `INSERT INTO _audit_log (doctype_name, docname, action, changed_by, diff)
216
+ VALUES ($1, $2, 'CREATE', $3, $4)`,
217
+ [doctypeName, finalRecord.name, user.email, JSON.stringify(record)]
218
+ );
219
+
220
+ return finalRecord;
221
+ });
222
+
223
+ return c.json(document);
224
+ } catch (err: any) {
225
+ const status = err.message.includes('Link validation failed') ? 400 : 500;
226
+ if (status === 500) {
227
+ console.error('Create record failed:', err);
228
+ }
229
+ return c.json({ error: 'Failed to create record in database', details: err.message }, status as any);
230
+ }
231
+ });
232
+
233
+ /**
234
+ * GET /api/doctype/:doctype
235
+ * Dynamic list endpoint with enterprise-safe SQL construction.
236
+ */
237
+ crudRouter.get('/', async (c) => {
238
+ const doctypeName = c.req.param('doctype') as string;
239
+
240
+ const definition = await registry.get(doctypeName);
241
+ if (!definition) {
242
+ return c.json({ error: `DocType '${doctypeName}' not found` }, 404);
243
+ }
244
+
245
+ const table = getTableName(doctypeName);
246
+
247
+ // Parsing query settings with safe defaults
248
+ const limitRaw = parseInt(c.req.query('limit') || '20');
249
+ const limit = Math.min(Math.max(isNaN(limitRaw) ? 20 : limitRaw, 1), 100);
250
+ const offsetRaw = parseInt(c.req.query('offset') || '0');
251
+ const offset = Math.max(isNaN(offsetRaw) ? 0 : offsetRaw, 0);
252
+ const resolveLinks = c.req.query('resolve_links') === 'true';
253
+
254
+ // Whitelist-validated ORDER BY to prevent SQL injection
255
+ const allowedSortColumns = new Set([
256
+ 'name', 'created_at', 'updated_at', 'docstatus',
257
+ ...definition.fields.filter(f => f.fieldtype !== 'Table' && f.fieldtype !== 'Table MultiSelect').map(f => f.fieldname)
258
+ ]);
259
+ const orderByInput = c.req.query('order_by') || 'created_at desc';
260
+ const orderParts = orderByInput.split(',').map(p => p.trim()).slice(0, 3); // Max 3 sort fields
261
+ const safeSortClauses: string[] = [];
262
+ for (const part of orderParts) {
263
+ const [col, dir] = part.split(/\s+/);
264
+ if (col && allowedSortColumns.has(col.toLowerCase())) {
265
+ const safeDir = dir?.toLowerCase() === 'asc' ? 'ASC' : 'DESC';
266
+ safeSortClauses.push(`${col.toLowerCase()} ${safeDir}`);
267
+ }
268
+ }
269
+ const orderByClause = safeSortClauses.length > 0 ? safeSortClauses.join(', ') : 'created_at DESC';
270
+
271
+ // Construct filters - only allow known schema columns via parameterized queries
272
+ const reservedParams = ['limit', 'offset', 'order_by', 'resolve_links', 'fields'];
273
+ const filterKeys = Object.keys(c.req.query()).filter(k => !reservedParams.includes(k));
274
+
275
+ const filters: string[] = [];
276
+ const params: any[] = [];
277
+ let paramIdx = 1;
278
+
279
+ for (const key of filterKeys) {
280
+ const fieldDef = definition.fields.find(f => f.fieldname === key);
281
+ const isSystemCol = ['name', 'docstatus', 'uuid', 'created_by', 'modified_by'].includes(key);
282
+ if (fieldDef || isSystemCol) {
283
+ // Use the validated column name, not user input
284
+ const safeCol = fieldDef ? fieldDef.fieldname : key;
285
+ filters.push(`${safeCol} = $${paramIdx++}`);
286
+ params.push(c.req.query(key));
287
+ }
288
+ }
289
+
290
+ const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
291
+
292
+ // Whitelist-validated SELECT columns to prevent over-fetching
293
+ const allowedFields = new Set([
294
+ 'name', 'uuid', 'created_at', 'updated_at', 'created_by', 'modified_by', 'docstatus', 'version',
295
+ ...definition.fields.filter(f => f.fieldtype !== 'Table' && f.fieldtype !== 'Table MultiSelect').map(f => f.fieldname)
296
+ ]);
297
+ const fieldsInput = c.req.query('fields');
298
+ let selectFields = '*';
299
+ if (fieldsInput) {
300
+ const requested = fieldsInput.split(',').map(f => f.trim().toLowerCase());
301
+ const matched = requested.filter(f => allowedFields.has(f));
302
+ if (matched.length > 0) {
303
+ selectFields = matched.join(', ');
304
+ }
305
+ }
306
+
307
+ try {
308
+ const countSql = `SELECT COUNT(*) AS total FROM ${table} ${whereClause}`;
309
+ const countRes = await query(countSql, params);
310
+ const totalCount = parseInt(countRes.rows[0].total);
311
+
312
+ const sql = `SELECT ${selectFields} FROM ${table} ${whereClause} ORDER BY ${orderByClause} LIMIT ${limit} OFFSET ${offset}`;
313
+ const res = await query(sql, params);
314
+ let records = res.rows;
315
+
316
+ if (resolveLinks) {
317
+ records = await resolveLinkFields(doctypeName, records);
318
+ }
319
+
320
+ return c.json({ data: records, total_count: totalCount, limit, offset });
321
+ } catch (err: any) {
322
+ return c.json({ error: 'Failed to retrieve list', details: err.message }, 500);
323
+ }
324
+ });
325
+
326
+ /**
327
+ * GET /api/doctype/:doctype/:name
328
+ * Retrieve single record with nested child table lists.
329
+ */
330
+ crudRouter.get('/:name', async (c) => {
331
+ const doctypeName = c.req.param('doctype') as string;
332
+ const name = c.req.param('name');
333
+ const resolveLinks = c.req.query('resolve_links') === 'true';
334
+
335
+ const definition = await registry.get(doctypeName);
336
+ if (!definition) {
337
+ return c.json({ error: `DocType '${doctypeName}' not found` }, 404);
338
+ }
339
+
340
+ const table = getTableName(doctypeName);
341
+
342
+ try {
343
+ const res = await query(`SELECT * FROM ${table} WHERE name = $1`, [name]);
344
+ if (res.rows.length === 0) {
345
+ return c.json({ error: 'Document not found' }, 404);
346
+ }
347
+
348
+ let record = res.rows[0];
349
+
350
+ // Fetch related child tables rows
351
+ for (const field of definition.fields) {
352
+ if (field.fieldtype === 'Table') {
353
+ const childDocTypeName = field.options!;
354
+ const childTable = getTableName(childDocTypeName);
355
+ const childRes = await query(
356
+ `SELECT * FROM ${childTable} WHERE parent = $1 AND parenttype = $2 AND parentfield = $3 ORDER BY idx ASC`,
357
+ [name, doctypeName, field.fieldname]
358
+ );
359
+ record[field.fieldname] = childRes.rows;
360
+ }
361
+ }
362
+
363
+ if (resolveLinks) {
364
+ const resolvedList = await resolveLinkFields(doctypeName, [record]);
365
+ record = resolvedList[0];
366
+ }
367
+
368
+ return c.json(record);
369
+ } catch (err: any) {
370
+ return c.json({ error: 'Failed to retrieve document', details: err.message }, 500);
371
+ }
372
+ });
373
+
374
+ /**
375
+ * PUT /api/doctype/:doctype/:name
376
+ * Update endpoint: supports transactional delta verification & child tables rewrite.
377
+ */
378
+ crudRouter.put('/:name', async (c) => {
379
+ const doctypeName = c.req.param('doctype') as string;
380
+ const name = c.req.param('name');
381
+ const body = await c.req.json();
382
+ const user = c.get('user');
383
+
384
+ const definition = await registry.get(doctypeName);
385
+ if (!definition) {
386
+ return c.json({ error: `DocType '${doctypeName}' not found` }, 404);
387
+ }
388
+
389
+ const validator = await registry.getValidator(doctypeName);
390
+ if (!validator) {
391
+ return c.json({ error: 'Failed to compile validation shapes' }, 500);
392
+ }
393
+
394
+ const parsed = validator.safeParse(body);
395
+ if (!parsed.success) {
396
+ return c.json({ error: 'Payload validation failed', details: parsed.error.format() }, 400);
397
+ }
398
+
399
+ const table = getTableName(doctypeName);
400
+
401
+ try {
402
+ const updated = await withTransaction(async (client) => {
403
+ const ctx: ControllerContext = { db: client, user };
404
+
405
+ // Load with row lock for concurrency safety
406
+ const oldRes = await client.query(`SELECT * FROM ${table} WHERE name = $1 FOR UPDATE`, [name]);
407
+ if (oldRes.rows.length === 0) throw new Error('Document not found');
408
+ const oldRecord = oldRes.rows[0];
409
+
410
+ if (oldRecord.docstatus !== 0) throw new Error('Submitted or Cancelled documents cannot be modified.');
411
+
412
+ // Optimistic concurrency control
413
+ if (body.version !== undefined && body.version !== oldRecord.version) {
414
+ throw new Error(`Conflict: document was modified by another user (expected version ${body.version}, current ${oldRecord.version}).`);
415
+ }
416
+
417
+ const updatedRecord = { ...oldRecord, ...parsed.data, modified_by: user.email, updated_at: new Date() };
418
+
419
+ // Link validation
420
+ const linkErrors = await validateLinks(definition, updatedRecord, client);
421
+ if (linkErrors.length > 0) throw new Error(`Link validation failed: ${linkErrors.join('; ')}`);
422
+
423
+ await controllerRegistry.triggerBeforeSave(doctypeName, updatedRecord, ctx);
424
+
425
+ const updateSets: string[] = ['modified_by = $2', 'updated_at = NOW()', 'version = version + 1'];
426
+ const updateVals: any[] = [name, user.email];
427
+ let valIdx = 3;
428
+
429
+ for (const field of definition.fields) {
430
+ if (field.fieldtype === 'Table' || field.fieldtype === 'Table MultiSelect') continue;
431
+ updateSets.push(`${field.fieldname} = $${valIdx++}`);
432
+ updateVals.push(updatedRecord[field.fieldname] ?? null);
433
+ }
434
+
435
+ const parentSql = `UPDATE ${table} SET ${updateSets.join(', ')} WHERE name = $1 RETURNING *`;
436
+ const parentRes = await client.query(parentSql, updateVals);
437
+ const savedParent = parentRes.rows[0];
438
+
439
+ // Dynamic rewrite of child tables collections (clear & replace)
440
+ const childData: Record<string, any[]> = {};
441
+
442
+ for (const field of definition.fields) {
443
+ if (field.fieldtype === 'Table') {
444
+ const childDocTypeName = field.options!;
445
+ const childTable = getTableName(childDocTypeName);
446
+ const childRowsInput = body[field.fieldname] as any[] || [];
447
+
448
+ const childDefinition = await registry.get(childDocTypeName);
449
+ if (!childDefinition) throw new Error(`Child definition missing: ${childDocTypeName}`);
450
+
451
+ const childValidator = await registry.getValidator(childDocTypeName);
452
+ if (!childValidator) throw new Error(`Child validator missing: ${childDocTypeName}`);
453
+
454
+ // Remove old items
455
+ await client.query(
456
+ `DELETE FROM ${childTable} WHERE parent = $1 AND parenttype = $2 AND parentfield = $3`,
457
+ [name, doctypeName, field.fieldname]
458
+ );
459
+
460
+ const savedChildren: any[] = [];
461
+ let idx = 1;
462
+
463
+ for (const row of childRowsInput) {
464
+ const childParsed = childValidator.safeParse(row);
465
+ if (!childParsed.success) {
466
+ throw new Error(`Child validation failed: ${JSON.stringify(childParsed.error.format())}`);
467
+ }
468
+
469
+ const rowName = row.name || crypto.randomUUID();
470
+ const childRecord: Record<string, any> = {
471
+ ...childParsed.data,
472
+ name: rowName,
473
+ parent: name,
474
+ parenttype: doctypeName,
475
+ parentfield: field.fieldname,
476
+ idx: idx++,
477
+ created_by: oldRecord.created_by,
478
+ modified_by: user.email
479
+ };
480
+
481
+ const childCols = ['name', 'parent', 'parenttype', 'parentfield', 'idx', 'created_by', 'modified_by'];
482
+ const childVals = [
483
+ childRecord.name,
484
+ childRecord.parent,
485
+ childRecord.parenttype,
486
+ childRecord.parentfield,
487
+ childRecord.idx,
488
+ childRecord.created_by,
489
+ childRecord.modified_by
490
+ ];
491
+
492
+ for (const cf of childDefinition.fields) {
493
+ if (cf.fieldtype === 'Table' || cf.fieldtype === 'Table MultiSelect') continue;
494
+ childCols.push(cf.fieldname);
495
+ childVals.push(childRecord[cf.fieldname] ?? null);
496
+ }
497
+
498
+ const childPlaceholder = childVals.map((_, i) => `$${i + 1}`).join(', ');
499
+ const childSql = `INSERT INTO ${childTable} (${childCols.join(', ')}) VALUES (${childPlaceholder}) RETURNING *`;
500
+ const childRes = await client.query(childSql, childVals);
501
+ savedChildren.push(childRes.rows[0]);
502
+ }
503
+
504
+ childData[field.fieldname] = savedChildren;
505
+ }
506
+ }
507
+
508
+ const finalRecord = { ...savedParent, ...childData };
509
+
510
+ // Trigger on_update hooks
511
+ await controllerRegistry.triggerOnUpdate(doctypeName, finalRecord, oldRecord, ctx);
512
+
513
+ // Compute scalar diffs & save version details
514
+ const diffLogs = computeDiff(oldRecord, finalRecord);
515
+
516
+ await client.query(
517
+ `INSERT INTO _audit_log (doctype_name, docname, action, changed_by, diff)
518
+ VALUES ($1, $2, 'UPDATE', $3, $4)`,
519
+ [doctypeName, name, user.email, JSON.stringify(diffLogs)]
520
+ );
521
+
522
+ return finalRecord;
523
+ });
524
+
525
+ return c.json(updated);
526
+ } catch (err: any) {
527
+ let status = 500;
528
+ if (err.message.includes('Link validation failed')) {
529
+ status = 400;
530
+ } else if (err.message.includes('Conflict:')) {
531
+ status = 409;
532
+ }
533
+ if (status === 500) {
534
+ console.error('Update record failed:', err);
535
+ }
536
+ return c.json({ error: 'Failed to update record in database', details: err.message }, status as any);
537
+ }
538
+ });
539
+
540
+ /**
541
+ * DELETE /api/doctype/:doctype/:name
542
+ * Delete record: validates docstatus and deletes parent + children records inside transaction.
543
+ */
544
+ crudRouter.delete('/:name', async (c) => {
545
+ const doctypeName = c.req.param('doctype') as string;
546
+ const name = c.req.param('name');
547
+ const user = c.get('user');
548
+
549
+ const definition = await registry.get(doctypeName);
550
+ if (!definition) {
551
+ return c.json({ error: `DocType '${doctypeName}' not found` }, 404);
552
+ }
553
+
554
+ const table = getTableName(doctypeName);
555
+
556
+ try {
557
+ await withTransaction(async (client) => {
558
+ const ctx: ControllerContext = { db: client, user };
559
+
560
+ // Load record to assert existence and status
561
+ const res = await client.query(`SELECT * FROM ${table} WHERE name = $1`, [name]);
562
+ if (res.rows.length === 0) {
563
+ throw new Error('Document not found');
564
+ }
565
+ const record = res.rows[0];
566
+
567
+ if (record.docstatus === 1) {
568
+ throw new Error('Submitted documents cannot be deleted. Cancel it first.');
569
+ }
570
+
571
+ // Trigger before delete hooks
572
+ await controllerRegistry.triggerBeforeDelete(doctypeName, record, ctx);
573
+
574
+ // Delete children items in child tables
575
+ for (const field of definition.fields) {
576
+ if (field.fieldtype === 'Table') {
577
+ const childTable = getTableName(field.options!);
578
+ await client.query(
579
+ `DELETE FROM ${childTable} WHERE parent = $1 AND parenttype = $2 AND parentfield = $3`,
580
+ [name, doctypeName, field.fieldname]
581
+ );
582
+ }
583
+ }
584
+
585
+ // Delete parent record
586
+ await client.query(`DELETE FROM ${table} WHERE name = $1`, [name]);
587
+
588
+ // Trigger on trash hooks
589
+ await controllerRegistry.triggerOnTrash(doctypeName, record, ctx);
590
+
591
+ // Audit deletion
592
+ await client.query(
593
+ `INSERT INTO _audit_log (doctype_name, docname, action, changed_by, diff)
594
+ VALUES ($1, $2, 'DELETE', $3, $4)`,
595
+ [doctypeName, name, user.email, JSON.stringify(record)]
596
+ );
597
+ });
598
+
599
+ return c.json({ success: true, message: 'Document deleted successfully' });
600
+ } catch (err: any) {
601
+ return c.json({ error: 'Failed to delete document', details: err.message }, 500);
602
+ }
603
+ });
604
+
605
+ /**
606
+ * POST /api/doctype/:doctype/:name/submit
607
+ */
608
+ crudRouter.post('/:name/submit', async (c) => {
609
+ const doctypeName = c.req.param('doctype') as string;
610
+ const name = c.req.param('name');
611
+ const user = c.get('user');
612
+
613
+ const definition = await registry.get(doctypeName);
614
+ if (!definition) return c.json({ error: `DocType '${doctypeName}' not found` }, 404);
615
+ if (!definition.is_submittable) return c.json({ error: `DocType '${doctypeName}' is not submittable` }, 400);
616
+
617
+ const table = getTableName(doctypeName);
618
+
619
+ try {
620
+ const updated = await withTransaction(async (client) => {
621
+ const ctx: ControllerContext = { db: client, user };
622
+ const res = await client.query(`SELECT * FROM ${table} WHERE name = $1 FOR UPDATE`, [name]);
623
+ if (res.rows.length === 0) throw new Error('Document not found');
624
+ const doc = res.rows[0];
625
+ if (doc.docstatus !== 0) throw new Error('Only draft documents (status 0) can be submitted.');
626
+
627
+ await controllerRegistry.triggerBeforeSubmit(doctypeName, doc, ctx);
628
+
629
+ const updateRes = await client.query(
630
+ `UPDATE ${table} SET docstatus = 1, modified_by = $2, updated_at = NOW(), version = version + 1 WHERE name = $1 RETURNING *`,
631
+ [name, user.email]
632
+ );
633
+
634
+ await controllerRegistry.triggerOnSubmit(doctypeName, updateRes.rows[0], ctx);
635
+
636
+ await client.query(
637
+ `INSERT INTO _audit_log (doctype_name, docname, action, changed_by, diff) VALUES ($1, $2, 'SUBMIT', $3, '{}')`,
638
+ [doctypeName, name, user.email]
639
+ );
640
+
641
+ return updateRes.rows[0];
642
+ });
643
+ return c.json(updated);
644
+ } catch (err: any) {
645
+ return c.json({ error: 'Submission failed', details: err.message }, 500);
646
+ }
647
+ });
648
+
649
+ /**
650
+ * POST /api/doctype/:doctype/:name/cancel
651
+ */
652
+ crudRouter.post('/:name/cancel', async (c) => {
653
+ const doctypeName = c.req.param('doctype') as string;
654
+ const name = c.req.param('name');
655
+ const user = c.get('user');
656
+
657
+ const definition = await registry.get(doctypeName);
658
+ if (!definition) return c.json({ error: `DocType '${doctypeName}' not found` }, 404);
659
+ if (!definition.is_submittable) return c.json({ error: `DocType '${doctypeName}' is not submittable` }, 400);
660
+
661
+ const table = getTableName(doctypeName);
662
+
663
+ try {
664
+ const updated = await withTransaction(async (client) => {
665
+ const ctx: ControllerContext = { db: client, user };
666
+ const res = await client.query(`SELECT * FROM ${table} WHERE name = $1 FOR UPDATE`, [name]);
667
+ if (res.rows.length === 0) throw new Error('Document not found');
668
+ const doc = res.rows[0];
669
+ if (doc.docstatus !== 1) throw new Error('Only submitted documents (status 1) can be cancelled.');
670
+
671
+ await controllerRegistry.triggerBeforeCancel(doctypeName, doc, ctx);
672
+
673
+ const updateRes = await client.query(
674
+ `UPDATE ${table} SET docstatus = 2, modified_by = $2, updated_at = NOW(), version = version + 1 WHERE name = $1 RETURNING *`,
675
+ [name, user.email]
676
+ );
677
+
678
+ await controllerRegistry.triggerOnCancel(doctypeName, updateRes.rows[0], ctx);
679
+
680
+ await client.query(
681
+ `INSERT INTO _audit_log (doctype_name, docname, action, changed_by, diff) VALUES ($1, $2, 'CANCEL', $3, '{}')`,
682
+ [doctypeName, name, user.email]
683
+ );
684
+
685
+ return updateRes.rows[0];
686
+ });
687
+ return c.json(updated);
688
+ } catch (err: any) {
689
+ return c.json({ error: 'Cancellation failed', details: err.message }, 500);
690
+ }
691
+ });
692
+
693
+ /**
694
+ * POST /api/doctype/:doctype/:name/workflow
695
+ * Performs a workflow action/transition on a document.
696
+ */
697
+ crudRouter.post('/:name/workflow', async (c) => {
698
+ const doctypeName = c.req.param('doctype') as string;
699
+ const name = c.req.param('name');
700
+ const user = c.get('user');
701
+ const { action } = await c.req.json();
702
+
703
+ if (!action) {
704
+ return c.json({ error: 'Missing parameter "action" in request body.' }, 400);
705
+ }
706
+
707
+ const definition = await registry.get(doctypeName);
708
+ if (!definition) return c.json({ error: `DocType '${doctypeName}' not found` }, 404);
709
+ if (!definition.workflow) {
710
+ return c.json({ error: `DocType '${doctypeName}' does not have a workflow configured.` }, 400);
711
+ }
712
+
713
+ const wf = definition.workflow;
714
+ const wfCol = wf.fieldname;
715
+ const table = getTableName(doctypeName);
716
+
717
+ try {
718
+ const updated = await withTransaction(async (client) => {
719
+ const res = await client.query(`SELECT * FROM ${table} WHERE name = $1 FOR UPDATE`, [name]);
720
+ if (res.rows.length === 0) throw new Error('Document not found');
721
+ const doc = res.rows[0];
722
+
723
+ const currentState = doc[wfCol] || wf.initial_state;
724
+
725
+ // Find the matching transition
726
+ const transition = wf.transitions.find(
727
+ (t) => t.state === currentState && t.action === action
728
+ );
729
+
730
+ if (!transition) {
731
+ throw new Error(`No workflow transition found for state '${currentState}' with action '${action}'.`);
732
+ }
733
+
734
+ // Check permissions: user must possess one of the allowed_roles
735
+ const userRoles = user.roles || ['Guest'];
736
+ const isAuthorized = transition.allowed_roles.some((role) => userRoles.includes(role));
737
+ if (!isAuthorized) {
738
+ throw new Error(`Unauthorized: User roles [${userRoles.join(', ')}] are not permitted to trigger action '${action}'.`);
739
+ }
740
+
741
+ // Perform update
742
+ const updateRes = await client.query(
743
+ `UPDATE ${table} SET ${wfCol} = $2, modified_by = $3, updated_at = NOW(), version = version + 1 WHERE name = $1 RETURNING *`,
744
+ [name, transition.next_state, user.email]
745
+ );
746
+
747
+ // Audit log the transition
748
+ const diff = {
749
+ field: wfCol,
750
+ from: currentState,
751
+ to: transition.next_state,
752
+ action
753
+ };
754
+ await client.query(
755
+ `INSERT INTO _audit_log (doctype_name, docname, action, changed_by, diff) VALUES ($1, $2, $3, $4, $5)`,
756
+ [doctypeName, name, 'WORKFLOW', user.email, JSON.stringify(diff)]
757
+ );
758
+
759
+ return updateRes.rows[0];
760
+ });
761
+
762
+ return c.json(updated);
763
+ } catch (err: any) {
764
+ const status = err.message.includes('Unauthorized') ? 403 : 400;
765
+ return c.json({ error: 'Workflow transition failed', details: err.message }, status);
766
+ }
767
+ });
768
+
769
+ /**
770
+ * POST /api/doctype/:doctype/:name/amend
771
+ * Creates a new draft copy of a cancelled (docstatus: 2) document.
772
+ * The new document references the original via the `amended_from` column.
773
+ */
774
+ crudRouter.post('/:name/amend', async (c) => {
775
+ const doctypeName = c.req.param('doctype') as string;
776
+ const name = c.req.param('name');
777
+ const user = c.get('user');
778
+
779
+ const definition = await registry.get(doctypeName);
780
+ if (!definition) return c.json({ error: `DocType '${doctypeName}' not found` }, 404);
781
+ if (!definition.is_submittable) {
782
+ return c.json({ error: `DocType '${doctypeName}' is not submittable` }, 400);
783
+ }
784
+
785
+ const table = getTableName(doctypeName);
786
+
787
+ try {
788
+ const draft = await withTransaction(async (client) => {
789
+ const ctx: ControllerContext = { db: client, user };
790
+
791
+ // 1. Fetch cancelled document
792
+ const parentRes = await client.query(`SELECT * FROM ${table} WHERE name = $1`, [name]);
793
+ if (parentRes.rows.length === 0) throw new Error('Original document not found');
794
+ const originalDoc = parentRes.rows[0];
795
+
796
+ if (originalDoc.docstatus !== 2) {
797
+ throw new Error('Only cancelled documents (status 2) can be amended.');
798
+ }
799
+
800
+ // 2. Generate new document name
801
+ const newName = await generateDocumentName(client, definition, originalDoc);
802
+
803
+ // 3. Construct parent record copy
804
+ const record: Record<string, any> = {
805
+ ...originalDoc,
806
+ name: newName,
807
+ docstatus: 0,
808
+ version: 1,
809
+ amended_from: name,
810
+ created_by: user.email,
811
+ modified_by: user.email,
812
+ created_at: new Date(),
813
+ updated_at: new Date()
814
+ };
815
+ // Delete uuid to let DB generate a fresh one
816
+ delete record.uuid;
817
+
818
+ // 4. Load child table data of original doc
819
+ const childData: Record<string, any[]> = {};
820
+ for (const field of definition.fields) {
821
+ if (field.fieldtype === 'Table') {
822
+ const childDocTypeName = field.options!;
823
+ const childTable = getTableName(childDocTypeName);
824
+ const childRes = await client.query(
825
+ `SELECT * FROM ${childTable} WHERE parent = $1 AND parenttype = $2 AND parentfield = $3 ORDER BY idx ASC`,
826
+ [name, doctypeName, field.fieldname]
827
+ );
828
+
829
+ const childDefinition = await registry.get(childDocTypeName);
830
+ if (!childDefinition) throw new Error(`Child DocType '${childDocTypeName}' not found`);
831
+
832
+ const savedChildren: any[] = [];
833
+ let idx = 1;
834
+ for (const row of childRes.rows) {
835
+ const childRecord: Record<string, any> = {
836
+ ...row,
837
+ name: crypto.randomUUID(),
838
+ parent: newName,
839
+ parenttype: doctypeName,
840
+ parentfield: field.fieldname,
841
+ idx: idx++,
842
+ created_by: user.email,
843
+ modified_by: user.email
844
+ };
845
+ delete childRecord.uuid;
846
+
847
+ const childCols = ['name', 'parent', 'parenttype', 'parentfield', 'idx', 'created_by', 'modified_by'];
848
+ const childVals = [
849
+ childRecord.name,
850
+ childRecord.parent,
851
+ childRecord.parenttype,
852
+ childRecord.parentfield,
853
+ childRecord.idx,
854
+ childRecord.created_by,
855
+ childRecord.modified_by
856
+ ];
857
+
858
+ for (const cf of childDefinition.fields) {
859
+ if (cf.fieldtype === 'Table' || cf.fieldtype === 'Table MultiSelect') continue;
860
+ childCols.push(cf.fieldname);
861
+ childVals.push(childRecord[cf.fieldname] ?? null);
862
+ }
863
+
864
+ const childPlaceholder = childVals.map((_, i) => `$${i + 1}`).join(', ');
865
+ const childSql = `INSERT INTO ${childTable} (${childCols.join(', ')}) VALUES (${childPlaceholder}) RETURNING *`;
866
+ const insertRes = await client.query(childSql, childVals);
867
+ savedChildren.push(insertRes.rows[0]);
868
+ }
869
+ childData[field.fieldname] = savedChildren;
870
+ }
871
+ }
872
+
873
+ // 5. Trigger lifecycle hooks
874
+ await controllerRegistry.triggerBeforeInsert(doctypeName, record, ctx);
875
+ await controllerRegistry.triggerBeforeSave(doctypeName, record, ctx);
876
+
877
+ // 6. Insert parent record
878
+ const parentCols: string[] = ['name', 'created_by', 'modified_by', 'docstatus', 'version', 'amended_from'];
879
+ const parentVals: any[] = [record.name, record.created_by, record.modified_by, record.docstatus, record.version, record.amended_from];
880
+
881
+ for (const field of definition.fields) {
882
+ if (field.fieldtype === 'Table' || field.fieldtype === 'Table MultiSelect') continue;
883
+ parentCols.push(field.fieldname);
884
+ parentVals.push(record[field.fieldname] ?? null);
885
+ }
886
+
887
+ const placeholders = parentVals.map((_, i) => `$${i + 1}`).join(', ');
888
+ const parentSql = `INSERT INTO ${table} (${parentCols.join(', ')}) VALUES (${placeholders}) RETURNING *`;
889
+ const parentInsert = await client.query(parentSql, parentVals);
890
+
891
+ const finalRecord = { ...parentInsert.rows[0], ...childData };
892
+
893
+ // 7. Audit log
894
+ await client.query(
895
+ `INSERT INTO _audit_log (doctype_name, docname, action, changed_by, diff) VALUES ($1, $2, 'AMEND', $3, '{}')`,
896
+ [doctypeName, newName, user.email]
897
+ );
898
+
899
+ return finalRecord;
900
+ });
901
+
902
+ return c.json(draft, 201);
903
+ } catch (err: any) {
904
+ return c.json({ error: 'Amend operation failed', details: err.message }, 500);
905
+ }
906
+ });
907
+
908
+ /**
909
+ * POST /api/doctype/:doctype/bulk
910
+ * Perform mass inserts, updates, or deletions inside a single database transaction.
911
+ */
912
+ crudRouter.post('/bulk', async (c) => {
913
+ const doctypeName = c.req.param('doctype') as string;
914
+ const body = await c.req.json();
915
+ const user = c.get('user');
916
+
917
+ const { action, items } = body;
918
+ if (!action || !Array.isArray(items)) {
919
+ return c.json({ error: "Invalid payload. Expected 'action' (delete/update/insert) and an 'items' array." }, 400);
920
+ }
921
+
922
+ const definition = await registry.get(doctypeName);
923
+ if (!definition) return c.json({ error: `DocType '${doctypeName}' not found` }, 404);
924
+
925
+ const table = getTableName(doctypeName);
926
+
927
+ try {
928
+ const result = await withTransaction(async (client) => {
929
+ const ctx: ControllerContext = { db: client, user };
930
+ const processed: any[] = [];
931
+
932
+ if (action === 'delete') {
933
+ for (const item of items) {
934
+ const name = typeof item === 'string' ? item : item.name;
935
+ if (!name) throw new Error("Missing 'name' field for delete action item.");
936
+
937
+ // Load original
938
+ const res = await client.query(`SELECT * FROM ${table} WHERE name = $1 FOR UPDATE`, [name]);
939
+ if (res.rows.length === 0) throw new Error(`Document '${name}' not found.`);
940
+ const record = res.rows[0];
941
+
942
+ if (record.docstatus === 1) {
943
+ throw new Error(`Submitted document '${name}' cannot be deleted. Cancel it first.`);
944
+ }
945
+
946
+ await controllerRegistry.triggerBeforeDelete(doctypeName, record, ctx);
947
+
948
+ // Delete children
949
+ for (const field of definition.fields) {
950
+ if (field.fieldtype === 'Table') {
951
+ const childTable = getTableName(field.options!);
952
+ await client.query(
953
+ `DELETE FROM ${childTable} WHERE parent = $1 AND parenttype = $2 AND parentfield = $3`,
954
+ [name, doctypeName, field.fieldname]
955
+ );
956
+ }
957
+ }
958
+
959
+ // Delete parent
960
+ await client.query(`DELETE FROM ${table} WHERE name = $1`, [name]);
961
+ await controllerRegistry.triggerOnTrash(doctypeName, record, ctx);
962
+
963
+ // Audit
964
+ await client.query(
965
+ `INSERT INTO _audit_log (doctype_name, docname, action, changed_by, diff) VALUES ($1, $2, 'DELETE', $3, $4)`,
966
+ [doctypeName, name, user.email, JSON.stringify(record)]
967
+ );
968
+ processed.push({ name, status: 'deleted' });
969
+ }
970
+ } else if (action === 'insert') {
971
+ const validator = await registry.getValidator(doctypeName);
972
+ if (!validator) throw new Error('Validator compilation failed');
973
+
974
+ for (const item of items) {
975
+ const parsed = validator.safeParse(item);
976
+ if (!parsed.success) throw new Error(`Validation failed for item: ${JSON.stringify(parsed.error.format())}`);
977
+
978
+ const docName = item.name || await generateDocumentName(client, definition, item);
979
+ const record: Record<string, any> = {
980
+ ...parsed.data, name: docName, docstatus: 0, version: 1,
981
+ created_by: user.email, modified_by: user.email
982
+ };
983
+
984
+ // Set workflow initial state default if missing
985
+ if (definition.workflow && record[definition.workflow.fieldname] === undefined) {
986
+ record[definition.workflow.fieldname] = definition.workflow.initial_state;
987
+ }
988
+
989
+ // Set field-level defaults if missing
990
+ for (const field of definition.fields) {
991
+ if (record[field.fieldname] === undefined && field.default !== undefined) {
992
+ record[field.fieldname] = field.default;
993
+ }
994
+ }
995
+
996
+ const linkErrors = await validateLinks(definition, record, client);
997
+ if (linkErrors.length > 0) throw new Error(`Link validation failed: ${linkErrors.join('; ')}`);
998
+
999
+ await controllerRegistry.triggerBeforeInsert(doctypeName, record, ctx);
1000
+ await controllerRegistry.triggerBeforeSave(doctypeName, record, ctx);
1001
+
1002
+ const parentCols = ['name', 'created_by', 'modified_by', 'docstatus', 'version'];
1003
+ const parentVals = [record.name, record.created_by, record.modified_by, record.docstatus, record.version];
1004
+
1005
+ for (const field of definition.fields) {
1006
+ if (field.fieldtype === 'Table' || field.fieldtype === 'Table MultiSelect') continue;
1007
+ parentCols.push(field.fieldname);
1008
+ parentVals.push(record[field.fieldname] ?? null);
1009
+ }
1010
+
1011
+ const placeholders = parentVals.map((_, i) => `$${i + 1}`).join(', ');
1012
+ const sql = `INSERT INTO ${table} (${parentCols.join(', ')}) VALUES (${placeholders}) RETURNING *`;
1013
+ const insertRes = await client.query(sql, parentVals);
1014
+ processed.push(insertRes.rows[0]);
1015
+ }
1016
+ } else {
1017
+ throw new Error(`Unsupported bulk action: ${action}`);
1018
+ }
1019
+
1020
+ return processed;
1021
+ });
1022
+
1023
+ return c.json({ success: true, count: result.length, data: result });
1024
+ } catch (err: any) {
1025
+ return c.json({ error: 'Bulk transaction rolled back', details: err.message }, 500);
1026
+ }
1027
+ });
1028
+
1029
+