@voyantjs/legal 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/LICENSE +109 -0
- package/README.md +60 -0
- package/dist/contracts/index.d.ts +13 -0
- package/dist/contracts/index.d.ts.map +1 -0
- package/dist/contracts/index.js +19 -0
- package/dist/contracts/routes.d.ts +1297 -0
- package/dist/contracts/routes.d.ts.map +1 -0
- package/dist/contracts/routes.js +224 -0
- package/dist/contracts/schema.d.ts +1531 -0
- package/dist/contracts/schema.d.ts.map +1 -0
- package/dist/contracts/schema.js +227 -0
- package/dist/contracts/service.d.ts +1753 -0
- package/dist/contracts/service.d.ts.map +1 -0
- package/dist/contracts/service.js +570 -0
- package/dist/contracts/validation.d.ts +274 -0
- package/dist/contracts/validation.d.ts.map +1 -0
- package/dist/contracts/validation.js +125 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/policies/index.d.ts +16 -0
- package/dist/policies/index.d.ts.map +1 -0
- package/dist/policies/index.js +26 -0
- package/dist/policies/routes.d.ts +916 -0
- package/dist/policies/routes.d.ts.map +1 -0
- package/dist/policies/routes.js +162 -0
- package/dist/policies/schema.d.ts +1176 -0
- package/dist/policies/schema.d.ts.map +1 -0
- package/dist/policies/schema.js +189 -0
- package/dist/policies/service.d.ts +1384 -0
- package/dist/policies/service.d.ts.map +1 -0
- package/dist/policies/service.js +438 -0
- package/dist/policies/validation.d.ts +273 -0
- package/dist/policies/validation.d.ts.map +1 -0
- package/dist/policies/validation.js +140 -0
- package/package.json +83 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/contracts/service.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
|
|
2
|
+
import { contractAttachments, contractNumberSeries, contractSignatures, contracts, contractTemplates, contractTemplateVersions, } from "./schema.js";
|
|
3
|
+
function toTimestamp(value) {
|
|
4
|
+
return value ? new Date(value) : null;
|
|
5
|
+
}
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Template rendering
|
|
8
|
+
// ============================================================================
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a dot-path against an object, supporting array index accessors:
|
|
11
|
+
* "a.b.c" → obj.a.b.c
|
|
12
|
+
* "arr[0].name" → obj.arr[0].name
|
|
13
|
+
* "a.b[2].c" → obj.a.b[2].c
|
|
14
|
+
*/
|
|
15
|
+
function resolvePath(obj, path) {
|
|
16
|
+
if (obj === null || obj === undefined)
|
|
17
|
+
return undefined;
|
|
18
|
+
const segments = [];
|
|
19
|
+
const parts = path.split(".");
|
|
20
|
+
for (const part of parts) {
|
|
21
|
+
if (!part)
|
|
22
|
+
continue;
|
|
23
|
+
// Match "name[0]" → ["name", 0]
|
|
24
|
+
const indexMatches = [...part.matchAll(/([^[\]]+)|\[(\d+)\]/g)];
|
|
25
|
+
for (const match of indexMatches) {
|
|
26
|
+
if (match[1] !== undefined)
|
|
27
|
+
segments.push(match[1]);
|
|
28
|
+
else if (match[2] !== undefined)
|
|
29
|
+
segments.push(Number.parseInt(match[2], 10));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
let current = obj;
|
|
33
|
+
for (const seg of segments) {
|
|
34
|
+
if (current === null || current === undefined)
|
|
35
|
+
return undefined;
|
|
36
|
+
if (typeof seg === "number") {
|
|
37
|
+
if (!Array.isArray(current))
|
|
38
|
+
return undefined;
|
|
39
|
+
current = current[seg];
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
if (typeof current !== "object")
|
|
43
|
+
return undefined;
|
|
44
|
+
current = current[seg];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return current;
|
|
48
|
+
}
|
|
49
|
+
function stringifyValue(value) {
|
|
50
|
+
if (value === null || value === undefined)
|
|
51
|
+
return "";
|
|
52
|
+
if (typeof value === "string")
|
|
53
|
+
return value;
|
|
54
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
55
|
+
return String(value);
|
|
56
|
+
return JSON.stringify(value);
|
|
57
|
+
}
|
|
58
|
+
const MUSTACHE_RE = /\{\{\s*([^}]+?)\s*\}\}/g;
|
|
59
|
+
function renderMustache(body, variables) {
|
|
60
|
+
return body.replace(MUSTACHE_RE, (_, path) => {
|
|
61
|
+
const resolved = resolvePath(variables, path.trim());
|
|
62
|
+
return stringifyValue(resolved);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function walkLexical(node, variables) {
|
|
66
|
+
const next = { ...node };
|
|
67
|
+
if (typeof next.text === "string") {
|
|
68
|
+
next.text = renderMustache(next.text, variables);
|
|
69
|
+
}
|
|
70
|
+
if (Array.isArray(next.children)) {
|
|
71
|
+
next.children = next.children.map((child) => walkLexical(child, variables));
|
|
72
|
+
}
|
|
73
|
+
return next;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Substitute variables in a template body. Supports:
|
|
77
|
+
* - markdown / html / plain text: mustache replacement `{{path.to.value}}`
|
|
78
|
+
* - lexical_json: walks the AST, rewriting text nodes only
|
|
79
|
+
*/
|
|
80
|
+
export function renderTemplate(body, bodyFormat, variables) {
|
|
81
|
+
if (bodyFormat === "lexical_json") {
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(body);
|
|
84
|
+
if (parsed && typeof parsed === "object") {
|
|
85
|
+
const obj = parsed;
|
|
86
|
+
if (obj.root && typeof obj.root === "object") {
|
|
87
|
+
const result = { ...obj, root: walkLexical(obj.root, variables) };
|
|
88
|
+
return JSON.stringify(result);
|
|
89
|
+
}
|
|
90
|
+
return JSON.stringify(walkLexical(obj, variables));
|
|
91
|
+
}
|
|
92
|
+
return body;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Fall through to mustache on parse error (treat as string template)
|
|
96
|
+
return renderMustache(body, variables);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return renderMustache(body, variables);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Validate variable values against a template's variableSchema (JSON object
|
|
103
|
+
* shape describing required fields). Returns a list of issues; empty array
|
|
104
|
+
* means valid.
|
|
105
|
+
*/
|
|
106
|
+
export function validateTemplateVariables(variableSchema, values) {
|
|
107
|
+
const issues = [];
|
|
108
|
+
if (!variableSchema || typeof variableSchema !== "object")
|
|
109
|
+
return issues;
|
|
110
|
+
const schema = variableSchema;
|
|
111
|
+
const requiredList = Array.isArray(schema.required) ? schema.required : [];
|
|
112
|
+
for (const key of requiredList) {
|
|
113
|
+
const resolved = resolvePath(values, key);
|
|
114
|
+
if (resolved === undefined || resolved === null || resolved === "") {
|
|
115
|
+
issues.push(`missing required variable: ${key}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return issues;
|
|
119
|
+
}
|
|
120
|
+
// ============================================================================
|
|
121
|
+
// Number allocation
|
|
122
|
+
// ============================================================================
|
|
123
|
+
function currentPeriodBoundary(strategy, now) {
|
|
124
|
+
if (strategy === "never")
|
|
125
|
+
return null;
|
|
126
|
+
if (strategy === "annual") {
|
|
127
|
+
return new Date(Date.UTC(now.getUTCFullYear(), 0, 1));
|
|
128
|
+
}
|
|
129
|
+
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
|
130
|
+
}
|
|
131
|
+
function formatNumber(prefix, separator, sequence, padLength) {
|
|
132
|
+
const padded = String(sequence).padStart(padLength, "0");
|
|
133
|
+
return `${prefix}${separator}${padded}`;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Transactionally allocate the next sequence number for a series. Honors the
|
|
137
|
+
* reset strategy (never/annual/monthly). Uses SELECT ... FOR UPDATE to avoid
|
|
138
|
+
* concurrent-increment races.
|
|
139
|
+
*/
|
|
140
|
+
export async function allocateContractNumber(db, seriesId) {
|
|
141
|
+
return db.transaction(async (tx) => {
|
|
142
|
+
const rows = await tx.execute(sql `SELECT * FROM ${contractNumberSeries}
|
|
143
|
+
WHERE ${contractNumberSeries.id} = ${seriesId}
|
|
144
|
+
FOR UPDATE`);
|
|
145
|
+
const row = rows[0];
|
|
146
|
+
if (!row)
|
|
147
|
+
return null;
|
|
148
|
+
const strategy = row.reset_strategy;
|
|
149
|
+
const prefix = row.prefix ?? "";
|
|
150
|
+
const separator = row.separator ?? "";
|
|
151
|
+
const padLength = row.pad_length ?? 4;
|
|
152
|
+
const currentSequence = row.current_sequence ?? 0;
|
|
153
|
+
const resetAt = row.reset_at ? new Date(row.reset_at) : null;
|
|
154
|
+
const now = new Date();
|
|
155
|
+
const boundary = currentPeriodBoundary(strategy, now);
|
|
156
|
+
const shouldReset = boundary !== null && (resetAt === null || resetAt.getTime() < boundary.getTime());
|
|
157
|
+
const nextSequence = shouldReset ? 1 : currentSequence + 1;
|
|
158
|
+
const nextResetAt = strategy === "never" ? resetAt : boundary;
|
|
159
|
+
await tx
|
|
160
|
+
.update(contractNumberSeries)
|
|
161
|
+
.set({
|
|
162
|
+
currentSequence: nextSequence,
|
|
163
|
+
resetAt: nextResetAt,
|
|
164
|
+
updatedAt: new Date(),
|
|
165
|
+
})
|
|
166
|
+
.where(eq(contractNumberSeries.id, seriesId));
|
|
167
|
+
return {
|
|
168
|
+
number: formatNumber(prefix, separator, nextSequence, padLength),
|
|
169
|
+
sequence: nextSequence,
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
// ============================================================================
|
|
174
|
+
// Service
|
|
175
|
+
// ============================================================================
|
|
176
|
+
async function paginate(rowsQuery, countQuery, limit, offset) {
|
|
177
|
+
const [data, countResult] = await Promise.all([rowsQuery, countQuery]);
|
|
178
|
+
return { data, total: countResult[0]?.total ?? 0, limit, offset };
|
|
179
|
+
}
|
|
180
|
+
export const contractsService = {
|
|
181
|
+
// ---------- contract templates ----------
|
|
182
|
+
async listTemplates(db, query) {
|
|
183
|
+
const conditions = [];
|
|
184
|
+
if (query.scope)
|
|
185
|
+
conditions.push(eq(contractTemplates.scope, query.scope));
|
|
186
|
+
if (query.language)
|
|
187
|
+
conditions.push(eq(contractTemplates.language, query.language));
|
|
188
|
+
if (query.active !== undefined)
|
|
189
|
+
conditions.push(eq(contractTemplates.active, query.active));
|
|
190
|
+
if (query.search) {
|
|
191
|
+
const term = `%${query.search}%`;
|
|
192
|
+
conditions.push(or(ilike(contractTemplates.name, term), ilike(contractTemplates.slug, term), ilike(contractTemplates.description, term)));
|
|
193
|
+
}
|
|
194
|
+
const where = conditions.length ? and(...conditions) : undefined;
|
|
195
|
+
return paginate(db
|
|
196
|
+
.select()
|
|
197
|
+
.from(contractTemplates)
|
|
198
|
+
.where(where)
|
|
199
|
+
.limit(query.limit)
|
|
200
|
+
.offset(query.offset)
|
|
201
|
+
.orderBy(desc(contractTemplates.updatedAt)), db.select({ total: sql `count(*)::int` }).from(contractTemplates).where(where), query.limit, query.offset);
|
|
202
|
+
},
|
|
203
|
+
async getTemplateById(db, id) {
|
|
204
|
+
const [row] = await db
|
|
205
|
+
.select()
|
|
206
|
+
.from(contractTemplates)
|
|
207
|
+
.where(eq(contractTemplates.id, id))
|
|
208
|
+
.limit(1);
|
|
209
|
+
return row ?? null;
|
|
210
|
+
},
|
|
211
|
+
async createTemplate(db, data) {
|
|
212
|
+
const [row] = await db.insert(contractTemplates).values(data).returning();
|
|
213
|
+
return row ?? null;
|
|
214
|
+
},
|
|
215
|
+
async updateTemplate(db, id, data) {
|
|
216
|
+
const [row] = await db
|
|
217
|
+
.update(contractTemplates)
|
|
218
|
+
.set({ ...data, updatedAt: new Date() })
|
|
219
|
+
.where(eq(contractTemplates.id, id))
|
|
220
|
+
.returning();
|
|
221
|
+
return row ?? null;
|
|
222
|
+
},
|
|
223
|
+
async deleteTemplate(db, id) {
|
|
224
|
+
const [row] = await db
|
|
225
|
+
.delete(contractTemplates)
|
|
226
|
+
.where(eq(contractTemplates.id, id))
|
|
227
|
+
.returning({ id: contractTemplates.id });
|
|
228
|
+
return row ?? null;
|
|
229
|
+
},
|
|
230
|
+
// ---------- contract template versions ----------
|
|
231
|
+
listTemplateVersions(db, templateId) {
|
|
232
|
+
return db
|
|
233
|
+
.select()
|
|
234
|
+
.from(contractTemplateVersions)
|
|
235
|
+
.where(eq(contractTemplateVersions.templateId, templateId))
|
|
236
|
+
.orderBy(desc(contractTemplateVersions.version));
|
|
237
|
+
},
|
|
238
|
+
async getTemplateVersionById(db, id) {
|
|
239
|
+
const [row] = await db
|
|
240
|
+
.select()
|
|
241
|
+
.from(contractTemplateVersions)
|
|
242
|
+
.where(eq(contractTemplateVersions.id, id))
|
|
243
|
+
.limit(1);
|
|
244
|
+
return row ?? null;
|
|
245
|
+
},
|
|
246
|
+
async createTemplateVersion(db, templateId, data) {
|
|
247
|
+
return db.transaction(async (tx) => {
|
|
248
|
+
const [template] = await tx
|
|
249
|
+
.select({ id: contractTemplates.id })
|
|
250
|
+
.from(contractTemplates)
|
|
251
|
+
.where(eq(contractTemplates.id, templateId))
|
|
252
|
+
.limit(1);
|
|
253
|
+
if (!template)
|
|
254
|
+
return null;
|
|
255
|
+
const [maxRow] = await tx
|
|
256
|
+
.select({ max: sql `coalesce(max(${contractTemplateVersions.version}), 0)::int` })
|
|
257
|
+
.from(contractTemplateVersions)
|
|
258
|
+
.where(eq(contractTemplateVersions.templateId, templateId));
|
|
259
|
+
const nextVersion = (maxRow?.max ?? 0) + 1;
|
|
260
|
+
const [version] = await tx
|
|
261
|
+
.insert(contractTemplateVersions)
|
|
262
|
+
.values({
|
|
263
|
+
templateId,
|
|
264
|
+
version: nextVersion,
|
|
265
|
+
bodyFormat: data.bodyFormat,
|
|
266
|
+
body: data.body,
|
|
267
|
+
variableSchema: data.variableSchema ?? null,
|
|
268
|
+
changelog: data.changelog ?? null,
|
|
269
|
+
createdBy: data.createdBy ?? null,
|
|
270
|
+
})
|
|
271
|
+
.returning();
|
|
272
|
+
if (version) {
|
|
273
|
+
await tx
|
|
274
|
+
.update(contractTemplates)
|
|
275
|
+
.set({ currentVersionId: version.id, updatedAt: new Date() })
|
|
276
|
+
.where(eq(contractTemplates.id, templateId));
|
|
277
|
+
}
|
|
278
|
+
return version ?? null;
|
|
279
|
+
});
|
|
280
|
+
},
|
|
281
|
+
// ---------- contract number series ----------
|
|
282
|
+
async listSeries(db) {
|
|
283
|
+
return db.select().from(contractNumberSeries).orderBy(desc(contractNumberSeries.updatedAt));
|
|
284
|
+
},
|
|
285
|
+
async getSeriesById(db, id) {
|
|
286
|
+
const [row] = await db
|
|
287
|
+
.select()
|
|
288
|
+
.from(contractNumberSeries)
|
|
289
|
+
.where(eq(contractNumberSeries.id, id))
|
|
290
|
+
.limit(1);
|
|
291
|
+
return row ?? null;
|
|
292
|
+
},
|
|
293
|
+
async createSeries(db, data) {
|
|
294
|
+
const [row] = await db.insert(contractNumberSeries).values(data).returning();
|
|
295
|
+
return row ?? null;
|
|
296
|
+
},
|
|
297
|
+
async updateSeries(db, id, data) {
|
|
298
|
+
const [row] = await db
|
|
299
|
+
.update(contractNumberSeries)
|
|
300
|
+
.set({ ...data, updatedAt: new Date() })
|
|
301
|
+
.where(eq(contractNumberSeries.id, id))
|
|
302
|
+
.returning();
|
|
303
|
+
return row ?? null;
|
|
304
|
+
},
|
|
305
|
+
async deleteSeries(db, id) {
|
|
306
|
+
const [row] = await db
|
|
307
|
+
.delete(contractNumberSeries)
|
|
308
|
+
.where(eq(contractNumberSeries.id, id))
|
|
309
|
+
.returning({ id: contractNumberSeries.id });
|
|
310
|
+
return row ?? null;
|
|
311
|
+
},
|
|
312
|
+
// ---------- contracts ----------
|
|
313
|
+
async listContracts(db, query) {
|
|
314
|
+
const conditions = [];
|
|
315
|
+
if (query.scope)
|
|
316
|
+
conditions.push(eq(contracts.scope, query.scope));
|
|
317
|
+
if (query.status)
|
|
318
|
+
conditions.push(eq(contracts.status, query.status));
|
|
319
|
+
if (query.personId)
|
|
320
|
+
conditions.push(eq(contracts.personId, query.personId));
|
|
321
|
+
if (query.organizationId)
|
|
322
|
+
conditions.push(eq(contracts.organizationId, query.organizationId));
|
|
323
|
+
if (query.supplierId)
|
|
324
|
+
conditions.push(eq(contracts.supplierId, query.supplierId));
|
|
325
|
+
if (query.bookingId)
|
|
326
|
+
conditions.push(eq(contracts.bookingId, query.bookingId));
|
|
327
|
+
if (query.orderId)
|
|
328
|
+
conditions.push(eq(contracts.orderId, query.orderId));
|
|
329
|
+
if (query.search) {
|
|
330
|
+
const term = `%${query.search}%`;
|
|
331
|
+
conditions.push(or(ilike(contracts.title, term), ilike(contracts.contractNumber, term)));
|
|
332
|
+
}
|
|
333
|
+
const where = conditions.length ? and(...conditions) : undefined;
|
|
334
|
+
return paginate(db
|
|
335
|
+
.select()
|
|
336
|
+
.from(contracts)
|
|
337
|
+
.where(where)
|
|
338
|
+
.limit(query.limit)
|
|
339
|
+
.offset(query.offset)
|
|
340
|
+
.orderBy(desc(contracts.createdAt)), db.select({ total: sql `count(*)::int` }).from(contracts).where(where), query.limit, query.offset);
|
|
341
|
+
},
|
|
342
|
+
async getContractById(db, id) {
|
|
343
|
+
const [row] = await db.select().from(contracts).where(eq(contracts.id, id)).limit(1);
|
|
344
|
+
return row ?? null;
|
|
345
|
+
},
|
|
346
|
+
async createContract(db, data) {
|
|
347
|
+
const [row] = await db
|
|
348
|
+
.insert(contracts)
|
|
349
|
+
.values({
|
|
350
|
+
...data,
|
|
351
|
+
expiresAt: toTimestamp(data.expiresAt),
|
|
352
|
+
})
|
|
353
|
+
.returning();
|
|
354
|
+
return row ?? null;
|
|
355
|
+
},
|
|
356
|
+
async updateContract(db, id, data) {
|
|
357
|
+
const [row] = await db
|
|
358
|
+
.update(contracts)
|
|
359
|
+
.set({
|
|
360
|
+
...data,
|
|
361
|
+
expiresAt: data.expiresAt === undefined ? undefined : toTimestamp(data.expiresAt),
|
|
362
|
+
updatedAt: new Date(),
|
|
363
|
+
})
|
|
364
|
+
.where(eq(contracts.id, id))
|
|
365
|
+
.returning();
|
|
366
|
+
return row ?? null;
|
|
367
|
+
},
|
|
368
|
+
async deleteContract(db, id) {
|
|
369
|
+
const [existing] = await db
|
|
370
|
+
.select({ id: contracts.id, status: contracts.status })
|
|
371
|
+
.from(contracts)
|
|
372
|
+
.where(eq(contracts.id, id))
|
|
373
|
+
.limit(1);
|
|
374
|
+
if (!existing)
|
|
375
|
+
return { status: "not_found" };
|
|
376
|
+
if (existing.status !== "draft")
|
|
377
|
+
return { status: "not_draft" };
|
|
378
|
+
await db.delete(contracts).where(eq(contracts.id, id));
|
|
379
|
+
return { status: "deleted" };
|
|
380
|
+
},
|
|
381
|
+
// ---------- lifecycle actions ----------
|
|
382
|
+
/**
|
|
383
|
+
* Transition a draft contract to `issued`: renders body from the template
|
|
384
|
+
* version, allocates a contract number if a series is set, timestamps
|
|
385
|
+
* `issuedAt`. Returns null if contract is missing or not in `draft`.
|
|
386
|
+
*/
|
|
387
|
+
async issueContract(db, contractId) {
|
|
388
|
+
return db.transaction(async (tx) => {
|
|
389
|
+
const [contract] = await tx
|
|
390
|
+
.select()
|
|
391
|
+
.from(contracts)
|
|
392
|
+
.where(eq(contracts.id, contractId))
|
|
393
|
+
.limit(1);
|
|
394
|
+
if (!contract)
|
|
395
|
+
return { status: "not_found" };
|
|
396
|
+
if (contract.status !== "draft")
|
|
397
|
+
return { status: "not_draft" };
|
|
398
|
+
// Render body from template version if referenced
|
|
399
|
+
let renderedBody = contract.renderedBody;
|
|
400
|
+
let renderedBodyFormat = contract.renderedBodyFormat;
|
|
401
|
+
if (contract.templateVersionId) {
|
|
402
|
+
const [version] = await tx
|
|
403
|
+
.select()
|
|
404
|
+
.from(contractTemplateVersions)
|
|
405
|
+
.where(eq(contractTemplateVersions.id, contract.templateVersionId))
|
|
406
|
+
.limit(1);
|
|
407
|
+
if (version) {
|
|
408
|
+
const vars = contract.variables ?? {};
|
|
409
|
+
renderedBody = renderTemplate(version.body, version.bodyFormat, vars);
|
|
410
|
+
renderedBodyFormat = version.bodyFormat;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// Allocate contract number if not already set and a series is linked
|
|
414
|
+
let contractNumber = contract.contractNumber;
|
|
415
|
+
if (!contractNumber && contract.seriesId) {
|
|
416
|
+
const allocated = await allocateContractNumber(tx, contract.seriesId);
|
|
417
|
+
if (allocated)
|
|
418
|
+
contractNumber = allocated.number;
|
|
419
|
+
}
|
|
420
|
+
const [updated] = await tx
|
|
421
|
+
.update(contracts)
|
|
422
|
+
.set({
|
|
423
|
+
status: "issued",
|
|
424
|
+
issuedAt: new Date(),
|
|
425
|
+
renderedBody,
|
|
426
|
+
renderedBodyFormat,
|
|
427
|
+
contractNumber,
|
|
428
|
+
updatedAt: new Date(),
|
|
429
|
+
})
|
|
430
|
+
.where(eq(contracts.id, contractId))
|
|
431
|
+
.returning();
|
|
432
|
+
return { status: "issued", contract: updated ?? null };
|
|
433
|
+
});
|
|
434
|
+
},
|
|
435
|
+
async sendContract(db, contractId) {
|
|
436
|
+
const [contract] = await db
|
|
437
|
+
.select({ id: contracts.id, status: contracts.status })
|
|
438
|
+
.from(contracts)
|
|
439
|
+
.where(eq(contracts.id, contractId))
|
|
440
|
+
.limit(1);
|
|
441
|
+
if (!contract)
|
|
442
|
+
return { status: "not_found" };
|
|
443
|
+
if (contract.status !== "issued" && contract.status !== "sent") {
|
|
444
|
+
return { status: "not_issued" };
|
|
445
|
+
}
|
|
446
|
+
const [updated] = await db
|
|
447
|
+
.update(contracts)
|
|
448
|
+
.set({ status: "sent", sentAt: new Date(), updatedAt: new Date() })
|
|
449
|
+
.where(eq(contracts.id, contractId))
|
|
450
|
+
.returning();
|
|
451
|
+
return { status: "sent", contract: updated ?? null };
|
|
452
|
+
},
|
|
453
|
+
async voidContract(db, contractId) {
|
|
454
|
+
const [contract] = await db
|
|
455
|
+
.select({ id: contracts.id, status: contracts.status })
|
|
456
|
+
.from(contracts)
|
|
457
|
+
.where(eq(contracts.id, contractId))
|
|
458
|
+
.limit(1);
|
|
459
|
+
if (!contract)
|
|
460
|
+
return { status: "not_found" };
|
|
461
|
+
if (contract.status === "void")
|
|
462
|
+
return { status: "already_void" };
|
|
463
|
+
const [updated] = await db
|
|
464
|
+
.update(contracts)
|
|
465
|
+
.set({ status: "void", voidedAt: new Date(), updatedAt: new Date() })
|
|
466
|
+
.where(eq(contracts.id, contractId))
|
|
467
|
+
.returning();
|
|
468
|
+
return { status: "voided", contract: updated ?? null };
|
|
469
|
+
},
|
|
470
|
+
// ---------- signatures ----------
|
|
471
|
+
listSignatures(db, contractId) {
|
|
472
|
+
return db
|
|
473
|
+
.select()
|
|
474
|
+
.from(contractSignatures)
|
|
475
|
+
.where(eq(contractSignatures.contractId, contractId))
|
|
476
|
+
.orderBy(desc(contractSignatures.signedAt));
|
|
477
|
+
},
|
|
478
|
+
async signContract(db, contractId, data) {
|
|
479
|
+
return db.transaction(async (tx) => {
|
|
480
|
+
const [contract] = await tx
|
|
481
|
+
.select()
|
|
482
|
+
.from(contracts)
|
|
483
|
+
.where(eq(contracts.id, contractId))
|
|
484
|
+
.limit(1);
|
|
485
|
+
if (!contract)
|
|
486
|
+
return { status: "not_found" };
|
|
487
|
+
if (contract.status !== "issued" && contract.status !== "sent") {
|
|
488
|
+
return { status: "not_signable" };
|
|
489
|
+
}
|
|
490
|
+
const [signature] = await tx
|
|
491
|
+
.insert(contractSignatures)
|
|
492
|
+
.values({ ...data, contractId })
|
|
493
|
+
.returning();
|
|
494
|
+
// Transition: first signature → signed; two-party → executed if both sides signed.
|
|
495
|
+
// MVP: single-signer flow → mark as "signed". Callers that need multi-party
|
|
496
|
+
// workflows should issue additional signatures and explicitly call execute.
|
|
497
|
+
const [updated] = await tx
|
|
498
|
+
.update(contracts)
|
|
499
|
+
.set({ status: "signed", updatedAt: new Date() })
|
|
500
|
+
.where(eq(contracts.id, contractId))
|
|
501
|
+
.returning();
|
|
502
|
+
return { status: "signed", contract: updated ?? null, signature: signature ?? null };
|
|
503
|
+
});
|
|
504
|
+
},
|
|
505
|
+
async executeContract(db, contractId) {
|
|
506
|
+
const [contract] = await db
|
|
507
|
+
.select({ id: contracts.id, status: contracts.status })
|
|
508
|
+
.from(contracts)
|
|
509
|
+
.where(eq(contracts.id, contractId))
|
|
510
|
+
.limit(1);
|
|
511
|
+
if (!contract)
|
|
512
|
+
return { status: "not_found" };
|
|
513
|
+
if (contract.status !== "signed")
|
|
514
|
+
return { status: "not_signed" };
|
|
515
|
+
const [updated] = await db
|
|
516
|
+
.update(contracts)
|
|
517
|
+
.set({ status: "executed", executedAt: new Date(), updatedAt: new Date() })
|
|
518
|
+
.where(eq(contracts.id, contractId))
|
|
519
|
+
.returning();
|
|
520
|
+
return { status: "executed", contract: updated ?? null };
|
|
521
|
+
},
|
|
522
|
+
// ---------- attachments ----------
|
|
523
|
+
listAttachments(db, contractId) {
|
|
524
|
+
return db
|
|
525
|
+
.select()
|
|
526
|
+
.from(contractAttachments)
|
|
527
|
+
.where(eq(contractAttachments.contractId, contractId))
|
|
528
|
+
.orderBy(desc(contractAttachments.createdAt));
|
|
529
|
+
},
|
|
530
|
+
async createAttachment(db, contractId, data) {
|
|
531
|
+
const [contract] = await db
|
|
532
|
+
.select({ id: contracts.id })
|
|
533
|
+
.from(contracts)
|
|
534
|
+
.where(eq(contracts.id, contractId))
|
|
535
|
+
.limit(1);
|
|
536
|
+
if (!contract)
|
|
537
|
+
return null;
|
|
538
|
+
const [row] = await db
|
|
539
|
+
.insert(contractAttachments)
|
|
540
|
+
.values({ ...data, contractId })
|
|
541
|
+
.returning();
|
|
542
|
+
return row ?? null;
|
|
543
|
+
},
|
|
544
|
+
async updateAttachment(db, attachmentId, data) {
|
|
545
|
+
const [row] = await db
|
|
546
|
+
.update(contractAttachments)
|
|
547
|
+
.set(data)
|
|
548
|
+
.where(eq(contractAttachments.id, attachmentId))
|
|
549
|
+
.returning();
|
|
550
|
+
return row ?? null;
|
|
551
|
+
},
|
|
552
|
+
async deleteAttachment(db, attachmentId) {
|
|
553
|
+
const [row] = await db
|
|
554
|
+
.delete(contractAttachments)
|
|
555
|
+
.where(eq(contractAttachments.id, attachmentId))
|
|
556
|
+
.returning({ id: contractAttachments.id });
|
|
557
|
+
return row ?? null;
|
|
558
|
+
},
|
|
559
|
+
// ---------- preview ----------
|
|
560
|
+
/**
|
|
561
|
+
* Preview render: substitute variables against an arbitrary body without
|
|
562
|
+
* touching the database. Used by `/v1/admin/contracts/:id/render` and
|
|
563
|
+
* `/v1/admin/contracts/templates/:id/preview`.
|
|
564
|
+
*/
|
|
565
|
+
renderPreview(input) {
|
|
566
|
+
const body = input.body ?? "";
|
|
567
|
+
const format = input.bodyFormat ?? "markdown";
|
|
568
|
+
return renderTemplate(body, format, input.variables);
|
|
569
|
+
},
|
|
570
|
+
};
|