@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.
- 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-registry-page-route/region-slots-page-data.ts +2 -2
- 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 -107
- package/src/fn-routers/assessment-report-pair.ts +0 -155
- package/src/fn-routers/assessment-research-report-data.test.ts +0 -262
- 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,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
|
-
}
|