@teamscala/service-boot 0.23.481 → 0.23.493
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -261
- package/src/agreement-gate/signing-session.ts +1 -1
- package/src/build-client-assets/styles-build.ts +11 -1
- package/src/mount-orpc-router.ts +1 -1
- package/src/register-page-server-client.ts +1 -15
- package/src/register-ws-routes.ts +1 -1
- package/src/fn-routers/agreement-certificate.ts +0 -224
- package/src/fn-routers/agreement-completion-email.ts +0 -114
- package/src/fn-routers/agreement-send-router.ts +0 -303
- package/src/fn-routers/agreement-sign-side-effects.ts +0 -192
- package/src/fn-routers/agreement-signing-evidence.ts +0 -105
- package/src/fn-routers/assessment-report-data.test.ts +0 -404
- package/src/fn-routers/assessment-report-data.ts +0 -485
- package/src/fn-routers/assessment-report-email.ts +0 -256
- package/src/fn-routers/assessment-report-pair.test.ts +0 -136
- package/src/fn-routers/assessment-report-pair.ts +0 -155
- package/src/fn-routers/assessment-research-report-data.test.ts +0 -233
- package/src/fn-routers/assessment-research-report-data.ts +0 -424
- package/src/fn-routers/assessment-scoring.ts +0 -105
- package/src/fn-routers/auth-router.ts +0 -65
- package/src/fn-routers/chat-session-router-shared.ts +0 -122
- package/src/fn-routers/chat-session-router.ts +0 -356
- package/src/fn-routers/conversation-ownership.ts +0 -24
- package/src/fn-routers/email-defaults.ts +0 -28
- package/src/fn-routers/email-link-ttl.ts +0 -28
- package/src/fn-routers/index.ts +0 -76
- package/src/fn-routers/linkedin-identity.test.ts +0 -27
- package/src/fn-routers/linkedin-identity.ts +0 -36
- package/src/fn-routers/messaging-whatsapp-router.test.ts +0 -121
- package/src/fn-routers/messaging-whatsapp-router.ts +0 -200
- package/src/fn-routers/panel-data-router.ts +0 -104
- package/src/fn-routers/persist-chat-messages.test.ts +0 -86
- package/src/fn-routers/persist-chat-messages.ts +0 -98
- package/src/fn-routers/proposal-send-router.ts +0 -207
- package/src/fn-routers/public-agreement-router.ts +0 -407
- package/src/fn-routers/public-assessment-router.test.ts +0 -437
- package/src/fn-routers/public-assessment-router.ts +0 -268
- package/src/fn-routers/public-gpt-session-router.ts +0 -378
- package/src/fn-routers/resend-client.ts +0 -39
- package/src/fn-routers/sign-in-flow-router.ts +0 -196
- package/src/fn-routers/user-preferences-router.ts +0 -130
- package/src/fn-routers/work-items-router.ts +0 -342
- package/src/page-server-orpc/audit-revert-router.ts +0 -102
- package/src/page-server-orpc/puck-pages-router.ts +0 -170
- package/src/page-server-orpc/user-preferences-router.ts +0 -132
|
@@ -1,86 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,98 +0,0 @@
|
|
|
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
|
-
import type { Prisma, PrismaClient } from "@teamscala/db/client";
|
|
22
|
-
|
|
23
|
-
export interface ChatMessageInput {
|
|
24
|
-
id?: string;
|
|
25
|
-
role: string;
|
|
26
|
-
content: string;
|
|
27
|
-
parts?: unknown[];
|
|
28
|
-
/** The SDK UIMessage `metadata` — carries per-message token usage
|
|
29
|
-
* (`inputTokens`/`outputTokens`/`contextWindow`/`model`) the stream attaches
|
|
30
|
-
* via `messageMetadata`. Persisted so the context-usage gauge can rehydrate
|
|
31
|
-
* on reload and budget-enforcement can read per-message token counts; without
|
|
32
|
-
* it the save dropped the usage and the gauge came back blank on refresh. */
|
|
33
|
-
metadata?: Record<string, unknown> | null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export async function persistChatMessages(
|
|
37
|
-
prisma: PrismaClient,
|
|
38
|
-
opts: {
|
|
39
|
-
conversationId: string;
|
|
40
|
-
organisationId: string | null;
|
|
41
|
-
messages: ChatMessageInput[];
|
|
42
|
-
},
|
|
43
|
-
): Promise<void> {
|
|
44
|
-
const { conversationId, organisationId, messages } = opts;
|
|
45
|
-
const base = Date.now();
|
|
46
|
-
for (const [i, msg] of messages.entries()) {
|
|
47
|
-
const channelData: Record<string, unknown> = msg.parts ? { parts: msg.parts } : {};
|
|
48
|
-
// Persist the UIMessage metadata (token usage) so it survives reload — the
|
|
49
|
-
// stream attaches {inputTokens, outputTokens, contextWindow, model} via
|
|
50
|
-
// messageMetadata, but the client save previously kept only `parts`,
|
|
51
|
-
// clobbering it. Flatten the token counts onto channel_data too so the
|
|
52
|
-
// context gauge + budget-enforcement read channel_data.input_tokens.
|
|
53
|
-
if (msg.metadata && typeof msg.metadata === "object") {
|
|
54
|
-
channelData.metadata = msg.metadata;
|
|
55
|
-
const meta = msg.metadata as Record<string, unknown>;
|
|
56
|
-
if (typeof meta.inputTokens === "number") channelData.input_tokens = meta.inputTokens;
|
|
57
|
-
if (typeof meta.outputTokens === "number") channelData.output_tokens = meta.outputTokens;
|
|
58
|
-
if (typeof meta.contextWindow === "number") channelData.context_window = meta.contextWindow;
|
|
59
|
-
}
|
|
60
|
-
const occurred_at = new Date(base + i);
|
|
61
|
-
const direction = msg.role === "user" ? "inbound" : "outbound";
|
|
62
|
-
if (msg.id) {
|
|
63
|
-
await prisma.messages.upsert({
|
|
64
|
-
where: { id: msg.id },
|
|
65
|
-
create: {
|
|
66
|
-
id: msg.id,
|
|
67
|
-
conversation_id: conversationId,
|
|
68
|
-
channel: "ai",
|
|
69
|
-
organisation_id: organisationId,
|
|
70
|
-
role: msg.role,
|
|
71
|
-
direction,
|
|
72
|
-
content: msg.content,
|
|
73
|
-
occurred_at,
|
|
74
|
-
channel_data: channelData as Prisma.InputJsonValue,
|
|
75
|
-
},
|
|
76
|
-
update: { content: msg.content, channel_data: channelData as Prisma.InputJsonValue },
|
|
77
|
-
});
|
|
78
|
-
} else {
|
|
79
|
-
await prisma.messages.create({
|
|
80
|
-
data: {
|
|
81
|
-
conversation_id: conversationId,
|
|
82
|
-
channel: "ai",
|
|
83
|
-
organisation_id: organisationId,
|
|
84
|
-
role: msg.role,
|
|
85
|
-
direction,
|
|
86
|
-
content: msg.content,
|
|
87
|
-
occurred_at,
|
|
88
|
-
channel_data: channelData as Prisma.InputJsonValue,
|
|
89
|
-
},
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
await prisma.conversations.update({
|
|
95
|
-
where: { id: conversationId },
|
|
96
|
-
data: { last_message_at: new Date() },
|
|
97
|
-
});
|
|
98
|
-
}
|
|
@@ -1,207 +0,0 @@
|
|
|
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 { sendResendEmail } from "./resend-client.ts";
|
|
16
|
-
import { applyTemplateVars, lookupTemplate } from "@teamscala/email-template/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 type { PrismaClient } from "@teamscala/db/client";
|
|
22
|
-
import { getDefaultEmailSender } from "./email-defaults.ts";
|
|
23
|
-
import { getEmailLinkTtlMs } from "./email-link-ttl.ts";
|
|
24
|
-
|
|
25
|
-
const PROP_COOKIE = "prop_token";
|
|
26
|
-
// Emailed links are opened hours/days later — they must outlive the gate's
|
|
27
|
-
// in-session window, else the recipient lands on the unauthed gate ("white page").
|
|
28
|
-
|
|
29
|
-
interface User {
|
|
30
|
-
id: string;
|
|
31
|
-
organisationId: string | null;
|
|
32
|
-
}
|
|
33
|
-
interface Context {
|
|
34
|
-
user: User | null | undefined;
|
|
35
|
-
headers: Headers;
|
|
36
|
-
// matches agreement-send-router Context
|
|
37
|
-
resolveUser?: (headers: Headers, prisma: PrismaClient) => Promise<User | null>;
|
|
38
|
-
// prisma client type is service-injected
|
|
39
|
-
prisma: PrismaClient;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const authed = os.$context<Context>().use(async (opt) => {
|
|
43
|
-
const { context } = opt;
|
|
44
|
-
let user = context.user;
|
|
45
|
-
if (!user && context.headers && context.resolveUser) {
|
|
46
|
-
user = await context.resolveUser(context.headers, context.prisma);
|
|
47
|
-
}
|
|
48
|
-
if (!user) {
|
|
49
|
-
const { ORPCError } = await import("@orpc/server");
|
|
50
|
-
throw new ORPCError("UNAUTHORIZED", { message: "Authentication required" });
|
|
51
|
-
}
|
|
52
|
-
// Audit attribution: set the audit session context so writes record the actor
|
|
53
|
-
// (app.current_user_name) — see auth-router.ts for the rationale.
|
|
54
|
-
const u = user as { id: string; name?: string | null; email?: string };
|
|
55
|
-
await setRlsContext(context.prisma, {
|
|
56
|
-
userId: u.id,
|
|
57
|
-
userName: u.name || u.email,
|
|
58
|
-
organisationId: user.organisationId,
|
|
59
|
-
});
|
|
60
|
-
try {
|
|
61
|
-
return await opt.next({ context: { ...context, user } });
|
|
62
|
-
} finally {
|
|
63
|
-
await resetRlsContext(context.prisma);
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
function bareAddress(sender: string): string {
|
|
68
|
-
const m = sender.match(/<([^>]+)>/);
|
|
69
|
-
return (m?.[1] ?? sender).trim();
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async function resolveSender(
|
|
73
|
-
// prisma client type is service-injected
|
|
74
|
-
prisma: PrismaClient,
|
|
75
|
-
organisationId: string,
|
|
76
|
-
): Promise<{ from: string; replyTo?: string; orgName: string }> {
|
|
77
|
-
const templates = await loadRegistryConfig(
|
|
78
|
-
"config",
|
|
79
|
-
"email-templates",
|
|
80
|
-
v.looseObject({ sender: v.optional(v.string()) }),
|
|
81
|
-
).catch(() => ({ sender: undefined }) as { sender?: string });
|
|
82
|
-
const verifiedAddr = bareAddress(
|
|
83
|
-
templates.sender ?? (await getDefaultEmailSender()),
|
|
84
|
-
);
|
|
85
|
-
const profile = await prisma.organisation_profile.findFirst({
|
|
86
|
-
where: { id: organisationId },
|
|
87
|
-
select: {
|
|
88
|
-
company_friendly_name: true,
|
|
89
|
-
company_name: true,
|
|
90
|
-
contact_email: true,
|
|
91
|
-
},
|
|
92
|
-
});
|
|
93
|
-
const orgName = (profile?.company_friendly_name ||
|
|
94
|
-
profile?.company_name ||
|
|
95
|
-
"") as string;
|
|
96
|
-
return {
|
|
97
|
-
from: `${orgName || "Proposal"} <${verifiedAddr}>`,
|
|
98
|
-
replyTo: (profile?.contact_email as string) || undefined,
|
|
99
|
-
orgName: orgName || "the sender",
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const sendSchema = v.object({
|
|
105
|
-
proposalId: v.string(),
|
|
106
|
-
/** Public base URL of the proposals site, e.g. https://proposals-dev.scala.business (no trailing slash). */
|
|
107
|
-
baseUrl: v.string(),
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
export const proposalSendRouter = {
|
|
111
|
-
send: authed.input(sendSchema).handler(async ({ input, context }) => {
|
|
112
|
-
const log = getAppLogger();
|
|
113
|
-
const prisma = context.prisma;
|
|
114
|
-
const proposalId = input.proposalId.trim();
|
|
115
|
-
const baseUrl = input.baseUrl.trim().replace(/\/+$/, "");
|
|
116
|
-
if (!proposalId || !baseUrl) {
|
|
117
|
-
return {
|
|
118
|
-
ok: false as const,
|
|
119
|
-
error: "proposalId and baseUrl are required",
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
try {
|
|
123
|
-
const proposal = await prisma.proposals.findFirst({
|
|
124
|
-
where: { id: proposalId },
|
|
125
|
-
select: { name: true, organisation_id: true, contact_id: true },
|
|
126
|
-
});
|
|
127
|
-
if (!proposal) return { ok: false as const, error: "Proposal not found" };
|
|
128
|
-
if (!proposal.contact_id) {
|
|
129
|
-
return {
|
|
130
|
-
ok: false as const,
|
|
131
|
-
error: "Proposal has no contact to send to",
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const contact = await prisma.client_database.findFirst({
|
|
136
|
-
where: { id: proposal.contact_id },
|
|
137
|
-
select: { primary_email: true, first_name: true, last_name: true },
|
|
138
|
-
});
|
|
139
|
-
const email = String(contact?.primary_email ?? "").trim();
|
|
140
|
-
if (!email) {
|
|
141
|
-
return { ok: false as const, error: "Contact has no email address" };
|
|
142
|
-
}
|
|
143
|
-
const name = [contact?.first_name, contact?.last_name]
|
|
144
|
-
.filter(Boolean)
|
|
145
|
-
.join(" ")
|
|
146
|
-
.trim();
|
|
147
|
-
|
|
148
|
-
const rawSecret = await loadRegistryConfig(
|
|
149
|
-
"secret",
|
|
150
|
-
"resend-api-key",
|
|
151
|
-
v.looseObject({ token: v.optional(v.string()) }),
|
|
152
|
-
).catch(() => null);
|
|
153
|
-
const decrypted = rawSecret
|
|
154
|
-
? (decryptSecretConfig(rawSecret as never) as { token?: string })
|
|
155
|
-
: null;
|
|
156
|
-
if (!decrypted?.token) {
|
|
157
|
-
return { ok: false as const, error: "Resend API key not configured" };
|
|
158
|
-
}
|
|
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 sendResendEmail(decrypted.token, {
|
|
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
|
-
};
|