@teamscala/service-boot 0.23.481 → 0.23.492
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/package.json +1 -261
- package/src/agreement-gate/signing-session.ts +1 -1
- package/src/build-client-assets/styles-build.ts +11 -1
- package/src/mount-orpc-router.ts +1 -1
- package/src/register-page-server-client.ts +1 -15
- package/src/register-ws-routes.ts +1 -1
- package/src/fn-routers/agreement-certificate.ts +0 -224
- package/src/fn-routers/agreement-completion-email.ts +0 -114
- package/src/fn-routers/agreement-send-router.ts +0 -303
- package/src/fn-routers/agreement-sign-side-effects.ts +0 -192
- package/src/fn-routers/agreement-signing-evidence.ts +0 -105
- package/src/fn-routers/assessment-report-data.test.ts +0 -404
- package/src/fn-routers/assessment-report-data.ts +0 -485
- package/src/fn-routers/assessment-report-email.ts +0 -256
- package/src/fn-routers/assessment-report-pair.test.ts +0 -136
- package/src/fn-routers/assessment-report-pair.ts +0 -155
- package/src/fn-routers/assessment-research-report-data.test.ts +0 -233
- package/src/fn-routers/assessment-research-report-data.ts +0 -424
- package/src/fn-routers/assessment-scoring.ts +0 -105
- package/src/fn-routers/auth-router.ts +0 -65
- package/src/fn-routers/chat-session-router-shared.ts +0 -122
- package/src/fn-routers/chat-session-router.ts +0 -356
- package/src/fn-routers/conversation-ownership.ts +0 -24
- package/src/fn-routers/email-defaults.ts +0 -28
- package/src/fn-routers/email-link-ttl.ts +0 -28
- package/src/fn-routers/index.ts +0 -76
- package/src/fn-routers/linkedin-identity.test.ts +0 -27
- package/src/fn-routers/linkedin-identity.ts +0 -36
- package/src/fn-routers/messaging-whatsapp-router.test.ts +0 -121
- package/src/fn-routers/messaging-whatsapp-router.ts +0 -200
- package/src/fn-routers/panel-data-router.ts +0 -104
- package/src/fn-routers/persist-chat-messages.test.ts +0 -86
- package/src/fn-routers/persist-chat-messages.ts +0 -98
- package/src/fn-routers/proposal-send-router.ts +0 -207
- package/src/fn-routers/public-agreement-router.ts +0 -407
- package/src/fn-routers/public-assessment-router.test.ts +0 -437
- package/src/fn-routers/public-assessment-router.ts +0 -268
- package/src/fn-routers/public-gpt-session-router.ts +0 -378
- package/src/fn-routers/resend-client.ts +0 -39
- package/src/fn-routers/sign-in-flow-router.ts +0 -196
- package/src/fn-routers/user-preferences-router.ts +0 -130
- package/src/fn-routers/work-items-router.ts +0 -342
- package/src/page-server-orpc/audit-revert-router.ts +0 -102
- package/src/page-server-orpc/puck-pages-router.ts +0 -170
- package/src/page-server-orpc/user-preferences-router.ts +0 -132
|
@@ -1,407 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Public ORPC procedures for DocuSign-style agreement signing, reached by the
|
|
7
|
-
* sign-in block's in-document signing blocks over the public HTTP transport
|
|
8
|
-
* (getOrpcClient().fn.publicAgreement.*). The unguessable agreement id plus an
|
|
9
|
-
* email that matches one of the agreement's parties IS the gate (no password).
|
|
10
|
-
*
|
|
11
|
-
* Off-the-shelf path (building-applications): typed ORPC procedure, not a
|
|
12
|
-
* hand-written Hono handler; writes go through context.prisma inside the
|
|
13
|
-
* handler (orpc-is-the-only-api) so the audit trigger sees the ORPC caller.
|
|
14
|
-
* ONE signing model, the DocuSign recipients shape: signature_data holds a
|
|
15
|
-
* parties roster (JSON), each party is gated by its own email, and the
|
|
16
|
-
* agreement completes only when every party has signed. A single signer is a
|
|
17
|
-
* roster of one -- there is no separate single-signer path.
|
|
18
|
-
*
|
|
19
|
-
* A legacy signer_email/signer_name fallback used to sit alongside this and was
|
|
20
|
-
* deleted 2026-09-03: measured across every agreement on the platform, 13 of 14
|
|
21
|
-
* carried a roster and ZERO used the fallback (the 14th was an unsent draft with
|
|
22
|
-
* no signature_data at all). It was never exercised, and it was not inert -- its
|
|
23
|
-
* write put a BARE STRING into signature_data, which would have destroyed the
|
|
24
|
-
* roster of any agreement that reached it. Keeping it also meant every change to
|
|
25
|
-
* this handler had to be made twice: the Prisma 8 timestamptz fix that made
|
|
26
|
-
* signing work at all had to be applied to both branches of one function.
|
|
27
|
-
*
|
|
28
|
-
* This module holds the three procedures (verify/sign/verifyDocument); the
|
|
29
|
-
* supporting machinery lives as siblings (agreement-signing-evidence,
|
|
30
|
-
* agreement-certificate, agreement-sign-side-effects) — split when the parent
|
|
31
|
-
* held them all at 853 lines.
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
import { os } from "@orpc/server";
|
|
35
|
-
import * as v from "valibot";
|
|
36
|
-
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
37
|
-
import { queryRows } from "@teamscala/db/query-rows";
|
|
38
|
-
import { recordEvent } from "@teamscala/event-log/record-event";
|
|
39
|
-
import type { PrismaClient } from "@teamscala/db/client";
|
|
40
|
-
import { signAgreementAccessToken } from "@teamscala/document-signing/agreement-access-token";
|
|
41
|
-
import {
|
|
42
|
-
parseAgreementParties as parseParties,
|
|
43
|
-
toPublicParties as partySummary,
|
|
44
|
-
} from "@teamscala/document-signing/agreement-parties";
|
|
45
|
-
import { sendAgreementCompletionEmails } from "./agreement-completion-email.ts";
|
|
46
|
-
import {
|
|
47
|
-
clientIp,
|
|
48
|
-
computeDocumentHash,
|
|
49
|
-
originFrom,
|
|
50
|
-
parseEvidence,
|
|
51
|
-
} from "./agreement-signing-evidence.ts";
|
|
52
|
-
import {
|
|
53
|
-
connectSignerToCrm,
|
|
54
|
-
invoicePayUrl,
|
|
55
|
-
maybeResetDemoAgreement,
|
|
56
|
-
} from "./agreement-sign-side-effects.ts";
|
|
57
|
-
|
|
58
|
-
export { buildCertificateData } from "./agreement-certificate.ts";
|
|
59
|
-
export type { CertificateData, CertificateCopy } from "./agreement-certificate.ts";
|
|
60
|
-
|
|
61
|
-
interface Context {
|
|
62
|
-
prisma: PrismaClient;
|
|
63
|
-
headers: Headers;
|
|
64
|
-
resolveUser?: (headers: Headers, prisma: PrismaClient) => Promise<unknown>;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const pub = os.$context<Context>();
|
|
68
|
-
|
|
69
|
-
const verifySchema = v.object({
|
|
70
|
-
agreementId: v.string(),
|
|
71
|
-
email: v.string(),
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
const signSchema = v.object({
|
|
75
|
-
agreementId: v.string(),
|
|
76
|
-
email: v.string(),
|
|
77
|
-
signatureData: v.optional(v.string()),
|
|
78
|
-
signerName: v.optional(v.string()),
|
|
79
|
-
// Legal signature-block fields, captured in the signing modal.
|
|
80
|
-
title: v.optional(v.string()),
|
|
81
|
-
company: v.optional(v.string()),
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
export const publicAgreementRouter = {
|
|
85
|
-
// View-gate: confirms an email matches a party on the agreement AND, only on a
|
|
86
|
-
// match, returns the document content. The content is NOT in the public page
|
|
87
|
-
// SSR (the public route ships only the SignIn gate block) — the document blocks
|
|
88
|
-
// live on the agreement's page_id and are served here, gated by the email
|
|
89
|
-
// match, so an unauthenticated/wrong-email request never receives the bytes.
|
|
90
|
-
verify: pub.input(verifySchema).handler(async ({ input, context }) => {
|
|
91
|
-
const agreementId = input.agreementId.trim();
|
|
92
|
-
const email = input.email.trim().toLowerCase();
|
|
93
|
-
if (!agreementId || !email) return { matched: false as const };
|
|
94
|
-
|
|
95
|
-
const agreement = await context.prisma.agreements.findFirst({
|
|
96
|
-
where: { id: agreementId },
|
|
97
|
-
select: {
|
|
98
|
-
id: true,
|
|
99
|
-
name: true,
|
|
100
|
-
signer_email: true,
|
|
101
|
-
signer_name: true,
|
|
102
|
-
signed_at: true,
|
|
103
|
-
signature_data: true,
|
|
104
|
-
},
|
|
105
|
-
});
|
|
106
|
-
if (!agreement) return { matched: false as const };
|
|
107
|
-
|
|
108
|
-
const parties = parseParties(agreement.signature_data);
|
|
109
|
-
if (parties) {
|
|
110
|
-
const me = parties.find((p) => p.email.trim().toLowerCase() === email);
|
|
111
|
-
if (!me) return { matched: false as const };
|
|
112
|
-
return {
|
|
113
|
-
matched: true,
|
|
114
|
-
multiParty: true,
|
|
115
|
-
name: agreement.name,
|
|
116
|
-
role: me.key,
|
|
117
|
-
signerName: me.name,
|
|
118
|
-
partySignedAt: me.signedAt ?? null,
|
|
119
|
-
allSigned: parties.every((p) => p.signedAt),
|
|
120
|
-
parties: partySummary(parties),
|
|
121
|
-
// Signed proof for the page loader: only with this token does the SSR
|
|
122
|
-
// include the document. The client sets it as a cookie and reloads;
|
|
123
|
-
// the loader validates it and renders the document server-side.
|
|
124
|
-
accessToken: signAgreementAccessToken(agreementId, email, Date.now()),
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// No roster means the agreement was never prepared for signing. It is
|
|
129
|
-
// not viewable, and it is certainly not signable.
|
|
130
|
-
return { matched: false as const };
|
|
131
|
-
}),
|
|
132
|
-
|
|
133
|
-
// Commit the authed party's signature. Returns { ok, parties } so the block
|
|
134
|
-
// can refresh the roster; on a gate failure returns { ok:false, error } so
|
|
135
|
-
// the block can show the specific message rather than a generic throw.
|
|
136
|
-
sign: pub.input(signSchema).handler(async ({ input, context }) => {
|
|
137
|
-
const log = getAppLogger();
|
|
138
|
-
const agreementId = input.agreementId.trim();
|
|
139
|
-
const email = input.email.trim().toLowerCase();
|
|
140
|
-
if (!agreementId || !email) {
|
|
141
|
-
return { ok: false as const, error: "agreementId and email are required" };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const agreement = await context.prisma.agreements.findFirst({
|
|
145
|
-
where: { id: agreementId },
|
|
146
|
-
select: {
|
|
147
|
-
id: true,
|
|
148
|
-
organisation_id: true,
|
|
149
|
-
signer_email: true,
|
|
150
|
-
signer_name: true,
|
|
151
|
-
contact_id: true,
|
|
152
|
-
signed_at: true,
|
|
153
|
-
signature_data: true,
|
|
154
|
-
page_id: true,
|
|
155
|
-
proposal_id: true,
|
|
156
|
-
},
|
|
157
|
-
});
|
|
158
|
-
if (!agreement) return { ok: false as const, error: "Agreement not found" };
|
|
159
|
-
|
|
160
|
-
// TWO representations, deliberately, because they have two consumers.
|
|
161
|
-
//
|
|
162
|
-
// `now` is an ISO STRING and belongs in the parties roster, which is
|
|
163
|
-
// serialised to JSON in signature_data — a Temporal.Instant would
|
|
164
|
-
// stringify into that JSON as an object.
|
|
165
|
-
//
|
|
166
|
-
// `nowInstant` is what the timestamptz COLUMNS take. Under Prisma 8 the
|
|
167
|
-
// timestamptz codec ('pg/timestamptz-temporal@1') accepts a
|
|
168
|
-
// Temporal.Instant and NOTHING ELSE. Measured against the live service:
|
|
169
|
-
// a string gives "received a string", a Date gives "received a Date",
|
|
170
|
-
// and only Temporal.Instant writes. Passing `now` therefore threw on
|
|
171
|
-
// EVERY signature, and the handler's catch reported the generic "Could
|
|
172
|
-
// not submit your signature." -- so signing was impossible for every
|
|
173
|
-
// signer with nothing naming the cause.
|
|
174
|
-
//
|
|
175
|
-
// The cast is load-bearing and is NOT laziness: the generated Prisma
|
|
176
|
-
// types declare these fields `string | Date` and reject Instant, so the
|
|
177
|
-
// client's TYPES and its RUNTIME CODEC disagree. Following the types
|
|
178
|
-
// produces code that compiles and always throws, which is exactly how
|
|
179
|
-
// this shipped. The runtime is the authority here; the generated types
|
|
180
|
-
// are the defect, and they are the thing to fix upstream.
|
|
181
|
-
const now = new Date().toISOString();
|
|
182
|
-
const nowInstant = Temporal.Now.instant() as unknown as Date;
|
|
183
|
-
|
|
184
|
-
try {
|
|
185
|
-
// Multi-party path: signature_data holds a parties roster.
|
|
186
|
-
const parties = parseParties(agreement.signature_data);
|
|
187
|
-
if (parties) {
|
|
188
|
-
const idx = parties.findIndex(
|
|
189
|
-
(p) => p.email.trim().toLowerCase() === email,
|
|
190
|
-
);
|
|
191
|
-
if (idx < 0) {
|
|
192
|
-
return {
|
|
193
|
-
ok: false as const,
|
|
194
|
-
error: "This email is not a signer on this agreement.",
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
if (!parties[idx].signedAt) {
|
|
198
|
-
parties[idx].signedAt = now;
|
|
199
|
-
parties[idx].signature = (
|
|
200
|
-
input.signatureData ?? parties[idx].name
|
|
201
|
-
).trim();
|
|
202
|
-
if (input.title != null)
|
|
203
|
-
parties[idx].title = input.title.trim() || null;
|
|
204
|
-
if (input.company != null)
|
|
205
|
-
parties[idx].company = input.company.trim() || null;
|
|
206
|
-
// Signing-evidence capture (DocuSign-style audit trail): the
|
|
207
|
-
// signer's IP + device at the moment of signing, plus how they
|
|
208
|
-
// were authenticated. Stored in signature_data → captured
|
|
209
|
-
// immutably by audit_trail on this update. The agreement gate
|
|
210
|
-
// currently authorises by email-match; authMethod becomes "email-otp"
|
|
211
|
-
// once the gate unifies onto the standard OTP login.
|
|
212
|
-
// unifies onto the standard OTP login.
|
|
213
|
-
parties[idx].signedIp = clientIp(context.headers);
|
|
214
|
-
parties[idx].signedUserAgent =
|
|
215
|
-
context.headers.get("user-agent") ?? null;
|
|
216
|
-
parties[idx].authMethod = "email-match";
|
|
217
|
-
}
|
|
218
|
-
// EXECUTION vs PAYMENT are separate (agreement-signing.md §2):
|
|
219
|
-
// • executed = every party has signed → gates signed_at, signed_by,
|
|
220
|
-
// the tamper-evident seal, and the Certificate of Completion. An
|
|
221
|
-
// agreement is NOT "complete" until all parties have signed.
|
|
222
|
-
// • firePayment = the CLIENT's signature triggers Checkout (operator
|
|
223
|
-
// ruling) — the client pays when they sign, even before the provider
|
|
224
|
-
// has countersigned. This does NOT mark the document executed.
|
|
225
|
-
const allSigned = parties.every((p) => p.signedAt);
|
|
226
|
-
const clientSigned = parties[idx].key === "client";
|
|
227
|
-
const executed = allSigned;
|
|
228
|
-
const firePayment = clientSigned;
|
|
229
|
-
// Tamper-evident seal: at completion, hash the executed document +
|
|
230
|
-
// signed roster and store it alongside the parties. The verification
|
|
231
|
-
// surface re-hashes later to prove the document was not altered.
|
|
232
|
-
let evidence: Record<string, unknown> | undefined;
|
|
233
|
-
if (executed && agreement.page_id) {
|
|
234
|
-
try {
|
|
235
|
-
const doc = await context.prisma.builda_pages.findFirst({
|
|
236
|
-
where: { id: agreement.page_id },
|
|
237
|
-
select: { puck_data: true },
|
|
238
|
-
});
|
|
239
|
-
evidence = {
|
|
240
|
-
documentHash: computeDocumentHash(doc?.puck_data ?? null, parties),
|
|
241
|
-
hashAlgo: "sha256",
|
|
242
|
-
hashedAt: now,
|
|
243
|
-
};
|
|
244
|
-
} catch (e) {
|
|
245
|
-
// A hashing failure must never block the signature itself.
|
|
246
|
-
log.warn(
|
|
247
|
-
`[publicAgreement.sign] document-hash failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`,
|
|
248
|
-
);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
const data: Record<string, unknown> = {
|
|
252
|
-
signature_data: JSON.stringify(
|
|
253
|
-
evidence ? { parties, evidence } : { parties },
|
|
254
|
-
),
|
|
255
|
-
viewed_at: nowInstant,
|
|
256
|
-
};
|
|
257
|
-
if (executed) {
|
|
258
|
-
data.signed_at = nowInstant;
|
|
259
|
-
data.signed_by = agreement.contact_id ?? null;
|
|
260
|
-
}
|
|
261
|
-
const updated = await context.prisma.agreements.update({
|
|
262
|
-
where: { id: agreementId },
|
|
263
|
-
data,
|
|
264
|
-
});
|
|
265
|
-
// Completion delivery: when THIS signature is the one that fully
|
|
266
|
-
// executes the agreement (it was not executed before this call),
|
|
267
|
-
// email every party their executed copy + Certificate of Completion.
|
|
268
|
-
// Fire-and-forget — the helper is best-effort and self-contained, so
|
|
269
|
-
// a mail failure never blocks or undoes the signature.
|
|
270
|
-
if (executed && !agreement.signed_at) {
|
|
271
|
-
void sendAgreementCompletionEmails(
|
|
272
|
-
agreementId,
|
|
273
|
-
originFrom(context.headers),
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
|
-
// Every captured identity → CRM: connect this signer to client_database.
|
|
277
|
-
await connectSignerToCrm(
|
|
278
|
-
context.prisma,
|
|
279
|
-
agreement,
|
|
280
|
-
email,
|
|
281
|
-
parties[idx].name,
|
|
282
|
-
input.company ?? parties[idx].company,
|
|
283
|
-
);
|
|
284
|
-
const fresh = parseParties(updated.signature_data) ?? parties;
|
|
285
|
-
const payUrl = firePayment
|
|
286
|
-
? await invoicePayUrl(agreementId, originFrom(context.headers))
|
|
287
|
-
: null;
|
|
288
|
-
const result = {
|
|
289
|
-
ok: true as const,
|
|
290
|
-
role: parties[idx].key,
|
|
291
|
-
signedAt: parties[idx].signedAt,
|
|
292
|
-
allSigned: executed,
|
|
293
|
-
parties: partySummary(fresh),
|
|
294
|
-
payUrl,
|
|
295
|
-
};
|
|
296
|
-
// Self-resetting demo harness: an allow-listed demo agreement
|
|
297
|
-
// auto-unsigns once it reaches payment, so the next person gets a
|
|
298
|
-
// fresh form to test the UI. The current signer's response (above)
|
|
299
|
-
// still shows their signature + payUrl; only the stored record is
|
|
300
|
-
// reset. Real agreements are never in the allow-list, so they never
|
|
301
|
-
// auto-reset. (config: demo-reset-agreements)
|
|
302
|
-
if (firePayment) {
|
|
303
|
-
await maybeResetDemoAgreement(
|
|
304
|
-
context.prisma,
|
|
305
|
-
agreementId,
|
|
306
|
-
agreement.proposal_id ?? null,
|
|
307
|
-
parties,
|
|
308
|
-
);
|
|
309
|
-
}
|
|
310
|
-
return result;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// An agreement with no roster was never prepared for signing.
|
|
314
|
-
// Refuse rather than invent a signer.
|
|
315
|
-
return {
|
|
316
|
-
ok: false as const,
|
|
317
|
-
error: "This agreement has no signers recorded and cannot be signed.",
|
|
318
|
-
};
|
|
319
|
-
} catch (err) {
|
|
320
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
321
|
-
log.error(`[publicAgreement.sign] failed: ${detail}`);
|
|
322
|
-
// A LOG LINE IS NOT A RECORD. Measured 2026-09-03: signing was broken
|
|
323
|
-
// for every signer for hours -- first a 404 on the procedure, then a
|
|
324
|
-
// Prisma 8 codec rejecting the timestamp write -- and error_logs held
|
|
325
|
-
// ZERO rows the whole time. The page returned 200, the service was
|
|
326
|
-
// running, the deploy was green, and the signer saw only "Could not
|
|
327
|
-
// submit your signature.", which reads as transient and invites a
|
|
328
|
-
// retry that fails identically. The failure was discovered by a human
|
|
329
|
-
// being unable to sign, which is the same shape as
|
|
330
|
-
// page-server.render.failed above and takes the same cure: emit a
|
|
331
|
-
// durable event so a surface can answer "is signing failing" without
|
|
332
|
-
// somebody attempting a signature.
|
|
333
|
-
//
|
|
334
|
-
// Fire-and-forget and last: telemetry may never change what the signer
|
|
335
|
-
// is served, and this path is already handling a failure
|
|
336
|
-
// (observability-is-passive). The agreement id and the signer role
|
|
337
|
-
// make a recorded event lead to the specific failure.
|
|
338
|
-
recordEvent({
|
|
339
|
-
kind: "agreement.sign.failed",
|
|
340
|
-
payload: {
|
|
341
|
-
agreementId,
|
|
342
|
-
error: detail.slice(0, 500),
|
|
343
|
-
errorName: err instanceof Error ? err.name : "unknown",
|
|
344
|
-
},
|
|
345
|
-
});
|
|
346
|
-
return { ok: false as const, error: "Could not submit your signature." };
|
|
347
|
-
}
|
|
348
|
-
}),
|
|
349
|
-
|
|
350
|
-
// Public tamper-verification: re-hash the currently-stored document + roster
|
|
351
|
-
// and compare to the sealed hash captured at completion. Returns only
|
|
352
|
-
// public-safe data (names, roles, signed timestamps, hash match) — never
|
|
353
|
-
// emails or IPs — so it can back a public "verify this document" page.
|
|
354
|
-
verifyDocument: pub
|
|
355
|
-
.input(v.object({ agreementId: v.string() }))
|
|
356
|
-
.handler(async ({ input }) => {
|
|
357
|
-
const agreementId = input.agreementId.trim();
|
|
358
|
-
if (!agreementId) return { ok: false as const };
|
|
359
|
-
// Public verification is intentionally unscoped: anyone holding the id
|
|
360
|
-
// confirms authenticity and gets only public-safe data back. The caller
|
|
361
|
-
// is unauthenticated (no access cookie), so context.prisma's RLS would
|
|
362
|
-
// hide the row — use the raw registry prisma, the same bypass the gate's
|
|
363
|
-
// signer-email resolver uses.
|
|
364
|
-
const [agreement] = await queryRows<
|
|
365
|
-
{
|
|
366
|
-
id: string;
|
|
367
|
-
name: string;
|
|
368
|
-
signed_at: Date | null;
|
|
369
|
-
signature_data: unknown;
|
|
370
|
-
page_id: string | null;
|
|
371
|
-
}
|
|
372
|
-
>`
|
|
373
|
-
SELECT id, name, signed_at, signature_data, page_id
|
|
374
|
-
FROM agreements
|
|
375
|
-
WHERE (id = ${agreementId} OR page_id = ${agreementId})
|
|
376
|
-
LIMIT 1`;
|
|
377
|
-
if (!agreement) return { ok: false as const };
|
|
378
|
-
const parties = parseParties(agreement.signature_data);
|
|
379
|
-
const evidence = parseEvidence(agreement.signature_data);
|
|
380
|
-
const complete =
|
|
381
|
-
!!agreement.signed_at || (parties?.every((p) => p.signedAt) ?? false);
|
|
382
|
-
let recomputedHash: string | null = null;
|
|
383
|
-
if (evidence?.documentHash && parties && agreement.page_id) {
|
|
384
|
-
const [doc] = await queryRows<{ puck_data: unknown }>`
|
|
385
|
-
SELECT puck_data FROM builda_pages WHERE id = ${agreement.page_id} LIMIT 1`;
|
|
386
|
-
recomputedHash = computeDocumentHash(doc?.puck_data ?? null, parties);
|
|
387
|
-
}
|
|
388
|
-
return {
|
|
389
|
-
ok: true as const,
|
|
390
|
-
envelopeId: agreement.id,
|
|
391
|
-
documentName: agreement.name,
|
|
392
|
-
complete,
|
|
393
|
-
signedAt: agreement.signed_at?.toISOString() ?? null,
|
|
394
|
-
sealedHash: evidence?.documentHash ?? null,
|
|
395
|
-
recomputedHash,
|
|
396
|
-
valid:
|
|
397
|
-
!!evidence?.documentHash && recomputedHash === evidence.documentHash,
|
|
398
|
-
parties: parties
|
|
399
|
-
? parties.map((p) => ({
|
|
400
|
-
role: p.key,
|
|
401
|
-
name: p.name,
|
|
402
|
-
signedAt: p.signedAt,
|
|
403
|
-
}))
|
|
404
|
-
: [],
|
|
405
|
-
};
|
|
406
|
-
}),
|
|
407
|
-
};
|