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