@teamscala/service-boot 0.23.479 → 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.
Files changed (46) hide show
  1. package/package.json +1 -261
  2. package/src/agreement-gate/signing-session.ts +1 -1
  3. package/src/build-client-assets/styles-build.ts +11 -1
  4. package/src/mount-orpc-router.ts +1 -1
  5. package/src/register-page-server-client.ts +1 -15
  6. package/src/register-registry-page-route/region-slots-page-data.ts +2 -2
  7. package/src/register-ws-routes.ts +1 -1
  8. package/src/fn-routers/agreement-certificate.ts +0 -224
  9. package/src/fn-routers/agreement-completion-email.ts +0 -114
  10. package/src/fn-routers/agreement-send-router.ts +0 -303
  11. package/src/fn-routers/agreement-sign-side-effects.ts +0 -192
  12. package/src/fn-routers/agreement-signing-evidence.ts +0 -105
  13. package/src/fn-routers/assessment-report-data.test.ts +0 -404
  14. package/src/fn-routers/assessment-report-data.ts +0 -485
  15. package/src/fn-routers/assessment-report-email.ts +0 -256
  16. package/src/fn-routers/assessment-report-pair.test.ts +0 -107
  17. package/src/fn-routers/assessment-report-pair.ts +0 -155
  18. package/src/fn-routers/assessment-research-report-data.test.ts +0 -262
  19. package/src/fn-routers/assessment-research-report-data.ts +0 -424
  20. package/src/fn-routers/assessment-scoring.ts +0 -105
  21. package/src/fn-routers/auth-router.ts +0 -65
  22. package/src/fn-routers/chat-session-router-shared.ts +0 -122
  23. package/src/fn-routers/chat-session-router.ts +0 -356
  24. package/src/fn-routers/conversation-ownership.ts +0 -24
  25. package/src/fn-routers/email-defaults.ts +0 -28
  26. package/src/fn-routers/email-link-ttl.ts +0 -28
  27. package/src/fn-routers/index.ts +0 -76
  28. package/src/fn-routers/linkedin-identity.test.ts +0 -27
  29. package/src/fn-routers/linkedin-identity.ts +0 -36
  30. package/src/fn-routers/messaging-whatsapp-router.test.ts +0 -121
  31. package/src/fn-routers/messaging-whatsapp-router.ts +0 -200
  32. package/src/fn-routers/panel-data-router.ts +0 -104
  33. package/src/fn-routers/persist-chat-messages.test.ts +0 -86
  34. package/src/fn-routers/persist-chat-messages.ts +0 -98
  35. package/src/fn-routers/proposal-send-router.ts +0 -207
  36. package/src/fn-routers/public-agreement-router.ts +0 -407
  37. package/src/fn-routers/public-assessment-router.test.ts +0 -437
  38. package/src/fn-routers/public-assessment-router.ts +0 -268
  39. package/src/fn-routers/public-gpt-session-router.ts +0 -378
  40. package/src/fn-routers/resend-client.ts +0 -39
  41. package/src/fn-routers/sign-in-flow-router.ts +0 -196
  42. package/src/fn-routers/user-preferences-router.ts +0 -130
  43. package/src/fn-routers/work-items-router.ts +0 -342
  44. package/src/page-server-orpc/audit-revert-router.ts +0 -102
  45. package/src/page-server-orpc/puck-pages-router.ts +0 -170
  46. package/src/page-server-orpc/user-preferences-router.ts +0 -132
