@teamscala/service-boot 0.23.284

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 (138) hide show
  1. package/package.json +1016 -0
  2. package/src/agreement-gate.test.ts +118 -0
  3. package/src/agreement-gate.ts +887 -0
  4. package/src/asset-cache-key.ts +59 -0
  5. package/src/asset-optimize.ts +93 -0
  6. package/src/asset-proxy.ts +148 -0
  7. package/src/asset-social-card.ts +58 -0
  8. package/src/attach-agreement-magic.ts +62 -0
  9. package/src/attach-portal-members.ts +107 -0
  10. package/src/attach-proposal-accept.ts +123 -0
  11. package/src/attach-proposal-magic.ts +55 -0
  12. package/src/attach-transfers-upload.ts +100 -0
  13. package/src/auth-gate.ts +385 -0
  14. package/src/bind-page-handler.ts +223 -0
  15. package/src/build-client-assets/input-hashing.ts +206 -0
  16. package/src/build-client-assets/source-candidates.ts +115 -0
  17. package/src/build-client-assets.test.ts +216 -0
  18. package/src/build-client-assets.ts +962 -0
  19. package/src/compose-page-title.test.ts +88 -0
  20. package/src/compose-page-title.ts +73 -0
  21. package/src/content-gate/access.ts +89 -0
  22. package/src/content-gate/config-types.ts +108 -0
  23. package/src/content-gate/gate.ts +74 -0
  24. package/src/content-gate/parse-cookie.ts +23 -0
  25. package/src/content-gate/token.test.ts +70 -0
  26. package/src/content-gate/token.ts +148 -0
  27. package/src/content-page-floor.test.ts +127 -0
  28. package/src/content-page-floor.ts +70 -0
  29. package/src/discover-blocks.ts +228 -0
  30. package/src/expand-roster.ts +197 -0
  31. package/src/fn-routers/agreement-completion-email.ts +115 -0
  32. package/src/fn-routers/agreement-send-router.ts +303 -0
  33. package/src/fn-routers/ai-health-router.ts +64 -0
  34. package/src/fn-routers/auth-router.ts +131 -0
  35. package/src/fn-routers/cache-admin-router.ts +50 -0
  36. package/src/fn-routers/chat-session-router.ts +455 -0
  37. package/src/fn-routers/email-defaults.ts +22 -0
  38. package/src/fn-routers/email-link-ttl.ts +23 -0
  39. package/src/fn-routers/index.ts +70 -0
  40. package/src/fn-routers/panel-data-router.ts +93 -0
  41. package/src/fn-routers/persist-chat-messages.test.ts +86 -0
  42. package/src/fn-routers/persist-chat-messages.ts +96 -0
  43. package/src/fn-routers/plans-router.ts +141 -0
  44. package/src/fn-routers/proposal-send-router.ts +207 -0
  45. package/src/fn-routers/public-agreement-router.ts +865 -0
  46. package/src/fn-routers/public-assessment-router.ts +421 -0
  47. package/src/fn-routers/public-gpt-session-router.ts +373 -0
  48. package/src/fn-routers/sign-in-flow-router.ts +196 -0
  49. package/src/fn-routers/task-sync-bind.test.ts +105 -0
  50. package/src/fn-routers/task-sync-classify.test.ts +109 -0
  51. package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
  52. package/src/fn-routers/task-sync-dispatch.ts +124 -0
  53. package/src/fn-routers/task-sync-router.test.ts +279 -0
  54. package/src/fn-routers/task-sync-router.ts +1036 -0
  55. package/src/fn-routers/tasks-router.ts +77 -0
  56. package/src/fn-routers/user-preferences-router.ts +139 -0
  57. package/src/fn-routers/work-items-router.ts +341 -0
  58. package/src/gate-ttls.ts +56 -0
  59. package/src/gpt-gate.ts +76 -0
  60. package/src/handlers/bun-stable-watch.ts +89 -0
  61. package/src/handlers/projects-digest.ts +198 -0
  62. package/src/handlers/prune-stale-data.ts +110 -0
  63. package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
  64. package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
  65. package/src/handlers/scala-tools-currency-watch.ts +171 -0
  66. package/src/handlers/scheduled-browser-tests.ts +85 -0
  67. package/src/handlers/sweep-event-log.ts +98 -0
  68. package/src/handlers/sweep-host-cache.ts +211 -0
  69. package/src/handlers/upstream-issue-sync.ts +210 -0
  70. package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
  71. package/src/handlers/whatsapp-liveness-logic.ts +53 -0
  72. package/src/handlers/whatsapp-liveness.test.ts +78 -0
  73. package/src/handlers/whatsapp-liveness.ts +125 -0
  74. package/src/hono-error-handler.ts +55 -0
  75. package/src/install-harness.ts +80 -0
  76. package/src/install-logging.ts +182 -0
  77. package/src/load-service-install-row.ts +98 -0
  78. package/src/load-service-prisma-client.ts +53 -0
  79. package/src/mount-orpc-router.ts +148 -0
  80. package/src/mount-server-packages.ts +155 -0
  81. package/src/mount.ts +18 -0
  82. package/src/mutable-app-ref.ts +68 -0
  83. package/src/page-route-shell-types.ts +16 -0
  84. package/src/page-server-orpc/audit-revert-router.ts +102 -0
  85. package/src/page-server-orpc/hub-mcp-client.ts +20 -0
  86. package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
  87. package/src/page-server-orpc/puck-pages-router.ts +272 -0
  88. package/src/page-server-orpc/server-client.ts +31 -0
  89. package/src/page-server-orpc/templates-router.ts +116 -0
  90. package/src/page-server-orpc/user-preferences-router.ts +132 -0
  91. package/src/pg-boss-handler-registry.ts +36 -0
  92. package/src/pg-boss-mount.ts +295 -0
  93. package/src/proposal-authorized-emails.ts +65 -0
  94. package/src/proposal-chat-inject.ts +141 -0
  95. package/src/proposal-gate.ts +175 -0
  96. package/src/read-html-template.test.ts +74 -0
  97. package/src/read-html-template.ts +102 -0
  98. package/src/read-viewport-from-request.ts +36 -0
  99. package/src/readiness-gate.ts +39 -0
  100. package/src/register-api-routes.ts +127 -0
  101. package/src/register-crash-handlers.ts +39 -0
  102. package/src/register-page-routes.ts +62 -0
  103. package/src/register-page-server-client.ts +109 -0
  104. package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
  105. package/src/register-registry-page-route/graph-cache.test.ts +84 -0
  106. package/src/register-registry-page-route/graph-cache.ts +90 -0
  107. package/src/register-registry-page-route/meta-title.ts +73 -0
  108. package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
  109. package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
  110. package/src/register-registry-page-route/resolve-page-data.ts +518 -0
  111. package/src/register-registry-page-route.ts +765 -0
  112. package/src/register-static-assets.ts +180 -0
  113. package/src/register-ws-routes.ts +180 -0
  114. package/src/resolve-app-base-url.ts +28 -0
  115. package/src/resolve-brand-favicon.ts +65 -0
  116. package/src/resolve-from-loader-config.test.ts +44 -0
  117. package/src/resolve-from-loader-config.ts +291 -0
  118. package/src/resolve-service-context.ts +67 -0
  119. package/src/resolve-theme-mode.ts +61 -0
  120. package/src/resolvers/fetch-org-brand.ts +28 -0
  121. package/src/resolvers/portal-route-paths.test.ts +102 -0
  122. package/src/resolvers/portal-route-paths.ts +83 -0
  123. package/src/resolvers/website-by-host-map.ts +49 -0
  124. package/src/resource-chat-inject.ts +168 -0
  125. package/src/resource-not-found.ts +263 -0
  126. package/src/run-boot-hook-list.ts +75 -0
  127. package/src/run-boot-hooks.ts +97 -0
  128. package/src/scala-tools-currency-watchdog.ts +160 -0
  129. package/src/security-headers.ts +59 -0
  130. package/src/serve/serve-ws.test.ts +135 -0
  131. package/src/serve/serve.ts +190 -0
  132. package/src/server-error-page.test.ts +102 -0
  133. package/src/server-error-page.ts +95 -0
  134. package/src/start-scheduled-jobs.ts +84 -0
  135. package/src/two-phase-boot.ts +449 -0
  136. package/src/types.ts +52 -0
  137. package/src/ui-service.ts +171 -0
  138. package/src/ws-boot-self-test.ts +114 -0
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Locks the shared chat-message write surface used by BOTH the authed and
7
+ * public session routers: a re-save of the same turn (same SDK ids) upserts in
8
+ * place instead of inserting duplicate rows (chat-message-ids-server-stable),
9
+ * and the conversation's last_message_at is bumped once per save.
10
+ */
11
+
12
+ import { describe, expect, test } from "bun:test";
13
+ import { persistChatMessages, type ChatMessageInput } from "./persist-chat-messages.ts";
14
+
15
+ interface Row {
16
+ id?: string;
17
+ role: string;
18
+ content: string;
19
+ channel_data: Record<string, unknown>;
20
+ }
21
+
22
+ function makeFakePrisma() {
23
+ const rows: Row[] = [];
24
+ let convUpdates = 0;
25
+ let n = 0;
26
+ const prisma = {
27
+ messages: {
28
+ create: async ({ data }: { data: Row }) => {
29
+ rows.push({ ...data, id: data.id ?? `m${++n}` });
30
+ },
31
+ upsert: async ({
32
+ where,
33
+ create,
34
+ update,
35
+ }: {
36
+ where: { id: string };
37
+ create: Row;
38
+ update: Partial<Row>;
39
+ }) => {
40
+ const existing = rows.find((r) => r.id === where.id);
41
+ if (existing) Object.assign(existing, update);
42
+ else rows.push({ ...create, id: where.id });
43
+ },
44
+ },
45
+ conversations: {
46
+ update: async () => {
47
+ convUpdates++;
48
+ },
49
+ },
50
+ };
51
+ return { prisma, rows, getConvUpdates: () => convUpdates };
52
+ }
53
+
54
+ const turn: ChatMessageInput[] = [
55
+ { id: "user-1", role: "user", content: "Hi", parts: [{ type: "text", text: "Hi" }] },
56
+ { id: "assistant-1", role: "assistant", content: "Hello", parts: [{ type: "text", text: "Hello" }] },
57
+ ];
58
+
59
+ describe("persistChatMessages — shared write surface", () => {
60
+ test("re-saving the same turn upserts — one row per message, never duplicates", async () => {
61
+ const { prisma, rows, getConvUpdates } = makeFakePrisma();
62
+ const run = () =>
63
+ persistChatMessages(prisma as never, {
64
+ conversationId: "conv-1",
65
+ organisationId: "org-1",
66
+ messages: turn,
67
+ });
68
+
69
+ await run();
70
+ await run();
71
+
72
+ expect(rows.map((r) => r.id).sort()).toEqual(["assistant-1", "user-1"]);
73
+ expect(getConvUpdates()).toBe(2); // once per save
74
+ });
75
+
76
+ test("id-less messages fall back to insert (legacy)", async () => {
77
+ const { prisma, rows } = makeFakePrisma();
78
+ await persistChatMessages(prisma as never, {
79
+ conversationId: "conv-1",
80
+ organisationId: null,
81
+ messages: [{ role: "user", content: "no id" }],
82
+ });
83
+ expect(rows).toHaveLength(1);
84
+ expect(rows[0]?.role).toBe("user");
85
+ });
86
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The ONE chat-message write surface for the session routers. Both the authed
7
+ * (chatSession) and public (publicGptSession) saveMessages procedures persist
8
+ * turns through here — they differ only in IDENTITY/ownership (user_id vs
9
+ * contact_email, resolved in the router before calling this), never in the
10
+ * write itself. Keeping the write in two routers was literal duplication
11
+ * (no-duplicate-canonical-units) and is why the stable-id fix had to be applied
12
+ * twice.
13
+ *
14
+ * Upserts each message on its SDK UIMessage id so a re-save of a turn (a retry,
15
+ * a reconnect after a network drop, or a stray dual-write) updates the one row
16
+ * in place instead of inserting a duplicate (chat-message-ids-server-stable);
17
+ * falls back to create only for legacy id-less callers. occurred_at is stamped
18
+ * with a 1ms step preserving array order so same-second saves restore in order.
19
+ */
20
+
21
+ export interface ChatMessageInput {
22
+ id?: string;
23
+ role: string;
24
+ content: string;
25
+ parts?: unknown[];
26
+ /** The SDK UIMessage `metadata` — carries per-message token usage
27
+ * (`inputTokens`/`outputTokens`/`contextWindow`/`model`) the stream attaches
28
+ * via `messageMetadata`. Persisted so the context-usage gauge can rehydrate
29
+ * on reload and budget-enforcement can read per-message token counts; without
30
+ * it the save dropped the usage and the gauge came back blank on refresh. */
31
+ metadata?: Record<string, unknown> | null;
32
+ }
33
+
34
+ export async function persistChatMessages(
35
+ prisma: any,
36
+ opts: {
37
+ conversationId: string;
38
+ organisationId: string | null;
39
+ messages: ChatMessageInput[];
40
+ },
41
+ ): Promise<void> {
42
+ const { conversationId, organisationId, messages } = opts;
43
+ const base = Date.now();
44
+ for (const [i, msg] of messages.entries()) {
45
+ const channelData: Record<string, unknown> = msg.parts ? { parts: msg.parts } : {};
46
+ // Persist the UIMessage metadata (token usage) so it survives reload — the
47
+ // stream attaches {inputTokens, outputTokens, contextWindow, model} via
48
+ // messageMetadata, but the client save previously kept only `parts`,
49
+ // clobbering it. Flatten the token counts onto channel_data too so the
50
+ // context gauge + budget-enforcement read channel_data.input_tokens.
51
+ if (msg.metadata && typeof msg.metadata === "object") {
52
+ channelData.metadata = msg.metadata;
53
+ const meta = msg.metadata as Record<string, unknown>;
54
+ if (typeof meta.inputTokens === "number") channelData.input_tokens = meta.inputTokens;
55
+ if (typeof meta.outputTokens === "number") channelData.output_tokens = meta.outputTokens;
56
+ if (typeof meta.contextWindow === "number") channelData.context_window = meta.contextWindow;
57
+ }
58
+ const occurred_at = new Date(base + i);
59
+ const direction = msg.role === "user" ? "inbound" : "outbound";
60
+ if (msg.id) {
61
+ await prisma.messages.upsert({
62
+ where: { id: msg.id },
63
+ create: {
64
+ id: msg.id,
65
+ conversation_id: conversationId,
66
+ channel: "ai",
67
+ organisation_id: organisationId,
68
+ role: msg.role,
69
+ direction,
70
+ content: msg.content,
71
+ occurred_at,
72
+ channel_data: channelData,
73
+ },
74
+ update: { content: msg.content, channel_data: channelData },
75
+ });
76
+ } else {
77
+ await prisma.messages.create({
78
+ data: {
79
+ conversation_id: conversationId,
80
+ channel: "ai",
81
+ organisation_id: organisationId,
82
+ role: msg.role,
83
+ direction,
84
+ content: msg.content,
85
+ occurred_at,
86
+ channel_data: channelData,
87
+ },
88
+ });
89
+ }
90
+ }
91
+
92
+ await prisma.conversations.update({
93
+ where: { id: conversationId },
94
+ data: { last_message_at: new Date() },
95
+ });
96
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * ORPC procedures for plan creation with tasks and subtasks.
7
+ * Extracted from @teamscala/orpc/fn-router — primitives tier must not
8
+ * contain application-specific work_items logic.
9
+ */
10
+
11
+ import { authed, type Context } from "./auth-router.ts";
12
+
13
+ function toSession(ctx: Context): { userId: string; orgId: string | null } {
14
+ const user = ctx.user as { id: string; organisationId?: string | null };
15
+ return {
16
+ userId: user.id,
17
+ orgId: ctx.orgId || user.organisationId || null,
18
+ };
19
+ }
20
+
21
+ export const plansRouter = {
22
+ save: authed.handler(async ({ input, context }) => {
23
+ const data = input as {
24
+ project_id?: string;
25
+ initiative_id?: string;
26
+ title: string;
27
+ description?: string;
28
+ status?: string;
29
+ due_date?: string;
30
+ start_date?: string;
31
+ tasks?: Array<{
32
+ title: string;
33
+ description?: string;
34
+ status?: string;
35
+ position?: number;
36
+ depends_on?: Array<string | number>;
37
+ subtasks?: Array<{
38
+ title: string;
39
+ completed?: boolean;
40
+ position?: number;
41
+ }>;
42
+ }>;
43
+ };
44
+
45
+ if (!data.title) throw new Error("title is required");
46
+ if (!data.initiative_id && !data.project_id)
47
+ throw new Error("initiative_id is required when creating a new plan");
48
+
49
+ const userId = toSession(context).userId;
50
+ const result = await (context.prisma as any).$transaction(
51
+ async (tx: any) => {
52
+ let pid = data.project_id;
53
+ if (!pid) {
54
+ const project = await tx.work_items.create({
55
+ data: {
56
+ kind: "project",
57
+ initiative_id: data.initiative_id!,
58
+ title: data.title,
59
+ description: data.description || null,
60
+ status: data.status || "active",
61
+ due_date: data.due_date || null,
62
+ start_date: data.start_date || null,
63
+ created_by: userId,
64
+ updated_by: userId,
65
+ },
66
+ select: { id: true },
67
+ });
68
+ pid = project.id;
69
+ } else {
70
+ await tx.work_items.update({
71
+ where: { kind: "project", id: pid },
72
+ data: {
73
+ title: data.title,
74
+ description: data.description || undefined,
75
+ status: data.status || undefined,
76
+ due_date: data.due_date || undefined,
77
+ start_date: data.start_date || undefined,
78
+ updated_by: userId,
79
+ },
80
+ });
81
+ }
82
+
83
+ const createdTaskIds: string[] = [];
84
+ const createdTasks: any[] = [];
85
+
86
+ if (data.tasks && data.tasks.length > 0) {
87
+ for (let i = 0; i < data.tasks.length; i++) {
88
+ const t = data.tasks[i]!;
89
+ const resolvedDeps = (t.depends_on || []).map((dep) => {
90
+ if (typeof dep === "number") {
91
+ const resolved = createdTaskIds[dep];
92
+ if (!resolved)
93
+ throw new Error(
94
+ `depends_on index ${dep} is out of range (only ${createdTaskIds.length} tasks created so far)`,
95
+ );
96
+ return resolved;
97
+ }
98
+ return dep;
99
+ });
100
+ const task = await tx.work_items.create({
101
+ data: {
102
+ kind: "task",
103
+ project_id: pid!,
104
+ title: t.title,
105
+ description: t.description || null,
106
+ status: t.status || "todo",
107
+ position: String(t.position ?? i),
108
+ depends_on: resolvedDeps,
109
+ assignee_type: "agent",
110
+ automation_type: "manual",
111
+ responsible_party: "team",
112
+ created_by: userId,
113
+ updated_by: userId,
114
+ },
115
+ select: { id: true },
116
+ });
117
+ createdTaskIds.push(task.id);
118
+ createdTasks.push({ ...t, id: task.id, _tempIndex: i });
119
+
120
+ if (t.subtasks && t.subtasks.length > 0) {
121
+ for (let j = 0; j < t.subtasks.length; j++) {
122
+ const st = t.subtasks[j]!;
123
+ await tx.work_items.create({
124
+ data: {
125
+ kind: "subtask",
126
+ task_id: task.id,
127
+ title: st.title,
128
+ completed: st.completed ?? false,
129
+ position: st.position ?? j,
130
+ },
131
+ });
132
+ }
133
+ }
134
+ }
135
+ }
136
+ return { project_id: pid, tasks_created: createdTasks.length };
137
+ },
138
+ );
139
+ return { success: true, data: result };
140
+ }),
141
+ };
@@ -0,0 +1,207 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The proposal SEND router — the proposal twin of agreement-send-router. An
7
+ * authed portal caller POSTs {proposalId, baseUrl} to email the proposal's CRM
8
+ * contact a per-recipient magic-link (no email gate, no OTP — see
9
+ * attach-proposal-magic.ts) to view their proposal. Resend-backed.
10
+ */
11
+
12
+ import { os } from "@orpc/server";
13
+ import * as v from "valibot";
14
+ import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
15
+ import { sendEmail, initResendClient } from "@teamscala/api-resend/send-email";
16
+ import { applyTemplateVars, lookupTemplate } from "@teamscala/email/templates/lookup";
17
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
18
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
19
+ import { signAccessToken } from "../content-gate/token.ts";
20
+ import { resetRlsContext, setRlsContext } from "@teamscala/db/rls-context";
21
+ import { getDefaultEmailSender } from "./email-defaults.ts";
22
+ import { getEmailLinkTtlMs } from "./email-link-ttl.ts";
23
+
24
+ const PROP_COOKIE = "prop_token";
25
+ // Emailed links are opened hours/days later — they must outlive the gate's
26
+ // in-session window, else the recipient lands on the unauthed gate ("white page").
27
+
28
+ interface User {
29
+ id: string;
30
+ organisationId: string | null;
31
+ }
32
+ interface Context {
33
+ user: User | null | undefined;
34
+ headers: Headers;
35
+ // matches agreement-send-router Context
36
+ resolveUser?: (headers: Headers, prisma: any) => Promise<User | null>;
37
+ // prisma client type is service-injected
38
+ prisma: any;
39
+ }
40
+
41
+ const authed = os.$context<Context>().use(async (opt) => {
42
+ const { context } = opt;
43
+ let user = context.user;
44
+ if (!user && context.headers && context.resolveUser) {
45
+ user = await context.resolveUser(context.headers, context.prisma);
46
+ }
47
+ if (!user) {
48
+ const { ORPCError } = await import("@orpc/server");
49
+ throw new ORPCError("UNAUTHORIZED", { message: "Authentication required" });
50
+ }
51
+ // Audit attribution: set the audit session context so writes record the actor
52
+ // (app.current_user_name) — see auth-router.ts for the rationale.
53
+ const u = user as { id: string; name?: string | null; email?: string };
54
+ await setRlsContext(context.prisma, {
55
+ userId: u.id,
56
+ userName: u.name || u.email,
57
+ organisationId: user.organisationId,
58
+ });
59
+ try {
60
+ return await opt.next({ context: { ...context, user } });
61
+ } finally {
62
+ await resetRlsContext(context.prisma);
63
+ }
64
+ });
65
+
66
+ function bareAddress(sender: string): string {
67
+ const m = sender.match(/<([^>]+)>/);
68
+ return (m?.[1] ?? sender).trim();
69
+ }
70
+
71
+ async function resolveSender(
72
+ // prisma client type is service-injected
73
+ prisma: any,
74
+ organisationId: string,
75
+ ): Promise<{ from: string; replyTo?: string; orgName: string }> {
76
+ const templates = await loadRegistryConfig(
77
+ "config",
78
+ "email-templates",
79
+ v.looseObject({ sender: v.optional(v.string()) }),
80
+ ).catch(() => ({ sender: undefined }) as { sender?: string });
81
+ const verifiedAddr = bareAddress(
82
+ templates.sender ?? (await getDefaultEmailSender()),
83
+ );
84
+ const profile = await prisma.organisation_profile.findFirst({
85
+ where: { id: organisationId },
86
+ select: {
87
+ company_friendly_name: true,
88
+ company_name: true,
89
+ contact_email: true,
90
+ },
91
+ });
92
+ const orgName = (profile?.company_friendly_name ||
93
+ profile?.company_name ||
94
+ "") as string;
95
+ return {
96
+ from: `${orgName || "Proposal"} <${verifiedAddr}>`,
97
+ replyTo: (profile?.contact_email as string) || undefined,
98
+ orgName: orgName || "the sender",
99
+ };
100
+ }
101
+
102
+
103
+ const sendSchema = v.object({
104
+ proposalId: v.string(),
105
+ /** Public base URL of the proposals site, e.g. https://proposals-dev.scala.business (no trailing slash). */
106
+ baseUrl: v.string(),
107
+ });
108
+
109
+ export const proposalSendRouter = {
110
+ send: authed.input(sendSchema).handler(async ({ input, context }) => {
111
+ const log = getAppLogger();
112
+ const prisma = context.prisma;
113
+ const proposalId = input.proposalId.trim();
114
+ const baseUrl = input.baseUrl.trim().replace(/\/+$/, "");
115
+ if (!proposalId || !baseUrl) {
116
+ return {
117
+ ok: false as const,
118
+ error: "proposalId and baseUrl are required",
119
+ };
120
+ }
121
+ try {
122
+ const proposal = await prisma.proposals.findFirst({
123
+ where: { id: proposalId },
124
+ select: { name: true, organisation_id: true, contact_id: true },
125
+ });
126
+ if (!proposal) return { ok: false as const, error: "Proposal not found" };
127
+ if (!proposal.contact_id) {
128
+ return {
129
+ ok: false as const,
130
+ error: "Proposal has no contact to send to",
131
+ };
132
+ }
133
+
134
+ const contact = await prisma.client_database.findFirst({
135
+ where: { id: proposal.contact_id },
136
+ select: { primary_email: true, first_name: true, last_name: true },
137
+ });
138
+ const email = String(contact?.primary_email ?? "").trim();
139
+ if (!email) {
140
+ return { ok: false as const, error: "Contact has no email address" };
141
+ }
142
+ const name = [contact?.first_name, contact?.last_name]
143
+ .filter(Boolean)
144
+ .join(" ")
145
+ .trim();
146
+
147
+ const rawSecret = await loadRegistryConfig(
148
+ "secret",
149
+ "resend-api-key",
150
+ v.looseObject({ token: v.optional(v.string()) }),
151
+ ).catch(() => null);
152
+ const decrypted = rawSecret
153
+ ? (decryptSecretConfig(rawSecret as never) as { token?: string })
154
+ : null;
155
+ if (!decrypted?.token) {
156
+ return { ok: false as const, error: "Resend API key not configured" };
157
+ }
158
+ initResendClient(decrypted.token);
159
+
160
+ const sender = await resolveSender(prisma, proposal.organisation_id);
161
+ const token = signAccessToken(
162
+ PROP_COOKIE,
163
+ proposalId,
164
+ email,
165
+ Date.now(),
166
+ await getEmailLinkTtlMs(),
167
+ );
168
+ const url = token
169
+ ? `${baseUrl}/api/proposal-magic?p=${encodeURIComponent(proposalId)}&t=${encodeURIComponent(token)}&to=${encodeURIComponent(`/${proposalId}`)}`
170
+ : `${baseUrl}/${proposalId}`;
171
+
172
+ // Subject + body come from the `proposal-send` email_template row, never
173
+ // from literals here (email-template-registry-type): changing the copy is
174
+ // a registry_edit, not a deploy, and an org can override the row per
175
+ // tenant. The `from` stays computed — it is the verified sender identity,
176
+ // not copy.
177
+ const tpl = applyTemplateVars(await lookupTemplate("proposal-send"), {
178
+ hello: name ? `Hi ${name},` : "Hello,",
179
+ orgName: sender.orgName,
180
+ proposalName: proposal.name,
181
+ url,
182
+ });
183
+ const res = await sendEmail({
184
+ from: sender.from,
185
+ to: email,
186
+ reply_to: sender.replyTo,
187
+ subject: tpl.subject,
188
+ html: tpl.bodyHtml,
189
+ text: tpl.bodyText,
190
+ });
191
+ if (!res.success) {
192
+ log.error(
193
+ `[proposalSend] failed to ${email}: ${res.error ?? "unknown"}`,
194
+ );
195
+ return { ok: false as const, error: res.error ?? "send failed" };
196
+ }
197
+ log.info(
198
+ `[proposalSend] ${proposalId} sent to ${email}${res.id ? ` (id ${res.id})` : ""}`,
199
+ );
200
+ return { ok: true as const, sentAt: new Date().toISOString(), url, email };
201
+ } catch (err) {
202
+ const message = err instanceof Error ? err.message : String(err);
203
+ log.error(`[proposalSend] ${proposalId}: ${message}`);
204
+ return { ok: false as const, error: message };
205
+ }
206
+ }),
207
+ };