@teamscala/service-boot 0.23.415 → 0.23.416
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 +25 -1
- package/src/agreement-gate/signing-session.ts +1 -1
- package/src/fn-routers/agreement-certificate.ts +224 -0
- package/src/fn-routers/agreement-sign-side-effects.ts +190 -0
- package/src/fn-routers/agreement-signing-evidence.ts +105 -0
- package/src/fn-routers/public-agreement-router.ts +21 -470
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
9
|
+
"version": "0.23.416",
|
|
10
10
|
"sideEffects": false,
|
|
11
11
|
"imports": {
|
|
12
12
|
"#service-boot/*": "./src/*",
|
|
@@ -1181,6 +1181,30 @@
|
|
|
1181
1181
|
"./agreement-gate/authorized-emails.ts": {
|
|
1182
1182
|
"types": "./src/agreement-gate/authorized-emails.d.ts",
|
|
1183
1183
|
"default": "./src/agreement-gate/authorized-emails.ts"
|
|
1184
|
+
},
|
|
1185
|
+
"./fn-routers/agreement-signing-evidence": {
|
|
1186
|
+
"types": "./src/fn-routers/agreement-signing-evidence.d.ts",
|
|
1187
|
+
"default": "./src/fn-routers/agreement-signing-evidence.ts"
|
|
1188
|
+
},
|
|
1189
|
+
"./fn-routers/agreement-signing-evidence.ts": {
|
|
1190
|
+
"types": "./src/fn-routers/agreement-signing-evidence.d.ts",
|
|
1191
|
+
"default": "./src/fn-routers/agreement-signing-evidence.ts"
|
|
1192
|
+
},
|
|
1193
|
+
"./fn-routers/agreement-certificate": {
|
|
1194
|
+
"types": "./src/fn-routers/agreement-certificate.d.ts",
|
|
1195
|
+
"default": "./src/fn-routers/agreement-certificate.ts"
|
|
1196
|
+
},
|
|
1197
|
+
"./fn-routers/agreement-certificate.ts": {
|
|
1198
|
+
"types": "./src/fn-routers/agreement-certificate.d.ts",
|
|
1199
|
+
"default": "./src/fn-routers/agreement-certificate.ts"
|
|
1200
|
+
},
|
|
1201
|
+
"./fn-routers/agreement-sign-side-effects": {
|
|
1202
|
+
"types": "./src/fn-routers/agreement-sign-side-effects.d.ts",
|
|
1203
|
+
"default": "./src/fn-routers/agreement-sign-side-effects.ts"
|
|
1204
|
+
},
|
|
1205
|
+
"./fn-routers/agreement-sign-side-effects.ts": {
|
|
1206
|
+
"types": "./src/fn-routers/agreement-sign-side-effects.d.ts",
|
|
1207
|
+
"default": "./src/fn-routers/agreement-sign-side-effects.ts"
|
|
1184
1208
|
}
|
|
1185
1209
|
},
|
|
1186
1210
|
"repository": "https://github.com/teamgbg/service-boot",
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
import {
|
|
21
21
|
buildCertificateData,
|
|
22
22
|
type CertificateData,
|
|
23
|
-
} from "../fn-routers/
|
|
23
|
+
} from "../fn-routers/agreement-certificate.ts";
|
|
24
24
|
import { findFirstAgreement, resolveAgreementViewerDirectory } from "./agreement-lookup.ts";
|
|
25
25
|
|
|
26
26
|
export interface ContentBlock {
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The Certificate of Completion for an executed agreement: DB-driven display
|
|
7
|
+
* copy (config/certificate-of-completion), the CertificateData shape, and
|
|
8
|
+
* the party-gated builder.
|
|
9
|
+
*
|
|
10
|
+
* Split out of fn-routers/public-agreement-router.ts (853 lines) —
|
|
11
|
+
* byte-for-byte move; import paths adjusted only.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as v from "valibot";
|
|
15
|
+
import { queryRows } from "@teamscala/db/query-rows";
|
|
16
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
17
|
+
import {
|
|
18
|
+
parseAgreementParties as parseParties,
|
|
19
|
+
} from "@teamscala/document-signing/agreement-parties";
|
|
20
|
+
import { parseEvidence } from "./agreement-signing-evidence.ts";
|
|
21
|
+
|
|
22
|
+
/** The Certificate of Completion display copy. DB-driven via the
|
|
23
|
+
* `config/certificate-of-completion` registry row — mirrors the agreement gate's
|
|
24
|
+
* `config/content-gate-agreement` pattern (same signing surface), so an operator
|
|
25
|
+
* can rebrand/reword the certificate via `registry_edit` with no deploy; the
|
|
26
|
+
* hardcoded defaults are the fallback. Generic structural data-column labels
|
|
27
|
+
* (Signer/Role/Envelope ID/…) stay in the component, exactly as the gate keeps
|
|
28
|
+
* its structural markup in code. */
|
|
29
|
+
export interface CertificateCopy {
|
|
30
|
+
title: string;
|
|
31
|
+
signersHeading: string;
|
|
32
|
+
sealHeading: string;
|
|
33
|
+
sealNote: string;
|
|
34
|
+
footer: string;
|
|
35
|
+
badgeCompleted: string;
|
|
36
|
+
badgeInProgress: string;
|
|
37
|
+
/** Audit-table column labels — DB-driven so rewording is a registry_edit.
|
|
38
|
+
* Each labels BOTH the <th> header AND the stacked-row data-label on mobile
|
|
39
|
+
* (one source, kept in sync), never a second hardcoded copy. */
|
|
40
|
+
colSigner: string;
|
|
41
|
+
colRole: string;
|
|
42
|
+
colSigned: string;
|
|
43
|
+
colIp: string;
|
|
44
|
+
colAuth: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const CERTIFICATE_COPY_DEFAULTS: CertificateCopy = {
|
|
48
|
+
title: "CERTIFICATE OF COMPLETION",
|
|
49
|
+
signersHeading: "Signers",
|
|
50
|
+
sealHeading: "Tamper-evident seal",
|
|
51
|
+
sealNote:
|
|
52
|
+
"{{hashAlgo}} hash of the executed document + signed roster, sealed {{hashedAt}}. Re-hashing the stored document and comparing to this value proves it has not been altered since signing.",
|
|
53
|
+
footer:
|
|
54
|
+
"This certificate is a record of the electronic signing of the above agreement, produced from the platform's permanent audit trail. Electronic signatures are legally recognised under the ESIGN Act and UETA (US) and eIDAS (EU).",
|
|
55
|
+
badgeCompleted: "Completed",
|
|
56
|
+
badgeInProgress: "In progress",
|
|
57
|
+
colSigner: "Signer",
|
|
58
|
+
colRole: "Role",
|
|
59
|
+
colSigned: "Signed (UTC)",
|
|
60
|
+
colIp: "IP address",
|
|
61
|
+
colAuth: "Auth",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const CertificateCopySchema = v.object({
|
|
65
|
+
title: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.title),
|
|
66
|
+
signersHeading: v.optional(
|
|
67
|
+
v.string(),
|
|
68
|
+
CERTIFICATE_COPY_DEFAULTS.signersHeading,
|
|
69
|
+
),
|
|
70
|
+
sealHeading: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.sealHeading),
|
|
71
|
+
sealNote: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.sealNote),
|
|
72
|
+
footer: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.footer),
|
|
73
|
+
badgeCompleted: v.optional(
|
|
74
|
+
v.string(),
|
|
75
|
+
CERTIFICATE_COPY_DEFAULTS.badgeCompleted,
|
|
76
|
+
),
|
|
77
|
+
badgeInProgress: v.optional(
|
|
78
|
+
v.string(),
|
|
79
|
+
CERTIFICATE_COPY_DEFAULTS.badgeInProgress,
|
|
80
|
+
),
|
|
81
|
+
colSigner: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.colSigner),
|
|
82
|
+
colRole: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.colRole),
|
|
83
|
+
colSigned: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.colSigned),
|
|
84
|
+
colIp: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.colIp),
|
|
85
|
+
colAuth: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.colAuth),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
async function loadCertificateCopy(): Promise<CertificateCopy | null> {
|
|
89
|
+
try {
|
|
90
|
+
return await loadRegistryConfig(
|
|
91
|
+
"config",
|
|
92
|
+
"certificate-of-completion",
|
|
93
|
+
CertificateCopySchema,
|
|
94
|
+
);
|
|
95
|
+
} catch {
|
|
96
|
+
// Absent signal, not a masking fallback (protective-layers-fail-explicitly):
|
|
97
|
+
// null propagates to CertificateData.copy, and the component renders its
|
|
98
|
+
// FALLBACK_COPY — same default values, but the absence is honest rather
|
|
99
|
+
// than disguised as a successful config load.
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The Certificate of Completion data shape — the court-style audit certificate.
|
|
105
|
+
* One home: {@link buildCertificateData} is consumed by the SSR agreement
|
|
106
|
+
* gate, so the signing block receives the server-seeded prop. */
|
|
107
|
+
export interface CertificateData {
|
|
108
|
+
ok: boolean;
|
|
109
|
+
envelopeId?: string;
|
|
110
|
+
documentName?: string | null;
|
|
111
|
+
issuedAt?: string;
|
|
112
|
+
complete?: boolean;
|
|
113
|
+
createdAt?: string | null;
|
|
114
|
+
sentAt?: string | null;
|
|
115
|
+
viewedAt?: string | null;
|
|
116
|
+
signedAt?: string | null;
|
|
117
|
+
documentHash?: string | null;
|
|
118
|
+
hashAlgo?: string | null;
|
|
119
|
+
hashedAt?: string | null;
|
|
120
|
+
parties?: {
|
|
121
|
+
role: string;
|
|
122
|
+
name: string | null;
|
|
123
|
+
email: string | null;
|
|
124
|
+
title: string | null;
|
|
125
|
+
company: string | null;
|
|
126
|
+
signedAt: string | null;
|
|
127
|
+
ip: string | null;
|
|
128
|
+
userAgent: string | null;
|
|
129
|
+
authMethod: string | null;
|
|
130
|
+
}[];
|
|
131
|
+
/** DB-driven display copy (config/certificate-of-completion); null when the
|
|
132
|
+
* config row is missing/broken — the component falls back to FALLBACK_COPY. */
|
|
133
|
+
copy?: CertificateCopy | null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Build the Certificate of Completion data for an agreement, gated to a party
|
|
138
|
+
* email — signer evidence (IP/device) is never exposed to a non-party. One data
|
|
139
|
+
* home: the SSR agreement gate (`buildSigningSession` in agreement-gate.ts)
|
|
140
|
+
* calls this. The read is
|
|
141
|
+
* intentionally RLS-unscoped via the raw registry prisma — the certificate is
|
|
142
|
+
* reached without a session (AG cookie only), which context.prisma's RLS would
|
|
143
|
+
* otherwise treat as no-org and hide. Accepts either the agreement record id or
|
|
144
|
+
* its page id (the UUID the signer sees in the URL is the page id).
|
|
145
|
+
*/
|
|
146
|
+
export async function buildCertificateData(
|
|
147
|
+
agreementId: string,
|
|
148
|
+
email: string,
|
|
149
|
+
): Promise<CertificateData> {
|
|
150
|
+
const id = agreementId.trim();
|
|
151
|
+
const mail = email.trim().toLowerCase();
|
|
152
|
+
if (!id || !mail) return { ok: false };
|
|
153
|
+
const [agreement] = await queryRows<
|
|
154
|
+
{
|
|
155
|
+
id: string;
|
|
156
|
+
name: string;
|
|
157
|
+
created_at: Date | null;
|
|
158
|
+
sent_at: Date | null;
|
|
159
|
+
viewed_at: Date | null;
|
|
160
|
+
signed_at: Date | null;
|
|
161
|
+
signature_data: unknown;
|
|
162
|
+
signer_email: string | null;
|
|
163
|
+
signer_name: string | null;
|
|
164
|
+
}[]
|
|
165
|
+
>`
|
|
166
|
+
SELECT id, name, created_at, sent_at, viewed_at, signed_at,
|
|
167
|
+
signature_data, signer_email, signer_name
|
|
168
|
+
FROM agreements
|
|
169
|
+
WHERE (id = ${id} OR page_id = ${id})
|
|
170
|
+
LIMIT 1`;
|
|
171
|
+
if (!agreement) return { ok: false };
|
|
172
|
+
const parties = parseParties(agreement.signature_data);
|
|
173
|
+
const evidence = parseEvidence(agreement.signature_data);
|
|
174
|
+
const isParty = parties
|
|
175
|
+
? parties.some((p) => p.email.trim().toLowerCase() === mail)
|
|
176
|
+
: String(agreement.signer_email ?? "")
|
|
177
|
+
.trim()
|
|
178
|
+
.toLowerCase() === mail;
|
|
179
|
+
if (!isParty) return { ok: false };
|
|
180
|
+
const complete =
|
|
181
|
+
!!agreement.signed_at || (parties?.every((p) => p.signedAt) ?? false);
|
|
182
|
+
const iso = (d: Date | null | undefined) => d?.toISOString() ?? null;
|
|
183
|
+
const copy = await loadCertificateCopy();
|
|
184
|
+
return {
|
|
185
|
+
ok: true,
|
|
186
|
+
copy,
|
|
187
|
+
envelopeId: agreement.id,
|
|
188
|
+
documentName: agreement.name,
|
|
189
|
+
issuedAt: new Date().toISOString(),
|
|
190
|
+
complete,
|
|
191
|
+
createdAt: iso(agreement.created_at),
|
|
192
|
+
sentAt: iso(agreement.sent_at),
|
|
193
|
+
viewedAt: iso(agreement.viewed_at),
|
|
194
|
+
signedAt: iso(agreement.signed_at),
|
|
195
|
+
documentHash: evidence?.documentHash ?? null,
|
|
196
|
+
hashAlgo: evidence?.hashAlgo ?? null,
|
|
197
|
+
hashedAt: evidence?.hashedAt ?? null,
|
|
198
|
+
parties: parties
|
|
199
|
+
? parties.map((p) => ({
|
|
200
|
+
role: p.key,
|
|
201
|
+
name: p.name,
|
|
202
|
+
email: p.email,
|
|
203
|
+
title: p.title,
|
|
204
|
+
company: p.company,
|
|
205
|
+
signedAt: p.signedAt,
|
|
206
|
+
ip: p.signedIp ?? null,
|
|
207
|
+
userAgent: p.signedUserAgent ?? null,
|
|
208
|
+
authMethod: p.authMethod ?? null,
|
|
209
|
+
}))
|
|
210
|
+
: [
|
|
211
|
+
{
|
|
212
|
+
role: "signer",
|
|
213
|
+
name: agreement.signer_name,
|
|
214
|
+
email: agreement.signer_email,
|
|
215
|
+
title: null,
|
|
216
|
+
company: null,
|
|
217
|
+
signedAt: iso(agreement.signed_at),
|
|
218
|
+
ip: null,
|
|
219
|
+
userAgent: null,
|
|
220
|
+
authMethod: "email-match",
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Post-signature side effects of public agreement signing: Stripe invoice
|
|
7
|
+
* creation via the payments service, the self-resetting demo harness, and
|
|
8
|
+
* CRM connection of the captured signer.
|
|
9
|
+
*
|
|
10
|
+
* Split out of fn-routers/public-agreement-router.ts (853 lines) —
|
|
11
|
+
* byte-for-byte move; import paths adjusted only.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
15
|
+
import { queryRows } from "@teamscala/db/query-rows";
|
|
16
|
+
import type { AgreementParty } from "@teamscala/document-signing/agreement-parties";
|
|
17
|
+
|
|
18
|
+
/** On full agreement completion, delegate to the payment service over HTTP to
|
|
19
|
+
* create the Stripe invoice (per payment-is-one-service — all Stripe logic
|
|
20
|
+
* lives in @teamscala/payment, reached via POST /api/payments/checkout). Never
|
|
21
|
+
* throws — a payment-setup failure must not undo a completed signature; the
|
|
22
|
+
* signer is simply not auto-redirected to pay. But every failure mode is
|
|
23
|
+
* logged at WARN with the actual cause (URL missing, non-2xx, network, parse)
|
|
24
|
+
* so the silent-fail class the user would otherwise diagnose by hand is
|
|
25
|
+
* observable in GlitchTip + pm2 logs. */
|
|
26
|
+
export async function invoicePayUrl(
|
|
27
|
+
agreementId: string,
|
|
28
|
+
baseUrl: string,
|
|
29
|
+
): Promise<string | null> {
|
|
30
|
+
const log = getAppLogger();
|
|
31
|
+
const paymentsUrl = (process.env.SCALA_PAYMENTS_URL ?? "").replace(/\/+$/, "");
|
|
32
|
+
if (!paymentsUrl) {
|
|
33
|
+
log.warn(
|
|
34
|
+
`[publicAgreement] agreement ${agreementId} signed but SCALA_PAYMENTS_URL is unset — no invoice created. Set the env var on scala-sign's service_env row to a pay-internal tunnel URL.`,
|
|
35
|
+
);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(`${paymentsUrl}/api/payments/checkout`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "Content-Type": "application/json" },
|
|
42
|
+
body: JSON.stringify({ agreementId, baseUrl }),
|
|
43
|
+
});
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
log.warn(
|
|
46
|
+
`[publicAgreement] payments checkout returned ${res.status} for agreement ${agreementId} (${paymentsUrl}/api/payments/checkout)`,
|
|
47
|
+
);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const json = (await res.json()) as { ok?: boolean; hostedInvoiceUrl?: string };
|
|
51
|
+
if (!json.ok) {
|
|
52
|
+
log.warn(
|
|
53
|
+
`[publicAgreement] payments checkout ok=false for agreement ${agreementId}: ${JSON.stringify(json).slice(0, 200)}`,
|
|
54
|
+
);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return json.hostedInvoiceUrl ?? null;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
log.warn(
|
|
60
|
+
`[publicAgreement] payments checkout threw for agreement ${agreementId} (${paymentsUrl}): ${err instanceof Error ? err.message : String(err)}`,
|
|
61
|
+
);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Self-resetting demo harness. If the signed agreement's `proposal_id` is in
|
|
68
|
+
* the `demo-reset-agreements.proposalIds` allow-list, reset its parties to
|
|
69
|
+
* UNSIGNED (signedAt/signature cleared) + clear signed_at/signed_by + status
|
|
70
|
+
* 'sent', so the next person gets a fresh form to test the UI.
|
|
71
|
+
*
|
|
72
|
+
* Keyed on the stable PROPOSAL id, NOT the agreement id: the demo agreement is
|
|
73
|
+
* regenerated under a fresh id whenever the demo proposal is re-accepted, but
|
|
74
|
+
* its `proposal_id` FK is constant — so the allow-list cannot dangle. This
|
|
75
|
+
* deletes the drift class (prevention, not a patrol): the prior design keyed on
|
|
76
|
+
* `agreementIds`, and when the demo was regenerated the list silently pointed
|
|
77
|
+
* at a dead id and the reset became a no-op for weeks. A `proposal_id`-
|
|
78
|
+
* referenced row that doesn't exist is a real proposal being deleted, not a
|
|
79
|
+
* regeneration — a negligible residual the operator would notice directly.
|
|
80
|
+
*
|
|
81
|
+
* Gated strictly by the allow-list — a real agreement is never reset.
|
|
82
|
+
* Best-effort: any failure is swallowed (a demo-reset must never fail the
|
|
83
|
+
* signer's own response). The current signer already has their response (incl.
|
|
84
|
+
* payUrl) before this runs; only the stored record is reset.
|
|
85
|
+
*/
|
|
86
|
+
export async function maybeResetDemoAgreement(
|
|
87
|
+
prisma: any,
|
|
88
|
+
agreementId: string,
|
|
89
|
+
proposalId: string | null,
|
|
90
|
+
parties: AgreementParty[],
|
|
91
|
+
): Promise<void> {
|
|
92
|
+
if (!proposalId) return;
|
|
93
|
+
try {
|
|
94
|
+
const rows = await queryRows`
|
|
95
|
+
SELECT config FROM registry_entries
|
|
96
|
+
WHERE type = 'config' AND slug = 'demo-reset-agreements' AND is_active = true
|
|
97
|
+
LIMIT 1` as { config: { proposalIds?: string[] } }[];
|
|
98
|
+
const ids = rows?.[0]?.config?.proposalIds ?? [];
|
|
99
|
+
if (!Array.isArray(ids) || !ids.includes(proposalId)) return;
|
|
100
|
+
const reset = parties.map((p) => ({
|
|
101
|
+
...p,
|
|
102
|
+
signedAt: null,
|
|
103
|
+
signature: null,
|
|
104
|
+
title: null,
|
|
105
|
+
company: null,
|
|
106
|
+
}));
|
|
107
|
+
await prisma.agreements.update({
|
|
108
|
+
where: { id: agreementId },
|
|
109
|
+
data: {
|
|
110
|
+
signature_data: JSON.stringify({ parties: reset }),
|
|
111
|
+
signed_at: null,
|
|
112
|
+
signed_by: null,
|
|
113
|
+
status: "sent",
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
getAppLogger().info(`[publicAgreement.sign] demo agreement ${agreementId} auto-reset for re-testing`);
|
|
117
|
+
} catch {
|
|
118
|
+
// never fail the signer's response over a demo-reset
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Connect a captured signer to the CRM — every signable document lands in
|
|
124
|
+
* client_database, the same find-or-create capture path proposals / public-GPT /
|
|
125
|
+
* assessments already use (scoped by organisation_id + primary_email). Also
|
|
126
|
+
* links agreement.contact_id when the agreement wasn't generated from a proposal
|
|
127
|
+
* (contact_id NULL), so a standalone signed doc still connects to the CRM.
|
|
128
|
+
* Best-effort: a CRM write must never fail the signature (the signature is the
|
|
129
|
+
* legal act; CRM connection is bookkeeping).
|
|
130
|
+
*/
|
|
131
|
+
export async function connectSignerToCrm(
|
|
132
|
+
prisma: any,
|
|
133
|
+
agreement: { id: string; organisation_id?: string | null; contact_id?: string | null },
|
|
134
|
+
email: string,
|
|
135
|
+
signerName?: string | null,
|
|
136
|
+
company?: string | null,
|
|
137
|
+
): Promise<void> {
|
|
138
|
+
const orgId = agreement.organisation_id;
|
|
139
|
+
if (!orgId || !email || !prisma?.client_database) return;
|
|
140
|
+
try {
|
|
141
|
+
const parts = (signerName ?? "").trim().split(/\s+/).filter(Boolean);
|
|
142
|
+
const firstName = parts[0] ?? "";
|
|
143
|
+
const lastName = parts.slice(1).join(" ");
|
|
144
|
+
const existing = await prisma.client_database.findFirst({
|
|
145
|
+
where: { organisation_id: orgId, primary_email: email },
|
|
146
|
+
select: { id: true },
|
|
147
|
+
});
|
|
148
|
+
let contactId: string | null = null;
|
|
149
|
+
if (existing) {
|
|
150
|
+
contactId = existing.id as string;
|
|
151
|
+
await prisma.client_database.update({
|
|
152
|
+
where: { id: existing.id },
|
|
153
|
+
data: {
|
|
154
|
+
...(firstName ? { first_name: firstName } : {}),
|
|
155
|
+
...(lastName ? { last_name: lastName } : {}),
|
|
156
|
+
...(company ? { company_name: company } : {}),
|
|
157
|
+
last_updated_by: "agreement-sign",
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
} else {
|
|
161
|
+
const created = await prisma.client_database.create({
|
|
162
|
+
data: {
|
|
163
|
+
organisation_id: orgId,
|
|
164
|
+
first_name: firstName,
|
|
165
|
+
last_name: lastName,
|
|
166
|
+
primary_email: email,
|
|
167
|
+
...(company ? { company_name: company } : {}),
|
|
168
|
+
lead_source: "agreement-sign",
|
|
169
|
+
data_source: "public-form",
|
|
170
|
+
referral_source: agreement.id,
|
|
171
|
+
lifecycle_stage: "lead",
|
|
172
|
+
created_by: "agreement-sign",
|
|
173
|
+
last_updated_by: "agreement-sign",
|
|
174
|
+
},
|
|
175
|
+
select: { id: true },
|
|
176
|
+
});
|
|
177
|
+
contactId = (created?.id as string) ?? null;
|
|
178
|
+
}
|
|
179
|
+
if (contactId && !agreement.contact_id) {
|
|
180
|
+
await prisma.agreements.update({
|
|
181
|
+
where: { id: agreement.id },
|
|
182
|
+
data: { contact_id: contactId },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
} catch (e) {
|
|
186
|
+
getAppLogger().warn(
|
|
187
|
+
`[publicAgreement.sign] CRM connect failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Signing-evidence utilities for public agreement signing: the signer's
|
|
7
|
+
* origin/IP extraction from request headers, the canonical JSON form, the
|
|
8
|
+
* tamper-evident document hash, and the evidence-seal parser.
|
|
9
|
+
*
|
|
10
|
+
* Split out of fn-routers/public-agreement-router.ts (853 lines) —
|
|
11
|
+
* byte-for-byte move except createHash → Bun.CryptoHasher
|
|
12
|
+
* (bun-native-crypto guard; identical digest output).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AgreementParty } from "@teamscala/document-signing/agreement-parties";
|
|
16
|
+
|
|
17
|
+
/** The signer's own origin, from the request headers (sign[-dev].scala.business),
|
|
18
|
+
* so Checkout returns them to the same site they signed on. */
|
|
19
|
+
export function originFrom(headers: Headers): string {
|
|
20
|
+
const host = headers.get("x-forwarded-host") ?? headers.get("host");
|
|
21
|
+
const proto = headers.get("x-forwarded-proto") ?? "https";
|
|
22
|
+
return host ? `${proto}://${host}` : "https://sign.scala.business";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The signer's real client IP at the moment of signing, for the audit trail.
|
|
26
|
+
* Behind Cloudflare/Railway the original IP is the FIRST hop of
|
|
27
|
+
* x-forwarded-for (the chain is client, proxy1, proxy2…); cf-connecting-ip and
|
|
28
|
+
* x-real-ip are the per-proxy fallbacks. Returns null if none are present. */
|
|
29
|
+
export function clientIp(headers: Headers): string | null {
|
|
30
|
+
const fwd = headers.get("x-forwarded-for");
|
|
31
|
+
const first = fwd?.split(",")[0]?.trim();
|
|
32
|
+
return (
|
|
33
|
+
first ||
|
|
34
|
+
headers.get("cf-connecting-ip") ||
|
|
35
|
+
headers.get("x-real-ip") ||
|
|
36
|
+
null
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Deterministic JSON (recursively sorted object keys) so a hash over the same
|
|
41
|
+
* logical content is reproducible regardless of property insertion order —
|
|
42
|
+
* required for tamper-verification (re-hash later, compare). */
|
|
43
|
+
function canonicalJson(value: unknown): string {
|
|
44
|
+
return JSON.stringify(value, (_key, val) =>
|
|
45
|
+
val && typeof val === "object" && !Array.isArray(val)
|
|
46
|
+
? Object.fromEntries(
|
|
47
|
+
Object.entries(val as Record<string, unknown>).sort(([a], [b]) =>
|
|
48
|
+
a.localeCompare(b),
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
: val,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Tamper-evident seal: SHA-256 over the executed document's content + the
|
|
56
|
+
* full signed roster. Storing this at completion lets the verification surface
|
|
57
|
+
* re-hash the stored document later and prove nothing changed after signing
|
|
58
|
+
* (the marketplace "tamper-evident" bar, short of a qualified-CA PAdES seal). */
|
|
59
|
+
export function computeDocumentHash(
|
|
60
|
+
documentContent: unknown,
|
|
61
|
+
parties: AgreementParty[],
|
|
62
|
+
): string {
|
|
63
|
+
return new Bun.CryptoHasher("sha256")
|
|
64
|
+
.update(
|
|
65
|
+
canonicalJson({
|
|
66
|
+
document: documentContent ?? null,
|
|
67
|
+
signatures: parties.map((p) => ({
|
|
68
|
+
key: p.key,
|
|
69
|
+
email: p.email,
|
|
70
|
+
name: p.name,
|
|
71
|
+
title: p.title,
|
|
72
|
+
company: p.company,
|
|
73
|
+
signature: p.signature,
|
|
74
|
+
signedAt: p.signedAt,
|
|
75
|
+
})),
|
|
76
|
+
}),
|
|
77
|
+
"utf8",
|
|
78
|
+
)
|
|
79
|
+
.digest("hex");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Parse the optional evidence seal (document hash) from signature_data, which
|
|
83
|
+
* has the shape { parties, evidence? }. Returns null when no seal is present. */
|
|
84
|
+
export function parseEvidence(sigData: unknown): {
|
|
85
|
+
documentHash?: string;
|
|
86
|
+
hashAlgo?: string;
|
|
87
|
+
hashedAt?: string;
|
|
88
|
+
} | null {
|
|
89
|
+
if (typeof sigData !== "string") return null;
|
|
90
|
+
const t = sigData.trim();
|
|
91
|
+
if (!t.startsWith("{")) return null;
|
|
92
|
+
try {
|
|
93
|
+
const o = JSON.parse(t) as { evidence?: Record<string, unknown> };
|
|
94
|
+
const e = o.evidence;
|
|
95
|
+
if (!e || typeof e !== "object") return null;
|
|
96
|
+
return {
|
|
97
|
+
documentHash:
|
|
98
|
+
typeof e.documentHash === "string" ? e.documentHash : undefined,
|
|
99
|
+
hashAlgo: typeof e.hashAlgo === "string" ? e.hashAlgo : undefined,
|
|
100
|
+
hashedAt: typeof e.hashedAt === "string" ? e.hashedAt : undefined,
|
|
101
|
+
};
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
3
|
+
* @status handwritten
|
|
4
4
|
* @edit edit directly
|
|
5
5
|
*
|
|
6
6
|
* Public ORPC procedures for DocuSign-style agreement signing, reached by the
|
|
@@ -20,488 +20,39 @@
|
|
|
20
20
|
* with no schema change. Each party is gated by its own email; the agreement
|
|
21
21
|
* completes (signed_at set) only when every party has signed. A single-signer
|
|
22
22
|
* agreement falls back to the original signer_email/signer_name columns.
|
|
23
|
+
*
|
|
24
|
+
* This module holds the three procedures (verify/sign/verifyDocument). The
|
|
25
|
+
* supporting machinery lives as siblings: agreement-signing-evidence.ts
|
|
26
|
+
* (origin/IP extraction, tamper-evident hash, evidence parser),
|
|
27
|
+
* agreement-certificate.ts (Certificate of Completion), and
|
|
28
|
+
* agreement-sign-side-effects.ts (invoice, demo-reset, CRM connect) — split
|
|
29
|
+
* when the parent held them all at 853 lines.
|
|
23
30
|
*/
|
|
24
31
|
|
|
25
|
-
import { createHash } from "node:crypto";
|
|
26
32
|
import { os } from "@orpc/server";
|
|
27
33
|
import * as v from "valibot";
|
|
28
34
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
29
35
|
import { queryRows } from "@teamscala/db/query-rows";
|
|
30
36
|
import { signAgreementAccessToken } from "@teamscala/document-signing/agreement-access-token";
|
|
31
37
|
import {
|
|
32
|
-
type AgreementParty,
|
|
33
38
|
parseAgreementParties as parseParties,
|
|
34
39
|
toPublicParties as partySummary,
|
|
35
40
|
} from "@teamscala/document-signing/agreement-parties";
|
|
36
41
|
import { sendAgreementCompletionEmails } from "./agreement-completion-email.ts";
|
|
37
|
-
import {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
baseUrl: string,
|
|
49
|
-
): Promise<string | null> {
|
|
50
|
-
const log = getAppLogger();
|
|
51
|
-
const paymentsUrl = (process.env.SCALA_PAYMENTS_URL ?? "").replace(/\/+$/, "");
|
|
52
|
-
if (!paymentsUrl) {
|
|
53
|
-
log.warn(
|
|
54
|
-
`[publicAgreement] agreement ${agreementId} signed but SCALA_PAYMENTS_URL is unset — no invoice created. Set the env var on scala-sign's service_env row to a pay-internal tunnel URL.`,
|
|
55
|
-
);
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
try {
|
|
59
|
-
const res = await fetch(`${paymentsUrl}/api/payments/checkout`, {
|
|
60
|
-
method: "POST",
|
|
61
|
-
headers: { "Content-Type": "application/json" },
|
|
62
|
-
body: JSON.stringify({ agreementId, baseUrl }),
|
|
63
|
-
});
|
|
64
|
-
if (!res.ok) {
|
|
65
|
-
log.warn(
|
|
66
|
-
`[publicAgreement] payments checkout returned ${res.status} for agreement ${agreementId} (${paymentsUrl}/api/payments/checkout)`,
|
|
67
|
-
);
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
const json = (await res.json()) as { ok?: boolean; hostedInvoiceUrl?: string };
|
|
71
|
-
if (!json.ok) {
|
|
72
|
-
log.warn(
|
|
73
|
-
`[publicAgreement] payments checkout ok=false for agreement ${agreementId}: ${JSON.stringify(json).slice(0, 200)}`,
|
|
74
|
-
);
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
return json.hostedInvoiceUrl ?? null;
|
|
78
|
-
} catch (err) {
|
|
79
|
-
log.warn(
|
|
80
|
-
`[publicAgreement] payments checkout threw for agreement ${agreementId} (${paymentsUrl}): ${err instanceof Error ? err.message : String(err)}`,
|
|
81
|
-
);
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** The signer's own origin, from the request headers (sign[-dev].scala.business),
|
|
87
|
-
* so Checkout returns them to the same site they signed on. */
|
|
88
|
-
function originFrom(headers: Headers): string {
|
|
89
|
-
const host = headers.get("x-forwarded-host") ?? headers.get("host");
|
|
90
|
-
const proto = headers.get("x-forwarded-proto") ?? "https";
|
|
91
|
-
return host ? `${proto}://${host}` : "https://sign.scala.business";
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** The signer's real client IP at the moment of signing, for the audit trail.
|
|
95
|
-
* Behind Cloudflare/Railway the original IP is the FIRST hop of
|
|
96
|
-
* x-forwarded-for (the chain is client, proxy1, proxy2…); cf-connecting-ip and
|
|
97
|
-
* x-real-ip are the per-proxy fallbacks. Returns null if none are present. */
|
|
98
|
-
function clientIp(headers: Headers): string | null {
|
|
99
|
-
const fwd = headers.get("x-forwarded-for");
|
|
100
|
-
const first = fwd?.split(",")[0]?.trim();
|
|
101
|
-
return (
|
|
102
|
-
first ||
|
|
103
|
-
headers.get("cf-connecting-ip") ||
|
|
104
|
-
headers.get("x-real-ip") ||
|
|
105
|
-
null
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Deterministic JSON (recursively sorted object keys) so a hash over the same
|
|
110
|
-
* logical content is reproducible regardless of property insertion order —
|
|
111
|
-
* required for tamper-verification (re-hash later, compare). */
|
|
112
|
-
function canonicalJson(value: unknown): string {
|
|
113
|
-
return JSON.stringify(value, (_key, val) =>
|
|
114
|
-
val && typeof val === "object" && !Array.isArray(val)
|
|
115
|
-
? Object.fromEntries(
|
|
116
|
-
Object.entries(val as Record<string, unknown>).sort(([a], [b]) =>
|
|
117
|
-
a.localeCompare(b),
|
|
118
|
-
),
|
|
119
|
-
)
|
|
120
|
-
: val,
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Tamper-evident seal: SHA-256 over the executed document's content + the
|
|
125
|
-
* full signed roster. Storing this at completion lets the verification surface
|
|
126
|
-
* re-hash the stored document later and prove nothing changed after signing
|
|
127
|
-
* (the marketplace "tamper-evident" bar, short of a qualified-CA PAdES seal). */
|
|
128
|
-
function computeDocumentHash(
|
|
129
|
-
documentContent: unknown,
|
|
130
|
-
parties: AgreementParty[],
|
|
131
|
-
): string {
|
|
132
|
-
return createHash("sha256")
|
|
133
|
-
.update(
|
|
134
|
-
canonicalJson({
|
|
135
|
-
document: documentContent ?? null,
|
|
136
|
-
signatures: parties.map((p) => ({
|
|
137
|
-
key: p.key,
|
|
138
|
-
email: p.email,
|
|
139
|
-
name: p.name,
|
|
140
|
-
title: p.title,
|
|
141
|
-
company: p.company,
|
|
142
|
-
signature: p.signature,
|
|
143
|
-
signedAt: p.signedAt,
|
|
144
|
-
})),
|
|
145
|
-
}),
|
|
146
|
-
"utf8",
|
|
147
|
-
)
|
|
148
|
-
.digest("hex");
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Parse the optional evidence seal (document hash) from signature_data, which
|
|
152
|
-
* has the shape { parties, evidence? }. Returns null when no seal is present. */
|
|
153
|
-
function parseEvidence(sigData: unknown): {
|
|
154
|
-
documentHash?: string;
|
|
155
|
-
hashAlgo?: string;
|
|
156
|
-
hashedAt?: string;
|
|
157
|
-
} | null {
|
|
158
|
-
if (typeof sigData !== "string") return null;
|
|
159
|
-
const t = sigData.trim();
|
|
160
|
-
if (!t.startsWith("{")) return null;
|
|
161
|
-
try {
|
|
162
|
-
const o = JSON.parse(t) as { evidence?: Record<string, unknown> };
|
|
163
|
-
const e = o.evidence;
|
|
164
|
-
if (!e || typeof e !== "object") return null;
|
|
165
|
-
return {
|
|
166
|
-
documentHash:
|
|
167
|
-
typeof e.documentHash === "string" ? e.documentHash : undefined,
|
|
168
|
-
hashAlgo: typeof e.hashAlgo === "string" ? e.hashAlgo : undefined,
|
|
169
|
-
hashedAt: typeof e.hashedAt === "string" ? e.hashedAt : undefined,
|
|
170
|
-
};
|
|
171
|
-
} catch {
|
|
172
|
-
return null;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/** The Certificate of Completion display copy. DB-driven via the
|
|
177
|
-
* `config/certificate-of-completion` registry row — mirrors the agreement gate's
|
|
178
|
-
* `config/content-gate-agreement` pattern (same signing surface), so an operator
|
|
179
|
-
* can rebrand/reword the certificate via `registry_edit` with no deploy; the
|
|
180
|
-
* hardcoded defaults are the fallback. Generic structural data-column labels
|
|
181
|
-
* (Signer/Role/Envelope ID/…) stay in the component, exactly as the gate keeps
|
|
182
|
-
* its structural markup in code. */
|
|
183
|
-
export interface CertificateCopy {
|
|
184
|
-
title: string;
|
|
185
|
-
signersHeading: string;
|
|
186
|
-
sealHeading: string;
|
|
187
|
-
sealNote: string;
|
|
188
|
-
footer: string;
|
|
189
|
-
badgeCompleted: string;
|
|
190
|
-
badgeInProgress: string;
|
|
191
|
-
/** Audit-table column labels — DB-driven so rewording is a registry_edit.
|
|
192
|
-
* Each labels BOTH the <th> header AND the stacked-row data-label on mobile
|
|
193
|
-
* (one source, kept in sync), never a second hardcoded copy. */
|
|
194
|
-
colSigner: string;
|
|
195
|
-
colRole: string;
|
|
196
|
-
colSigned: string;
|
|
197
|
-
colIp: string;
|
|
198
|
-
colAuth: string;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const CERTIFICATE_COPY_DEFAULTS: CertificateCopy = {
|
|
202
|
-
title: "CERTIFICATE OF COMPLETION",
|
|
203
|
-
signersHeading: "Signers",
|
|
204
|
-
sealHeading: "Tamper-evident seal",
|
|
205
|
-
sealNote:
|
|
206
|
-
"{{hashAlgo}} hash of the executed document + signed roster, sealed {{hashedAt}}. Re-hashing the stored document and comparing to this value proves it has not been altered since signing.",
|
|
207
|
-
footer:
|
|
208
|
-
"This certificate is a record of the electronic signing of the above agreement, produced from the platform's permanent audit trail. Electronic signatures are legally recognised under the ESIGN Act and UETA (US) and eIDAS (EU).",
|
|
209
|
-
badgeCompleted: "Completed",
|
|
210
|
-
badgeInProgress: "In progress",
|
|
211
|
-
colSigner: "Signer",
|
|
212
|
-
colRole: "Role",
|
|
213
|
-
colSigned: "Signed (UTC)",
|
|
214
|
-
colIp: "IP address",
|
|
215
|
-
colAuth: "Auth",
|
|
216
|
-
};
|
|
217
|
-
|
|
218
|
-
const CertificateCopySchema = v.object({
|
|
219
|
-
title: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.title),
|
|
220
|
-
signersHeading: v.optional(
|
|
221
|
-
v.string(),
|
|
222
|
-
CERTIFICATE_COPY_DEFAULTS.signersHeading,
|
|
223
|
-
),
|
|
224
|
-
sealHeading: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.sealHeading),
|
|
225
|
-
sealNote: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.sealNote),
|
|
226
|
-
footer: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.footer),
|
|
227
|
-
badgeCompleted: v.optional(
|
|
228
|
-
v.string(),
|
|
229
|
-
CERTIFICATE_COPY_DEFAULTS.badgeCompleted,
|
|
230
|
-
),
|
|
231
|
-
badgeInProgress: v.optional(
|
|
232
|
-
v.string(),
|
|
233
|
-
CERTIFICATE_COPY_DEFAULTS.badgeInProgress,
|
|
234
|
-
),
|
|
235
|
-
colSigner: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.colSigner),
|
|
236
|
-
colRole: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.colRole),
|
|
237
|
-
colSigned: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.colSigned),
|
|
238
|
-
colIp: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.colIp),
|
|
239
|
-
colAuth: v.optional(v.string(), CERTIFICATE_COPY_DEFAULTS.colAuth),
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
async function loadCertificateCopy(): Promise<CertificateCopy | null> {
|
|
243
|
-
try {
|
|
244
|
-
return await loadRegistryConfig(
|
|
245
|
-
"config",
|
|
246
|
-
"certificate-of-completion",
|
|
247
|
-
CertificateCopySchema,
|
|
248
|
-
);
|
|
249
|
-
} catch {
|
|
250
|
-
// Absent signal, not a masking fallback (protective-layers-fail-explicitly):
|
|
251
|
-
// null propagates to CertificateData.copy, and the component renders its
|
|
252
|
-
// FALLBACK_COPY — same default values, but the absence is honest rather
|
|
253
|
-
// than disguised as a successful config load.
|
|
254
|
-
return null;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/** The Certificate of Completion data shape — the court-style audit certificate.
|
|
259
|
-
* One home: {@link buildCertificateData} is consumed by the SSR agreement
|
|
260
|
-
* gate, so the signing block receives the server-seeded prop. */
|
|
261
|
-
export interface CertificateData {
|
|
262
|
-
ok: boolean;
|
|
263
|
-
envelopeId?: string;
|
|
264
|
-
documentName?: string | null;
|
|
265
|
-
issuedAt?: string;
|
|
266
|
-
complete?: boolean;
|
|
267
|
-
createdAt?: string | null;
|
|
268
|
-
sentAt?: string | null;
|
|
269
|
-
viewedAt?: string | null;
|
|
270
|
-
signedAt?: string | null;
|
|
271
|
-
documentHash?: string | null;
|
|
272
|
-
hashAlgo?: string | null;
|
|
273
|
-
hashedAt?: string | null;
|
|
274
|
-
parties?: {
|
|
275
|
-
role: string;
|
|
276
|
-
name: string | null;
|
|
277
|
-
email: string | null;
|
|
278
|
-
title: string | null;
|
|
279
|
-
company: string | null;
|
|
280
|
-
signedAt: string | null;
|
|
281
|
-
ip: string | null;
|
|
282
|
-
userAgent: string | null;
|
|
283
|
-
authMethod: string | null;
|
|
284
|
-
}[];
|
|
285
|
-
/** DB-driven display copy (config/certificate-of-completion); null when the
|
|
286
|
-
* config row is missing/broken — the component falls back to FALLBACK_COPY. */
|
|
287
|
-
copy?: CertificateCopy | null;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Build the Certificate of Completion data for an agreement, gated to a party
|
|
292
|
-
* email — signer evidence (IP/device) is never exposed to a non-party. One data
|
|
293
|
-
* home: the SSR agreement gate (`buildSigningSession` in agreement-gate.ts)
|
|
294
|
-
* calls this. The read is
|
|
295
|
-
* intentionally RLS-unscoped via the raw registry prisma — the certificate is
|
|
296
|
-
* reached without a session (AG cookie only), which context.prisma's RLS would
|
|
297
|
-
* otherwise treat as no-org and hide. Accepts either the agreement record id or
|
|
298
|
-
* its page id (the UUID the signer sees in the URL is the page id).
|
|
299
|
-
*/
|
|
300
|
-
export async function buildCertificateData(
|
|
301
|
-
agreementId: string,
|
|
302
|
-
email: string,
|
|
303
|
-
): Promise<CertificateData> {
|
|
304
|
-
const id = agreementId.trim();
|
|
305
|
-
const mail = email.trim().toLowerCase();
|
|
306
|
-
if (!id || !mail) return { ok: false };
|
|
307
|
-
const [agreement] = await queryRows<
|
|
308
|
-
{
|
|
309
|
-
id: string;
|
|
310
|
-
name: string;
|
|
311
|
-
created_at: Date | null;
|
|
312
|
-
sent_at: Date | null;
|
|
313
|
-
viewed_at: Date | null;
|
|
314
|
-
signed_at: Date | null;
|
|
315
|
-
signature_data: unknown;
|
|
316
|
-
signer_email: string | null;
|
|
317
|
-
signer_name: string | null;
|
|
318
|
-
}[]
|
|
319
|
-
>`
|
|
320
|
-
SELECT id, name, created_at, sent_at, viewed_at, signed_at,
|
|
321
|
-
signature_data, signer_email, signer_name
|
|
322
|
-
FROM agreements
|
|
323
|
-
WHERE (id = ${id} OR page_id = ${id})
|
|
324
|
-
LIMIT 1`;
|
|
325
|
-
if (!agreement) return { ok: false };
|
|
326
|
-
const parties = parseParties(agreement.signature_data);
|
|
327
|
-
const evidence = parseEvidence(agreement.signature_data);
|
|
328
|
-
const isParty = parties
|
|
329
|
-
? parties.some((p) => p.email.trim().toLowerCase() === mail)
|
|
330
|
-
: String(agreement.signer_email ?? "")
|
|
331
|
-
.trim()
|
|
332
|
-
.toLowerCase() === mail;
|
|
333
|
-
if (!isParty) return { ok: false };
|
|
334
|
-
const complete =
|
|
335
|
-
!!agreement.signed_at || (parties?.every((p) => p.signedAt) ?? false);
|
|
336
|
-
const iso = (d: Date | null | undefined) => d?.toISOString() ?? null;
|
|
337
|
-
const copy = await loadCertificateCopy();
|
|
338
|
-
return {
|
|
339
|
-
ok: true,
|
|
340
|
-
copy,
|
|
341
|
-
envelopeId: agreement.id,
|
|
342
|
-
documentName: agreement.name,
|
|
343
|
-
issuedAt: new Date().toISOString(),
|
|
344
|
-
complete,
|
|
345
|
-
createdAt: iso(agreement.created_at),
|
|
346
|
-
sentAt: iso(agreement.sent_at),
|
|
347
|
-
viewedAt: iso(agreement.viewed_at),
|
|
348
|
-
signedAt: iso(agreement.signed_at),
|
|
349
|
-
documentHash: evidence?.documentHash ?? null,
|
|
350
|
-
hashAlgo: evidence?.hashAlgo ?? null,
|
|
351
|
-
hashedAt: evidence?.hashedAt ?? null,
|
|
352
|
-
parties: parties
|
|
353
|
-
? parties.map((p) => ({
|
|
354
|
-
role: p.key,
|
|
355
|
-
name: p.name,
|
|
356
|
-
email: p.email,
|
|
357
|
-
title: p.title,
|
|
358
|
-
company: p.company,
|
|
359
|
-
signedAt: p.signedAt,
|
|
360
|
-
ip: p.signedIp ?? null,
|
|
361
|
-
userAgent: p.signedUserAgent ?? null,
|
|
362
|
-
authMethod: p.authMethod ?? null,
|
|
363
|
-
}))
|
|
364
|
-
: [
|
|
365
|
-
{
|
|
366
|
-
role: "signer",
|
|
367
|
-
name: agreement.signer_name,
|
|
368
|
-
email: agreement.signer_email,
|
|
369
|
-
title: null,
|
|
370
|
-
company: null,
|
|
371
|
-
signedAt: iso(agreement.signed_at),
|
|
372
|
-
ip: null,
|
|
373
|
-
userAgent: null,
|
|
374
|
-
authMethod: "email-match",
|
|
375
|
-
},
|
|
376
|
-
],
|
|
377
|
-
};
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Self-resetting demo harness. If the signed agreement's `proposal_id` is in
|
|
382
|
-
* the `demo-reset-agreements.proposalIds` allow-list, reset its parties to
|
|
383
|
-
* UNSIGNED (signedAt/signature cleared) + clear signed_at/signed_by + status
|
|
384
|
-
* 'sent', so the next person gets a fresh form to test the UI.
|
|
385
|
-
*
|
|
386
|
-
* Keyed on the stable PROPOSAL id, NOT the agreement id: the demo agreement is
|
|
387
|
-
* regenerated under a fresh id whenever the demo proposal is re-accepted, but
|
|
388
|
-
* its `proposal_id` FK is constant — so the allow-list cannot dangle. This
|
|
389
|
-
* deletes the drift class (prevention, not a patrol): the prior design keyed on
|
|
390
|
-
* `agreementIds`, and when the demo was regenerated the list silently pointed
|
|
391
|
-
* at a dead id and the reset became a no-op for weeks. A `proposal_id`-
|
|
392
|
-
* referenced row that doesn't exist is a real proposal being deleted, not a
|
|
393
|
-
* regeneration — a negligible residual the operator would notice directly.
|
|
394
|
-
*
|
|
395
|
-
* Gated strictly by the allow-list — a real agreement is never reset.
|
|
396
|
-
* Best-effort: any failure is swallowed (a demo-reset must never fail the
|
|
397
|
-
* signer's own response). The current signer already has their response (incl.
|
|
398
|
-
* payUrl) before this runs; only the stored record is reset.
|
|
399
|
-
*/
|
|
400
|
-
async function maybeResetDemoAgreement(
|
|
401
|
-
prisma: any,
|
|
402
|
-
agreementId: string,
|
|
403
|
-
proposalId: string | null,
|
|
404
|
-
parties: AgreementParty[],
|
|
405
|
-
): Promise<void> {
|
|
406
|
-
if (!proposalId) return;
|
|
407
|
-
try {
|
|
408
|
-
const rows = await queryRows`
|
|
409
|
-
SELECT config FROM registry_entries
|
|
410
|
-
WHERE type = 'config' AND slug = 'demo-reset-agreements' AND is_active = true
|
|
411
|
-
LIMIT 1` as { config: { proposalIds?: string[] } }[];
|
|
412
|
-
const ids = rows?.[0]?.config?.proposalIds ?? [];
|
|
413
|
-
if (!Array.isArray(ids) || !ids.includes(proposalId)) return;
|
|
414
|
-
const reset = parties.map((p) => ({
|
|
415
|
-
...p,
|
|
416
|
-
signedAt: null,
|
|
417
|
-
signature: null,
|
|
418
|
-
title: null,
|
|
419
|
-
company: null,
|
|
420
|
-
}));
|
|
421
|
-
await prisma.agreements.update({
|
|
422
|
-
where: { id: agreementId },
|
|
423
|
-
data: {
|
|
424
|
-
signature_data: JSON.stringify({ parties: reset }),
|
|
425
|
-
signed_at: null,
|
|
426
|
-
signed_by: null,
|
|
427
|
-
status: "sent",
|
|
428
|
-
},
|
|
429
|
-
});
|
|
430
|
-
getAppLogger().info(`[publicAgreement.sign] demo agreement ${agreementId} auto-reset for re-testing`);
|
|
431
|
-
} catch {
|
|
432
|
-
// never fail the signer's response over a demo-reset
|
|
433
|
-
}
|
|
434
|
-
}
|
|
42
|
+
import {
|
|
43
|
+
clientIp,
|
|
44
|
+
computeDocumentHash,
|
|
45
|
+
originFrom,
|
|
46
|
+
parseEvidence,
|
|
47
|
+
} from "./agreement-signing-evidence.ts";
|
|
48
|
+
import {
|
|
49
|
+
connectSignerToCrm,
|
|
50
|
+
invoicePayUrl,
|
|
51
|
+
maybeResetDemoAgreement,
|
|
52
|
+
} from "./agreement-sign-side-effects.ts";
|
|
435
53
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
* client_database, the same find-or-create capture path proposals / public-GPT /
|
|
439
|
-
* assessments already use (scoped by organisation_id + primary_email). Also
|
|
440
|
-
* links agreement.contact_id when the agreement wasn't generated from a proposal
|
|
441
|
-
* (contact_id NULL), so a standalone signed doc still connects to the CRM.
|
|
442
|
-
* Best-effort: a CRM write must never fail the signature (the signature is the
|
|
443
|
-
* legal act; CRM connection is bookkeeping).
|
|
444
|
-
*/
|
|
445
|
-
async function connectSignerToCrm(
|
|
446
|
-
prisma: any,
|
|
447
|
-
agreement: { id: string; organisation_id?: string | null; contact_id?: string | null },
|
|
448
|
-
email: string,
|
|
449
|
-
signerName?: string | null,
|
|
450
|
-
company?: string | null,
|
|
451
|
-
): Promise<void> {
|
|
452
|
-
const orgId = agreement.organisation_id;
|
|
453
|
-
if (!orgId || !email || !prisma?.client_database) return;
|
|
454
|
-
try {
|
|
455
|
-
const parts = (signerName ?? "").trim().split(/\s+/).filter(Boolean);
|
|
456
|
-
const firstName = parts[0] ?? "";
|
|
457
|
-
const lastName = parts.slice(1).join(" ");
|
|
458
|
-
const existing = await prisma.client_database.findFirst({
|
|
459
|
-
where: { organisation_id: orgId, primary_email: email },
|
|
460
|
-
select: { id: true },
|
|
461
|
-
});
|
|
462
|
-
let contactId: string | null = null;
|
|
463
|
-
if (existing) {
|
|
464
|
-
contactId = existing.id as string;
|
|
465
|
-
await prisma.client_database.update({
|
|
466
|
-
where: { id: existing.id },
|
|
467
|
-
data: {
|
|
468
|
-
...(firstName ? { first_name: firstName } : {}),
|
|
469
|
-
...(lastName ? { last_name: lastName } : {}),
|
|
470
|
-
...(company ? { company_name: company } : {}),
|
|
471
|
-
last_updated_by: "agreement-sign",
|
|
472
|
-
},
|
|
473
|
-
});
|
|
474
|
-
} else {
|
|
475
|
-
const created = await prisma.client_database.create({
|
|
476
|
-
data: {
|
|
477
|
-
organisation_id: orgId,
|
|
478
|
-
first_name: firstName,
|
|
479
|
-
last_name: lastName,
|
|
480
|
-
primary_email: email,
|
|
481
|
-
...(company ? { company_name: company } : {}),
|
|
482
|
-
lead_source: "agreement-sign",
|
|
483
|
-
data_source: "public-form",
|
|
484
|
-
referral_source: agreement.id,
|
|
485
|
-
lifecycle_stage: "lead",
|
|
486
|
-
created_by: "agreement-sign",
|
|
487
|
-
last_updated_by: "agreement-sign",
|
|
488
|
-
},
|
|
489
|
-
select: { id: true },
|
|
490
|
-
});
|
|
491
|
-
contactId = (created?.id as string) ?? null;
|
|
492
|
-
}
|
|
493
|
-
if (contactId && !agreement.contact_id) {
|
|
494
|
-
await prisma.agreements.update({
|
|
495
|
-
where: { id: agreement.id },
|
|
496
|
-
data: { contact_id: contactId },
|
|
497
|
-
});
|
|
498
|
-
}
|
|
499
|
-
} catch (e) {
|
|
500
|
-
getAppLogger().warn(
|
|
501
|
-
`[publicAgreement.sign] CRM connect failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`,
|
|
502
|
-
);
|
|
503
|
-
}
|
|
504
|
-
}
|
|
54
|
+
export { buildCertificateData } from "./agreement-certificate.ts";
|
|
55
|
+
export type { CertificateData, CertificateCopy } from "./agreement-certificate.ts";
|
|
505
56
|
|
|
506
57
|
interface Context {
|
|
507
58
|
prisma: any;
|