@@ -1,114 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Completion delivery. When an agreement is fully EXECUTED (every party has
7
- * signed), email each party their executed copy + a note to download the
8
- * Certificate of Completion. Each link is a per-party magic-link (no email gate,
9
- * no OTP — see attach-agreement-magic.ts). Best-effort and self-contained: it
10
- * loads its own data and swallows every error, so a send failure is logged and
11
- * NEVER undoes the signature (the payment-setup non-fatal rule, applied to mail).
12
- * Fire-and-forget from the sign handler: `void sendAgreementCompletionEmails(...)`.
13
- */
14
-
15
- import * as v from "valibot";
16
- import { getServerClient } from "@teamscala/orpc/server-client-registry";
17
- import type { PrismaClient } from "@teamscala/db/client";
18
- import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
19
- import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
20
- import { sendResendEmail } from "./resend-client.ts";
21
- import { applyTemplateVars, lookupTemplate } from "@teamscala/email-template/lookup";
22
- import { getAppLogger } from "@teamscala/logger/app-loggers";
23
- import { signAgreementAccessToken } from "@teamscala/document-signing/agreement-access-token";
24
- import { parseAgreementParties } from "@teamscala/document-signing/agreement-parties";
25
- import { getDefaultEmailSender } from "./email-defaults.ts";
26
- import { getEmailLinkTtlMs } from "./email-link-ttl.ts";
27
-
28
- // Emailed links are opened hours/days later — they must outlive the 30-min
29
- // in-session token, or the recipient lands on the unauthed gate ("white page").
30
-
31
-
32
- export async function sendAgreementCompletionEmails(
33
- agreementId: string,
34
- baseUrl: string,
35
- ): Promise<void> {
36
- const log = getAppLogger();
37
- try {
38
- const prisma = (await getServerClient()) as unknown as PrismaClient;
39
- const agreement = await prisma.agreements.findFirst({
40
- where: { id: agreementId },
41
- select: { name: true, page_id: true, signature_data: true },
42
- });
43
- if (!agreement) return;
44
- const parties = parseAgreementParties(agreement.signature_data) ?? [];
45
- if (parties.length === 0) return;
46
-
47
- const page = agreement.page_id
48
- ? await prisma.builda_pages.findFirst({
49
- where: { id: agreement.page_id },
50
- select: { path: true },
51
- })
52
- : null;
53
- if (!page?.path) return;
54
-
55
- const rawSecret = await loadRegistryConfig(
56
- "secret",
57
- "resend-api-key",
58
- v.looseObject({ token: v.optional(v.string()) }),
59
- ).catch(() => null);
60
- const decrypted = rawSecret
61
- ? (decryptSecretConfig(rawSecret as never) as { token?: string })
62
- : null;
63
- if (!decrypted?.token) {
64
- log.warn("[completion-email] Resend token unavailable — skipping");
65
- return;
66
- }
67
- const from = await getDefaultEmailSender();
68
-
69
- const base = baseUrl.replace(/\/+$/, "");
70
- log.info(
71
- `[completion-email] ${agreementId} executed — emailing ${parties.length} ${parties.length === 1 ? "party" : "parties"}`,
72
- );
73
- for (const p of parties) {
74
- if (!p.email) continue;
75
- const token = signAgreementAccessToken(
76
- agreementId,
77
- p.email,
78
- Date.now(),
79
- await getEmailLinkTtlMs(),
80
- );
81
- const url = token
82
- ? `${base}/api/agreement-magic?a=${encodeURIComponent(agreementId)}&t=${encodeURIComponent(token)}&to=${encodeURIComponent(page.path)}`
83
- : `${base}${page.path}`;
84
- // Subject + body from the `agreement-completion` email_template row, not
85
- // literals here (email-template-registry-type): copy changes are a
86
- // registry_edit, not a deploy, and an org can override the row.
87
- const tpl = applyTemplateVars(await lookupTemplate("agreement-completion"), {
88
- recipientName: p.name || "there",
89
- agreementName: agreement.name,
90
- url,
91
- });
92
- const res = await sendResendEmail(decrypted.token, {
93
- from,
94
- to: p.email,
95
- subject: tpl.subject,
96
- html: tpl.bodyHtml,
97
- text: tpl.bodyText,
98
- });
99
- if (res.success) {
100
- log.info(
101
- `[completion-email] sent to ${p.email}${res.id ? ` (id ${res.id})` : ""}`,
102
- );
103
- } else {
104
- log.error(
105
- `[completion-email] failed to ${p.email}: ${res.error ?? "unknown"}`,
106
- );
107
- }
108
- }
109
- } catch (e) {
110
- log.error(
111
- `[completion-email] non-fatal: ${e instanceof Error ? e.message : String(e)}`,
112
- );
113
- }
114
- }
@@ -1,303 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * fn.agreementSend.send — authenticated "send for signature" procedure. Emails
7
- * each party of an agreement a link to its signing page, stamps the lifecycle
8
- * (sent_to / sent_at, status draft→sent), and returns a per-recipient result.
9
- *
10
- * Off-the-shelf path per building-applications: this is server logic beyond
11
- * CRUD, so it is a typed ORPC procedure (not a bespoke endpoint), and it sends
12
- * through the shared Resend adapter (@teamscala/api-resend) — the same surface
13
- * @teamscala/auth uses for OTP — never a new email integration.
14
- *
15
- * Per-org sender: emails go FROM the org's display name on the platform's
16
- * verified Resend domain ("<Org> via Sign <verified-addr>"), with reply_to set
17
- * to the org's contact_email, so replies reach the org without each org needing
18
- * its own verified domain. When an org later verifies its own sending domain,
19
- * resolveSender is the single place to switch the FROM address.
20
- *
21
- * Sending is privileged (it reaches real inboxes), so the procedure requires an
22
- * authenticated caller — the localhost super-admin bypass authorises operator /
23
- * portal-SSR callers; the public signing page cannot invoke it.
24
- */
25
-
26
- import { os } from "@orpc/server";
27
- import * as v from "valibot";
28
- import {
29
- decryptSecretConfig,
30
- type SecretConfigLike,
31
- } from "@teamscala/encryption/crypto/secret-config";
32
- import { sendResendEmail } from "./resend-client.ts";
33
- import { applyTemplateVars, lookupTemplate } from "@teamscala/email-template/lookup";
34
- import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
35
- import type { PrismaClient } from "@teamscala/db/client";
36
- import { getAppLogger } from "@teamscala/logger/app-loggers";
37
- import { signAgreementAccessToken } from "@teamscala/document-signing/agreement-access-token";
38
- import { resetRlsContext, setRlsContext } from "@teamscala/db/rls-context";
39
- import { getDefaultEmailSender } from "./email-defaults.ts";
40
- import { getEmailLinkTtlMs } from "./email-link-ttl.ts";
41
-
42
- // Emailed links must outlive the 30-min in-session token (opened hours/days
43
- // later), else the recipient lands on the unauthed gate ("white page").
44
-
45
- interface User {
46
- id: string;
47
- organisationId: string | null;
48
- }
49
-
50
- interface Context {
51
- user: User | null | undefined;
52
- headers: Headers;
53
- resolveUser?: (headers: Headers, prisma: PrismaClient) => Promise<User | null>;
54
- prisma: PrismaClient;
55
- }
56
-
57
- const authed = os.$context<Context>().use(async (opt) => {
58
- const { context } = opt;
59
- let user = context.user;
60
- if (!user && context.headers && context.resolveUser) {
61
- user = await context.resolveUser(context.headers, context.prisma);
62
- }
63
- if (!user) {
64
- const { ORPCError } = await import("@orpc/server");
65
- throw new ORPCError("UNAUTHORIZED", { message: "Authentication required" });
66
- }
67
- // Audit attribution: set the audit session context so writes (agreements.update)
68
- // record the actor (app.current_user_name) — see auth-router.ts for the rationale.
69
- const u = user as { id: string; name?: string | null; email?: string };
70
- await setRlsContext(context.prisma, {
71
- userId: u.id,
72
- userName: u.name || u.email,
73
- organisationId: user.organisationId,
74
- });
75
- try {
76
- return await opt.next({ context: { ...context, user } });
77
- } finally {
78
- await resetRlsContext(context.prisma);
79
- }
80
- });
81
-
82
- interface Recipient {
83
- email: string;
84
- name: string;
85
- }
86
-
87
- /** Parse the multi-party roster, or fall back to the single signer columns. */
88
- function recipientsFor(agreement: {
89
- signature_data: unknown;
90
- signer_email: unknown;
91
- signer_name: unknown;
92
- }): Recipient[] {
93
- const raw = agreement.signature_data;
94
- if (typeof raw === "string" && raw.trim().startsWith("{")) {
95
- try {
96
- const o = JSON.parse(raw) as { parties?: Array<Record<string, unknown>> };
97
- if (Array.isArray(o.parties)) {
98
- return o.parties
99
- .map((p) => ({
100
- email: String(p.email ?? "").trim(),
101
- name: String(p.name ?? "").trim(),
102
- }))
103
- .filter((r) => r.email.length > 0);
104
- }
105
- } catch {
106
- /* fall through to single-signer */
107
- }
108
- }
109
- const email = String(agreement.signer_email ?? "").trim();
110
- return email ? [{ email, name: String(agreement.signer_name ?? "").trim() }] : [];
111
- }
112
-
113
- /** Extract the bare address from a "Display <addr@domain>" sender string. */
114
- function bareAddress(sender: string): string {
115
- const m = sender.match(/<([^>]+)>/);
116
- return (m?.[1] ?? sender).trim();
117
- }
118
-
119
- /**
120
- * Resolve the per-org FROM + reply_to. FROM uses the org display name on the
121
- * platform's verified Resend address; reply_to is the org's contact email.
122
- */
123
- async function resolveSender(
124
- prisma: PrismaClient,
125
- organisationId: string,
126
- ): Promise<{ from: string; replyTo?: string; orgName: string }> {
127
- const templates = await loadRegistryConfig(
128
- "config",
129
- "email-templates",
130
- v.looseObject({ sender: v.optional(v.string()) }),
131
- ).catch(() => ({ sender: undefined }) as { sender?: string });
132
- const verifiedAddr = bareAddress(
133
- templates.sender ?? (await getDefaultEmailSender()),
134
- );
135
-
136
- const profile = await prisma.organisation_profile.findFirst({
137
- where: { id: organisationId },
138
- select: {
139
- company_friendly_name: true,
140
- company_name: true,
141
- contact_email: true,
142
- },
143
- });
144
- const orgName =
145
- (profile?.company_friendly_name ||
146
- profile?.company_name ||
147
- "") as string;
148
- const display = orgName ? `${orgName} via Sign` : "Sign";
149
- return {
150
- from: `${display} <${verifiedAddr}>`,
151
- replyTo: (profile?.contact_email as string) || undefined,
152
- orgName: orgName || "the sender",
153
- };
154
- }
155
-
156
-
157
- const sendSchema = v.object({
158
- agreementId: v.string(),
159
- /** Public base URL of the signing site, e.g. https://sign-dev.scala.business (no trailing slash). */
160
- baseUrl: v.string(),
161
- });
162
-
163
- export const agreementSendRouter = {
164
- send: authed.input(sendSchema).handler(async ({ input, context }) => {
165
- const log = getAppLogger();
166
- const prisma = context.prisma;
167
- const agreementId = input.agreementId.trim();
168
- const baseUrl = input.baseUrl.trim().replace(/\/+$/, "");
169
- if (!agreementId || !baseUrl) {
170
- return { ok: false as const, error: "agreementId and baseUrl are required" };
171
- }
172
-
173
- try {
174
- return await runSend(prisma, agreementId, baseUrl, log);
175
- } catch (err) {
176
- const message = err instanceof Error ? err.message : String(err);
177
- log.error(`[agreementSend] unexpected failure: ${message}`);
178
- return { ok: false as const, error: message };
179
- }
180
- }),
181
- };
182
-
183
- async function runSend(
184
- prisma: PrismaClient,
185
- agreementId: string,
186
- baseUrl: string,
187
- log: ReturnType<typeof getAppLogger>,
188
- ) {
189
- const agreement = await prisma.agreements.findFirst({
190
- where: { id: agreementId },
191
- select: {
192
- id: true,
193
- name: true,
194
- status: true,
195
- organisation_id: true,
196
- page_id: true,
197
- signer_email: true,
198
- signer_name: true,
199
- signature_data: true,
200
- },
201
- });
202
- if (!agreement) return { ok: false as const, error: "Agreement not found" };
203
-
204
- const page = agreement.page_id
205
- ? await prisma.builda_pages.findFirst({
206
- where: { id: agreement.page_id },
207
- select: { path: true },
208
- })
209
- : null;
210
- if (!page?.path) {
211
- return { ok: false as const, error: "Agreement has no signing page" };
212
- }
213
- const url = `${baseUrl}${page.path}`;
214
-
215
- const recipients = recipientsFor(agreement);
216
- if (recipients.length === 0) {
217
- return { ok: false as const, error: "Agreement has no signer emails" };
218
- }
219
-
220
- const sender = await resolveSender(prisma, agreement.organisation_id);
221
-
222
- // Initialise the Resend client from the encrypted secret row, read live
223
- // via the registry helper (registry-via-helper) then decrypted.
224
- const rawSecret = await loadRegistryConfig(
225
- "secret",
226
- "resend-api-key",
227
- v.looseObject({ token: v.optional(v.string()) }),
228
- ).catch(() => null);
229
- if (!rawSecret) {
230
- return { ok: false as const, error: "Resend API key not configured" };
231
- }
232
- const decrypted = decryptSecretConfig(rawSecret as SecretConfigLike) as { token?: string };
233
- if (!decrypted.token) {
234
- return { ok: false as const, error: "Resend API key missing token" };
235
- }
236
-
237
- const sent: Array<{ email: string; ok: boolean; id?: string; error?: string }> =
238
- [];
239
- for (const r of recipients) {
240
- // Magic-link: a per-party signed token so this recipient opens the
241
- // document directly — no email-entry gate, no OTP. The token is scoped
242
- // to {agreementId, this email}; /api/agreement-magic verifies it, sets
243
- // the ag_token cookie, and redirects into the page. Falls back to the
244
- // plain page URL when the token secret is unavailable.
245
- const magicToken = signAgreementAccessToken(
246
- agreementId,
247
- r.email,
248
- Date.now(),
249
- await getEmailLinkTtlMs(),
250
- );
251
- const recipientUrl = magicToken
252
- ? `${baseUrl}/api/agreement-magic?a=${encodeURIComponent(agreementId)}&t=${encodeURIComponent(magicToken)}&to=${encodeURIComponent(page.path)}`
253
- : url;
254
- // Subject + body come from the `agreement-signature-request`
255
- // email_template row, never from literals here
256
- // (email-template-registry-type): changing the copy is a registry_edit,
257
- // not a deploy, and an org can override the row per tenant. The `from`
258
- // stays computed — it is the verified sender identity, not copy.
259
- const tpl = applyTemplateVars(
260
- await lookupTemplate("agreement-signature-request"),
261
- {
262
- hello: r.name ? `Hi ${r.name},` : "Hello,",
263
- orgName: sender.orgName,
264
- agreementName: agreement.name,
265
- url: recipientUrl,
266
- },
267
- );
268
- const res = await sendResendEmail(decrypted.token, {
269
- from: sender.from,
270
- to: r.email,
271
- reply_to: sender.replyTo,
272
- subject: tpl.subject,
273
- html: tpl.bodyHtml,
274
- text: tpl.bodyText,
275
- });
276
- sent.push({
277
- email: r.email,
278
- ok: res.success,
279
- id: res.id,
280
- error: res.error,
281
- });
282
- if (!res.success) {
283
- log.error(
284
- `[agreementSend] failed to ${r.email}: ${res.error ?? "unknown"}`,
285
- );
286
- }
287
- }
288
-
289
- const anySent = sent.some((s) => s.ok);
290
- const now = new Date().toISOString();
291
- if (anySent) {
292
- await prisma.agreements.update({
293
- where: { id: agreementId },
294
- data: {
295
- sent_to: recipients.map((r) => r.email).join(", "),
296
- sent_at: now,
297
- ...(agreement.status === "draft" ? { status: "sent" } : {}),
298
- },
299
- });
300
- }
301
-
302
- return { ok: anySent, sentAt: anySent ? now : null, url, sent };
303
- }
@@ -1,192 +0,0 @@
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
- /**
15
- * Self-resetting demo harness. If the signed agreement's `proposal_id` is in
16
- * the `demo-reset-agreements.proposalIds` allow-list, reset its parties to
17
- * UNSIGNED (signedAt/signature cleared) + clear signed_at/signed_by + status
18
- * 'sent', so the next person gets a fresh form to test the UI.
19
- *
20
- * Keyed on the stable PROPOSAL id, NOT the agreement id: the demo agreement is
21
- * regenerated under a fresh id whenever the demo proposal is re-accepted, but
22
- * its `proposal_id` FK is constant — so the allow-list cannot dangle. This
23
- * deletes the drift class (prevention, not a patrol): the prior design keyed on
24
- * `agreementIds`, and when the demo was regenerated the list silently pointed
25
- * at a dead id and the reset became a no-op for weeks. A `proposal_id`-
26
- * referenced row that doesn't exist is a real proposal being deleted, not a
27
- * regeneration — a negligible residual the operator would notice directly.
28
- *
29
- * Gated strictly by the allow-list — a real agreement is never reset.
30
- * Best-effort: any failure is swallowed (a demo-reset must never fail the
31
- * signer's own response). The current signer already has their response (incl.
32
- * payUrl) before this runs; only the stored record is reset.
33
- */
34
-
35
- import { getAppLogger } from "@teamscala/logger/app-loggers";
36
- import { queryRows } from "@teamscala/db/query-rows";
37
- import type { PrismaClient } from "@teamscala/db/client";
38
- import type { AgreementParty } from "@teamscala/document-signing/agreement-parties";
39
-
40
- /** On full agreement completion, delegate to the payment service over HTTP to
41
- * create the Stripe invoice (per payment-is-one-service — all Stripe logic
42
- * lives in @teamscala/payment, reached via POST /api/payments/checkout). Never
43
- * throws — a payment-setup failure must not undo a completed signature; the
44
- * signer is simply not auto-redirected to pay. But every failure mode is
45
- * logged at WARN with the actual cause (URL missing, non-2xx, network, parse)
46
- * so the silent-fail class the user would otherwise diagnose by hand is
47
- * observable in GlitchTip + pm2 logs. */
48
- export async function invoicePayUrl(
49
- agreementId: string,
50
- baseUrl: string,
51
- ): Promise<string | null> {
52
- const log = getAppLogger();
53
- const paymentsUrl = (process.env.SCALA_PAYMENTS_URL ?? "").replace(/\/+$/, "");
54
- if (!paymentsUrl) {
55
- log.warn(
56
- `[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.`,
57
- );
58
- return null;
59
- }
60
- try {
61
- const res = await fetch(`${paymentsUrl}/api/payments/checkout`, {
62
- method: "POST",
63
- headers: { "Content-Type": "application/json" },
64
- body: JSON.stringify({ agreementId, baseUrl }),
65
- });
66
- if (!res.ok) {
67
- log.warn(
68
- `[publicAgreement] payments checkout returned ${res.status} for agreement ${agreementId} (${paymentsUrl}/api/payments/checkout)`,
69
- );
70
- return null;
71
- }
72
- const json = (await res.json()) as { ok?: boolean; hostedInvoiceUrl?: string };
73
- if (!json.ok) {
74
- log.warn(
75
- `[publicAgreement] payments checkout ok=false for agreement ${agreementId}: ${JSON.stringify(json).slice(0, 200)}`,
76
- );
77
- return null;
78
- }
79
- return json.hostedInvoiceUrl ?? null;
80
- } catch (err) {
81
- log.warn(
82
- `[publicAgreement] payments checkout threw for agreement ${agreementId} (${paymentsUrl}): ${err instanceof Error ? err.message : String(err)}`,
83
- );
84
- return null;
85
- }
86
- }
87
-
88
- export async function maybeResetDemoAgreement(
89
- prisma: PrismaClient,
90
- agreementId: string,
91
- proposalId: string | null,
92
- parties: AgreementParty[],
93
- ): Promise<void> {
94
- if (!proposalId) return;
95
- try {
96
- const rows = await queryRows`
97
- SELECT config FROM registry_entries
98
- WHERE type = 'config' AND slug = 'demo-reset-agreements' AND is_active = true
99
- LIMIT 1` as { config: { proposalIds?: string[] } }[];
100
- const ids = rows?.[0]?.config?.proposalIds ?? [];
101
- if (!Array.isArray(ids) || !ids.includes(proposalId)) return;
102
- const reset = parties.map((p) => ({
103
- ...p,
104
- signedAt: null,
105
- signature: null,
106
- title: null,
107
- company: null,
108
- }));
109
- await prisma.agreements.update({
110
- where: { id: agreementId },
111
- data: {
112
- signature_data: JSON.stringify({ parties: reset }),
113
- signed_at: null,
114
- signed_by: null,
115
- status: "sent",
116
- },
117
- });
118
- getAppLogger().info(`[publicAgreement.sign] demo agreement ${agreementId} auto-reset for re-testing`);
119
- } catch {
120
- // never fail the signer's response over a demo-reset
121
- }
122
- }
123
-
124
- /**
125
- * Connect a captured signer to the CRM — every signable document lands in
126
- * client_database, the same find-or-create capture path proposals / public-GPT /
127
- * assessments already use (scoped by organisation_id + primary_email). Also
128
- * links agreement.contact_id when the agreement wasn't generated from a proposal
129
- * (contact_id NULL), so a standalone signed doc still connects to the CRM.
130
- * Best-effort: a CRM write must never fail the signature (the signature is the
131
- * legal act; CRM connection is bookkeeping).
132
- */
133
- export async function connectSignerToCrm(
134
- prisma: PrismaClient,
135
- agreement: { id: string; organisation_id?: string | null; contact_id?: string | null },
136
- email: string,
137
- signerName?: string | null,
138
- company?: string | null,
139
- ): Promise<void> {
140
- const orgId = agreement.organisation_id;
141
- if (!orgId || !email || !prisma?.client_database) return;
142
- try {
143
- const parts = (signerName ?? "").trim().split(/\s+/).filter(Boolean);
144
- const firstName = parts[0] ?? "";
145
- const lastName = parts.slice(1).join(" ");
146
- const existing = await prisma.client_database.findFirst({
147
- where: { organisation_id: orgId, primary_email: email },
148
- select: { id: true },
149
- });
150
- let contactId: string | null = null;
151
- if (existing) {
152
- contactId = existing.id as string;
153
- await prisma.client_database.update({
154
- where: { id: existing.id },
155
- data: {
156
- ...(firstName ? { first_name: firstName } : {}),
157
- ...(lastName ? { last_name: lastName } : {}),
158
- ...(company ? { company_name: company } : {}),
159
- last_updated_by: "agreement-sign",
160
- },
161
- });
162
- } else {
163
- const created = await prisma.client_database.create({
164
- data: {
165
- organisation_id: orgId,
166
- first_name: firstName,
167
- last_name: lastName,
168
- primary_email: email,
169
- ...(company ? { company_name: company } : {}),
170
- lead_source: "agreement-sign",
171
- data_source: "public-form",
172
- referral_source: agreement.id,
173
- lifecycle_stage: "lead",
174
- created_by: "agreement-sign",
175
- last_updated_by: "agreement-sign",
176
- },
177
- select: { id: true },
178
- });
179
- contactId = (created?.id as string) ?? null;
180
- }
181
- if (contactId && !agreement.contact_id) {
182
- await prisma.agreements.update({
183
- where: { id: agreement.id },
184
- data: { contact_id: contactId },
185
- });
186
- }
187
- } catch (e) {
188
- getAppLogger().warn(
189
- `[publicAgreement.sign] CRM connect failed (non-fatal): ${e instanceof Error ? e.message : String(e)}`,
190
- );
191
- }
192
- }