@teamscala/service-boot 0.23.481 → 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-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,28 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Hot snapshot of `email-defaults.default_sender` (outbound transactional
|
|
7
|
-
* from-address). Replaces the former process.env.DEFAULT_EMAIL_SENDER read.
|
|
8
|
-
* Invalidated by NOTIFY on `registry_config_changed`; `staleAfterMs` is the
|
|
9
|
-
* safety ceiling for an upstream regression that drops notifications.
|
|
10
|
-
*/
|
|
11
|
-
import { createNotifyCache } from "@teamscala/notify-cache/create-notify-cache";
|
|
12
|
-
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
13
|
-
import * as v from "valibot";
|
|
14
|
-
|
|
15
|
-
const FALLBACK_SENDER = "noreply@gateway.scala.business";
|
|
16
|
-
const defaultSenderCache = createNotifyCache<string>({
|
|
17
|
-
name: "service-boot:default-email-sender",
|
|
18
|
-
load: async () => {
|
|
19
|
-
const row = await loadRegistryConfig("config", "email-defaults", v.looseObject({}));
|
|
20
|
-
return (row?.default_sender as string | undefined) ?? FALLBACK_SENDER;
|
|
21
|
-
},
|
|
22
|
-
invalidateOn: ["registry_config_changed"],
|
|
23
|
-
staleAfterMs: 5 * 60 * 1000,
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
export async function getDefaultEmailSender(): Promise<string> {
|
|
27
|
-
return defaultSenderCache.get();
|
|
28
|
-
}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Hot snapshot of `email-links.email_link_ttl_ms` (magic-link validity
|
|
7
|
-
* window, dedup'd across the 3 fn-routers that mint them). Replaces the
|
|
8
|
-
* former per-file EMAIL_LINK_TTL_MS constant. Invalidated by NOTIFY on
|
|
9
|
-
* `registry_config_changed`.
|
|
10
|
-
*/
|
|
11
|
-
import { createNotifyCache } from "@teamscala/notify-cache/create-notify-cache";
|
|
12
|
-
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
13
|
-
import * as v from "valibot";
|
|
14
|
-
|
|
15
|
-
const DEFAULT_EMAIL_LINK_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
16
|
-
const emailLinkTtlCache = createNotifyCache<number>({
|
|
17
|
-
name: "service-boot:email-link-ttl",
|
|
18
|
-
load: async () => {
|
|
19
|
-
const row = await loadRegistryConfig("config", "email-links", v.looseObject({}));
|
|
20
|
-
return (row?.email_link_ttl_ms as number | undefined) ?? DEFAULT_EMAIL_LINK_TTL_MS;
|
|
21
|
-
},
|
|
22
|
-
invalidateOn: ["registry_config_changed"],
|
|
23
|
-
staleAfterMs: 5 * 60 * 1000,
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
export async function getEmailLinkTtlMs(): Promise<number> {
|
|
27
|
-
return emailLinkTtlCache.get();
|
|
28
|
-
}
|
package/src/fn-routers/index.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Composes auth, tasks, work-items, plans, chat-session, public-gpt-session,
|
|
7
|
-
* public-agreement, and user-preferences routers into the `fn` namespace and
|
|
8
|
-
* registers it via registerCustomRouter.
|
|
9
|
-
* Called at boot by registerFnRouters() — invoked before any ORPC dispatch.
|
|
10
|
-
*
|
|
11
|
-
* Also exposes the `regions` namespace on the client /api/rpc mount: a single
|
|
12
|
-
* `loadPage` procedure (reusing puckPagesRouter.loadFromWebsite) so a client
|
|
13
|
-
* RegionPageSwap can fetch a region's Puck page on demand and render it into a
|
|
14
|
-
* slot — the no-reload half of `region-views-are-pages` (every region is a Puck
|
|
15
|
-
* page; switching a view loads a different page's content).
|
|
16
|
-
*
|
|
17
|
-
* `resolvePanelData` WAS RESTORED 2026-08-31. `72ac1a7` ("Retire unoffered page
|
|
18
|
-
* ORPC procedures") deleted it three weeks earlier as unoffered, but its
|
|
19
|
-
* consumer is live and in ANOTHER REPO: `use-region-page.ts` in
|
|
20
|
-
* @teamscala/ui-foundation calls `regions.resolvePanelData` on every panel
|
|
21
|
-
* open. Only the comment describing it was left behind — the shape
|
|
22
|
-
* `a-deleted-producer-leaves-its-consumers-executing` names, where deleting a
|
|
23
|
-
* producer leaves its consumers demanding something that can no longer exist.
|
|
24
|
-
*
|
|
25
|
-
* It failed SILENTLY for three weeks because the client treats a failure as
|
|
26
|
-
* best-effort and maps it to null, so the 404 rendered an empty panel with no
|
|
27
|
-
* error anywhere. Observed live as `POST /api/rpc/regions/resolvePanelData 404`
|
|
28
|
-
* on every panel open on projects-dev.
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
import { registerCustomRouter } from "@teamscala/orpc/app-router";
|
|
32
|
-
import { puckPagesRouter } from "../page-server-orpc/puck-pages-router";
|
|
33
|
-
import { resolvePanelDataHandler } from "./panel-data-router.ts";
|
|
34
|
-
import { watchDataChange } from "@teamscala/realtime-utils/data-change-procedure";
|
|
35
|
-
import { watchPresence } from "@teamscala/realtime-utils/presence-procedure";
|
|
36
|
-
import { agreementSendRouter } from "./agreement-send-router.ts";
|
|
37
|
-
import { chatSessionRouter } from "./chat-session-router.ts";
|
|
38
|
-
import { proposalSendRouter } from "./proposal-send-router.ts";
|
|
39
|
-
import { publicAgreementRouter } from "./public-agreement-router.ts";
|
|
40
|
-
import { publicAssessmentRouter } from "./public-assessment-router.ts";
|
|
41
|
-
import { publicGptSessionRouter } from "./public-gpt-session-router.ts";
|
|
42
|
-
import { signInFlowRouter } from "./sign-in-flow-router.ts";
|
|
43
|
-
import { userPreferencesRouter } from "./user-preferences-router.ts";
|
|
44
|
-
import { workItemsRouter } from "./work-items-router.ts";
|
|
45
|
-
import { messagingWhatsappRouter } from "./messaging-whatsapp-router.ts";
|
|
46
|
-
|
|
47
|
-
// fn.cacheAdmin.* and fn.auth.* are ROW-COMPOSED now: api_route rows
|
|
48
|
-
// (handler_type "orpc_fn") are rendered into the service's generated ORPC
|
|
49
|
-
// bundle (routers/fn.ts) by orpc-bundle@1.0.0 and merged into the app router
|
|
50
|
-
// by getAppRouter. Register them by editing ROWS, never by re-adding
|
|
51
|
-
// handwritten routers here. Convert the remaining families the same way.
|
|
52
|
-
export function registerFnRouters(): void {
|
|
53
|
-
registerCustomRouter("fn", {
|
|
54
|
-
agreementSend: agreementSendRouter,
|
|
55
|
-
proposalSend: proposalSendRouter,
|
|
56
|
-
chatSession: chatSessionRouter,
|
|
57
|
-
messagingWhatsappSend: messagingWhatsappRouter.messagingWhatsappSend,
|
|
58
|
-
publicAgreement: publicAgreementRouter,
|
|
59
|
-
publicAssessment: publicAssessmentRouter,
|
|
60
|
-
publicGptSession: publicGptSessionRouter,
|
|
61
|
-
signInFlow: signInFlowRouter,
|
|
62
|
-
workItems: workItemsRouter,
|
|
63
|
-
userPreferences: userPreferencesRouter,
|
|
64
|
-
});
|
|
65
|
-
registerCustomRouter("regions", {
|
|
66
|
-
loadPage: puckPagesRouter.loadFromWebsite,
|
|
67
|
-
// Resolve a page-kind panel's OWN loaderData (panel_view.loader_slug) so a
|
|
68
|
-
// panel region renders against the panel page's data, not the host's.
|
|
69
|
-
//
|
|
70
|
-
resolvePanelData: resolvePanelDataHandler,
|
|
71
|
-
});
|
|
72
|
-
registerCustomRouter("realtime", {
|
|
73
|
-
watchDataChange,
|
|
74
|
-
watchPresence,
|
|
75
|
-
});
|
|
76
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { normaliseLinkedinIdentity } from "./linkedin-identity.ts";
|
|
3
|
-
|
|
4
|
-
describe("normaliseLinkedinIdentity", () => {
|
|
5
|
-
test("collapses every spelling of the same profile to one key", () => {
|
|
6
|
-
expect(normaliseLinkedinIdentity("https://www.linkedin.com/in/annaosherov/")).toBe(
|
|
7
|
-
"linkedin.com/in/annaosherov",
|
|
8
|
-
);
|
|
9
|
-
expect(normaliseLinkedinIdentity("linkedin.com/in/AnnaOsherov")).toBe(
|
|
10
|
-
"linkedin.com/in/annaosherov",
|
|
11
|
-
);
|
|
12
|
-
expect(normaliseLinkedinIdentity(" LinkedIn.com/in/annaosherov/ ")).toBe(
|
|
13
|
-
"linkedin.com/in/annaosherov",
|
|
14
|
-
);
|
|
15
|
-
expect(normaliseLinkedinIdentity("http://www.linkedin.com/in/annaosherov")).toBe(
|
|
16
|
-
"linkedin.com/in/annaosherov",
|
|
17
|
-
);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
test("refuses non-linkedin, empty and absent input rather than guessing a key", () => {
|
|
21
|
-
expect(normaliseLinkedinIdentity("https://example.com/in/someone")).toBeNull();
|
|
22
|
-
expect(normaliseLinkedinIdentity(" ")).toBeNull();
|
|
23
|
-
expect(normaliseLinkedinIdentity("")).toBeNull();
|
|
24
|
-
expect(normaliseLinkedinIdentity(null)).toBeNull();
|
|
25
|
-
expect(normaliseLinkedinIdentity(undefined)).toBeNull();
|
|
26
|
-
});
|
|
27
|
-
});
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten @indivisible-unit owner=lane/unified-authority-index-report reason="one identity normalisation shared by the research writer and the report pairing lookup — a second spelling of the rule is the drift the pairing exists to prevent"
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* linkedin-identity.ts — the ONE normalisation from a LinkedIn profile URL to
|
|
7
|
-
* the pairing key that joins research passes to quiz responses.
|
|
8
|
-
*
|
|
9
|
-
* WHY IT EXISTS AS A SHARED UNIT (operator pin, 2026-09-04): the same person
|
|
10
|
-
* arrives as `https://www.linkedin.com/in/annaosherov/` on a quiz response and
|
|
11
|
-
* as `linkedin.com/in/AnnaOsherov` in a research note. Both sides MUST
|
|
12
|
-
* collapse to the same `identity_key` or the report renders one half of the
|
|
13
|
-
* person. The normalisation therefore lives here, exported once, and every
|
|
14
|
-
* surface that writes or looks up an identity key calls THIS function — a
|
|
15
|
-
* second local copy of the rule is the drift the pairing exists to prevent.
|
|
16
|
-
*
|
|
17
|
-
* Rule: trim; lowercase; strip the scheme (http/https); strip a leading
|
|
18
|
-
* `www.`; strip one trailing slash. Everything else is kept verbatim, so
|
|
19
|
-
* `linkedin.com/in/annaosherov` and a custom slug both survive intact.
|
|
20
|
-
* Non-LinkedIn or empty input returns null — callers refuse rather than pair
|
|
21
|
-
* on a guessed key.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
export function normaliseLinkedinIdentity(
|
|
25
|
-
rawUrl: string | null | undefined,
|
|
26
|
-
): string | null {
|
|
27
|
-
if (typeof rawUrl !== "string") return null;
|
|
28
|
-
let value = rawUrl.trim().toLowerCase();
|
|
29
|
-
if (value.length === 0) return null;
|
|
30
|
-
value = value.replace(/^https?:\/\//, "");
|
|
31
|
-
value = value.replace(/^www\./, "");
|
|
32
|
-
value = value.replace(/\/+$/, "");
|
|
33
|
-
if (value.length === 0) return null;
|
|
34
|
-
if (!value.includes("linkedin.com")) return null;
|
|
35
|
-
return value;
|
|
36
|
-
}
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Proves fn.messagingWhatsappSend routes through scala-messaging-service: the
|
|
7
|
-
* URL is DERIVED from the service_install row (never spelled), the sealed-row
|
|
8
|
-
* credential is passed through the decrypt mock (identity, matching the
|
|
9
|
-
* public-assessment-router test pattern), the request carries the internal
|
|
10
|
-
* auth header, and the caller receives WhatsApp's message_id — the only thing
|
|
11
|
-
* that makes delivery provable. Failure modes (401, blank input) fail loudly
|
|
12
|
-
* with named causes, never a generic send error.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { createRouterClient } from "@orpc/server";
|
|
16
|
-
import { afterAll, describe, expect, mock, test } from "bun:test";
|
|
17
|
-
|
|
18
|
-
const SEND_CALLS: Array<{ url: string; headers: Record<string, string>; body: unknown }> = [];
|
|
19
|
-
|
|
20
|
-
mock.module("@teamscala/db/registry/load-config", () => ({
|
|
21
|
-
loadRegistryConfig: async (type: string, slug: string) => {
|
|
22
|
-
if (type === "config" && slug === "messaging-service-internal-url") {
|
|
23
|
-
return { internalBaseUrl: "http://scala-messaging-rust.railway.internal:8080" };
|
|
24
|
-
}
|
|
25
|
-
if (type === "secret" && slug === "railway-scala-messaging-whatsapp-webhook-secret") {
|
|
26
|
-
return { token: "test-token" };
|
|
27
|
-
}
|
|
28
|
-
throw new Error(`unexpected loadRegistryConfig(${type}, ${slug})`);
|
|
29
|
-
},
|
|
30
|
-
}));
|
|
31
|
-
|
|
32
|
-
// Identity decrypt (the public-assessment-router test pattern).
|
|
33
|
-
mock.module("@teamscala/encryption/crypto/secret-config", () => ({
|
|
34
|
-
decryptSecretConfig: (raw: unknown) => raw,
|
|
35
|
-
}));
|
|
36
|
-
|
|
37
|
-
mock.module("@teamscala/logger/app-loggers", () => ({
|
|
38
|
-
getAppLogger: () => ({
|
|
39
|
-
info: () => {},
|
|
40
|
-
warn: () => {},
|
|
41
|
-
error: () => {},
|
|
42
|
-
debug: () => {},
|
|
43
|
-
}),
|
|
44
|
-
}));
|
|
45
|
-
|
|
46
|
-
const originalFetch = globalThis.fetch;
|
|
47
|
-
(globalThis as { fetch: unknown }).fetch = async (url: string, init: RequestInit) => {
|
|
48
|
-
SEND_CALLS.push({
|
|
49
|
-
url,
|
|
50
|
-
headers: init.headers as Record<string, string>,
|
|
51
|
-
body: JSON.parse(init.body as string),
|
|
52
|
-
});
|
|
53
|
-
const body = JSON.parse(init.body as string) as { phone?: string };
|
|
54
|
-
if (body.phone === "0-0-1-refuse") {
|
|
55
|
-
return new Response("no", { status: 401 });
|
|
56
|
-
}
|
|
57
|
-
return Response.json({ ok: true, sent: true, messageId: "3EB07EE8TESTID" });
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const { messagingWhatsappRouter } = await import("./messaging-whatsapp-router.ts");
|
|
61
|
-
|
|
62
|
-
// Invoke through a real ORPC client so the authed middleware runs too —
|
|
63
|
-
// calling the DecoratedProcedure directly is not a round trip.
|
|
64
|
-
const client = createRouterClient(
|
|
65
|
-
{ messagingWhatsappSend: messagingWhatsappRouter.messagingWhatsappSend } as never,
|
|
66
|
-
{
|
|
67
|
-
context: {
|
|
68
|
-
user: { id: "system", organisationId: null },
|
|
69
|
-
headers: new Headers(),
|
|
70
|
-
// The authed base now sets the RLS/audit context per request; the
|
|
71
|
-
// mock only needs to accept the SET statements.
|
|
72
|
-
prisma: { $executeRawUnsafe: async () => [] },
|
|
73
|
-
} as never,
|
|
74
|
-
},
|
|
75
|
-
) as { messagingWhatsappSend: (a: unknown) => Promise<unknown> };
|
|
76
|
-
|
|
77
|
-
describe("fn.messagingWhatsappSend", () => {
|
|
78
|
-
test("derives the URL from the config row, sends with the decrypted header value, returns the message_id", async () => {
|
|
79
|
-
SEND_CALLS.length = 0;
|
|
80
|
-
const result = await client.messagingWhatsappSend({
|
|
81
|
-
phone: "61428193498",
|
|
82
|
-
message: "migration probe",
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
expect(SEND_CALLS).toHaveLength(1);
|
|
86
|
-
expect(SEND_CALLS[0]?.url).toBe(
|
|
87
|
-
"http://scala-messaging-rust.railway.internal:8080/api/messaging/whatsapp/send",
|
|
88
|
-
);
|
|
89
|
-
expect(SEND_CALLS[0]?.headers["x-webhook-secret"]).toBe("test-token");
|
|
90
|
-
expect(SEND_CALLS[0]?.body).toEqual({ phone: "61428193498", message: "migration probe" });
|
|
91
|
-
expect(result).toEqual({ ok: true, message_id: "3EB07EE8TESTID" });
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test("a 401 from the messaging service names the credential, not a generic failure", async () => {
|
|
95
|
-
SEND_CALLS.length = 0;
|
|
96
|
-
const result = await client.messagingWhatsappSend({
|
|
97
|
-
phone: "0-0-1-refuse",
|
|
98
|
-
message: "x",
|
|
99
|
-
});
|
|
100
|
-
expect(result).toEqual({
|
|
101
|
-
ok: false,
|
|
102
|
-
error: "messaging service refused the webhook secret (401)",
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
test("blank phone or message is refused before any request is made", async () => {
|
|
107
|
-
SEND_CALLS.length = 0;
|
|
108
|
-
const blank = await client.messagingWhatsappSend({
|
|
109
|
-
phone: " ",
|
|
110
|
-
message: "x",
|
|
111
|
-
});
|
|
112
|
-
expect(blank).toEqual({ ok: false, error: "phone and message are required" });
|
|
113
|
-
expect(SEND_CALLS).toHaveLength(0);
|
|
114
|
-
});
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// Restore INSIDE afterAll — a top-level restore would run before the tests
|
|
118
|
-
// (all top-level code executes first) and hand the router back the real fetch.
|
|
119
|
-
afterAll(() => {
|
|
120
|
-
(globalThis as { fetch: unknown }).fetch = originalFetch;
|
|
121
|
-
});
|
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* fn.messagingWhatsappSend — the agent-facing WhatsApp send that routes through
|
|
7
|
-
* scala-messaging-service (the Rust owner of the channel), replacing the
|
|
8
|
-
* retired TS @teamscala/api-gowa SDK send path (operator ruling 2026-08-26:
|
|
9
|
-
* "make sure the migration is finished, i cant have half ts and half rust").
|
|
10
|
-
*
|
|
11
|
-
* The messaging service's POST /api/messaging/whatsapp/send is the single send
|
|
12
|
-
* surface: it resolves the sending device, returns WhatsApp's message_id for
|
|
13
|
-
* delivery correlation, and records the send in messaging_logs itself (Rust,
|
|
14
|
-
* `write_outbound_tracked`) — better than the deleted TS recorder, which only
|
|
15
|
-
* ran when the send happened to flow through tool-executor.
|
|
16
|
-
*
|
|
17
|
-
* Both facts this procedure needs are DERIVED at call time, never spelled:
|
|
18
|
-
* - the service URL comes from the service_install row (slug + port +
|
|
19
|
-
* deploy_target → Railway private DNS <slug>.railway.internal:<port>), the
|
|
20
|
-
* same derivation every *.railway.internal consumer uses;
|
|
21
|
-
* - the auth credential is the existing sealed secret row
|
|
22
|
-
* railway-scala-messaging-whatsapp-webhook-secret, decrypted per
|
|
23
|
-
* registry-via-helper + the agreement-send precedent.
|
|
24
|
-
*
|
|
25
|
-
* Registered flat under the `fn` namespace (two-segment procedure
|
|
26
|
-
* "fn.messagingWhatsappSend") because the tool-executor ORPC dispatch resolves
|
|
27
|
-
* "model.method" — nested routers would not be reachable as a tool procedure.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import { os } from "@orpc/server";
|
|
31
|
-
import * as v from "valibot";
|
|
32
|
-
import {
|
|
33
|
-
decryptSecretConfig,
|
|
34
|
-
type SecretConfigLike,
|
|
35
|
-
} from "@teamscala/encryption/crypto/secret-config";
|
|
36
|
-
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
37
|
-
import type { PrismaClient } from "@teamscala/db/client";
|
|
38
|
-
import { resetRlsContext, setRlsContext } from "@teamscala/db/rls-context";
|
|
39
|
-
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
40
|
-
|
|
41
|
-
interface User {
|
|
42
|
-
id: string;
|
|
43
|
-
organisationId: string | null;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
interface Context {
|
|
47
|
-
user: User | null | undefined;
|
|
48
|
-
headers: Headers;
|
|
49
|
-
resolveUser?: (headers: Headers, prisma: PrismaClient) => Promise<User | null>;
|
|
50
|
-
prisma: PrismaClient;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const authed = os.$context<Context>().use(async (opt) => {
|
|
54
|
-
const { context } = opt;
|
|
55
|
-
let user = context.user;
|
|
56
|
-
if (!user && context.headers && context.resolveUser) {
|
|
57
|
-
user = await context.resolveUser(context.headers, context.prisma);
|
|
58
|
-
}
|
|
59
|
-
if (!user) {
|
|
60
|
-
const { ORPCError } = await import("@orpc/server");
|
|
61
|
-
throw new ORPCError("UNAUTHORIZED", { message: "Authentication required" });
|
|
62
|
-
}
|
|
63
|
-
// Audit attribution: writes through this base must record the actor in
|
|
64
|
-
// audit_trail (app.current_user_name), not an empty actor (the 'by .' bug).
|
|
65
|
-
const u = user as { id: string; name?: string | null; organisationId: string | null };
|
|
66
|
-
await setRlsContext(context.prisma, {
|
|
67
|
-
userId: u.id,
|
|
68
|
-
userName: u.name || u.id,
|
|
69
|
-
organisationId: u.organisationId,
|
|
70
|
-
});
|
|
71
|
-
try {
|
|
72
|
-
return await opt.next({ context: { ...context, user } });
|
|
73
|
-
} finally {
|
|
74
|
-
await resetRlsContext(context.prisma);
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Derive the messaging service base URL. The single source is the
|
|
80
|
-
* config/messaging-service-internal-url row (the Railway private domain is a
|
|
81
|
-
* Railway fact — the registry slug and the Railway service name differ, e.g.
|
|
82
|
-
* scala-messaging-service vs scala-messaging-rust — so the resolved URL lives
|
|
83
|
-
* in ONE registry row instead of being spelled per-caller).
|
|
84
|
-
*/
|
|
85
|
-
const MessagingUrlSchema = v.looseObject({
|
|
86
|
-
internalBaseUrl: v.string(),
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
/** The sealed webhook-secret row shape (value is decryptSecretConfig input). */
|
|
90
|
-
const MessagingSecretSchema = v.looseObject({});
|
|
91
|
-
|
|
92
|
-
async function resolveMessagingBaseUrl(): Promise<string> {
|
|
93
|
-
const row = await loadRegistryConfig(
|
|
94
|
-
"config",
|
|
95
|
-
"messaging-service-internal-url",
|
|
96
|
-
MessagingUrlSchema,
|
|
97
|
-
);
|
|
98
|
-
const url = row.internalBaseUrl.trim().replace(/\/+$/, "");
|
|
99
|
-
if (!url.startsWith("http")) {
|
|
100
|
-
throw new Error(
|
|
101
|
-
"config/messaging-service-internal-url.internalBaseUrl must be an http(s) URL — " +
|
|
102
|
-
"the fn router refuses to guess a messaging service address.",
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
return url;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const sendSchema = v.object({
|
|
109
|
-
/** Destination phone number in E.164 (e.g. 61428193498). */
|
|
110
|
-
phone: v.string(),
|
|
111
|
-
/** Text body to send. */
|
|
112
|
-
message: v.string(),
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
export const messagingWhatsappRouter = {
|
|
116
|
-
messagingWhatsappSend: authed
|
|
117
|
-
.input(sendSchema)
|
|
118
|
-
.handler(async ({ input }) => {
|
|
119
|
-
const log = getAppLogger();
|
|
120
|
-
const phone = input.phone.trim();
|
|
121
|
-
const message = input.message;
|
|
122
|
-
if (!phone || !message) {
|
|
123
|
-
return { ok: false as const, error: "phone and message are required" };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
try {
|
|
127
|
-
const [baseUrl, rawSecret] = await Promise.all([
|
|
128
|
-
resolveMessagingBaseUrl(),
|
|
129
|
-
loadRegistryConfig(
|
|
130
|
-
"secret",
|
|
131
|
-
"railway-scala-messaging-whatsapp-webhook-secret",
|
|
132
|
-
MessagingSecretSchema,
|
|
133
|
-
).catch(() => {
|
|
134
|
-
throw new Error(
|
|
135
|
-
"secret row railway-scala-messaging-whatsapp-webhook-secret is missing — " +
|
|
136
|
-
"the messaging service send endpoint cannot be authenticated",
|
|
137
|
-
);
|
|
138
|
-
}),
|
|
139
|
-
]);
|
|
140
|
-
const decrypted = decryptSecretConfig(
|
|
141
|
-
rawSecret as SecretConfigLike,
|
|
142
|
-
) as { token?: string };
|
|
143
|
-
if (!decrypted.token) {
|
|
144
|
-
return {
|
|
145
|
-
ok: false as const,
|
|
146
|
-
error: "whatsapp webhook secret row has no token",
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const response = await fetch(
|
|
151
|
-
`${baseUrl}/api/messaging/whatsapp/send`,
|
|
152
|
-
{
|
|
153
|
-
method: "POST",
|
|
154
|
-
headers: {
|
|
155
|
-
"Content-Type": "application/json",
|
|
156
|
-
// The Rust handler accepts x-webhook-secret (or
|
|
157
|
-
// x-api-key) against WHATSAPP_WEBHOOK_SECRET.
|
|
158
|
-
"x-webhook-secret": decrypted.token,
|
|
159
|
-
},
|
|
160
|
-
body: JSON.stringify({ phone, message }),
|
|
161
|
-
signal: AbortSignal.timeout(30_000),
|
|
162
|
-
},
|
|
163
|
-
);
|
|
164
|
-
if (response.status === 401) {
|
|
165
|
-
return {
|
|
166
|
-
ok: false as const,
|
|
167
|
-
error: "messaging service refused the webhook secret (401)",
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
const body = (await response.json().catch(() => null)) as {
|
|
171
|
-
// The Rust handler answers { ok, sent, messageId } (camelCase).
|
|
172
|
-
messageId?: string;
|
|
173
|
-
error?: string;
|
|
174
|
-
} | null;
|
|
175
|
-
if (!response.ok) {
|
|
176
|
-
return {
|
|
177
|
-
ok: false as const,
|
|
178
|
-
error:
|
|
179
|
-
body?.error ??
|
|
180
|
-
`messaging service send failed: ${response.status}`,
|
|
181
|
-
};
|
|
182
|
-
}
|
|
183
|
-
// The message_id is the only thing that makes delivery provable —
|
|
184
|
-
// hand it back so the caller can correlate the ack (gowa.rs
|
|
185
|
-
// parse_message_id contract).
|
|
186
|
-
log.info("[fn.messagingWhatsappSend] sent", {
|
|
187
|
-
phone,
|
|
188
|
-
message_id: body?.messageId ?? null,
|
|
189
|
-
});
|
|
190
|
-
return {
|
|
191
|
-
ok: true as const,
|
|
192
|
-
message_id: body?.messageId ?? null,
|
|
193
|
-
};
|
|
194
|
-
} catch (err) {
|
|
195
|
-
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
196
|
-
log.error(`[fn.messagingWhatsappSend] failed: ${errorMessage}`);
|
|
197
|
-
return { ok: false as const, error: errorMessage };
|
|
198
|
-
}
|
|
199
|
-
}),
|
|
200
|
-
};
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* `regions.resolvePanelData` — resolves a page-kind panel's OWN loaderData so a
|
|
7
|
-
* panel region renders blocks against the panel page's data, not the host
|
|
8
|
-
* page's. RegionPageSwap calls this on panel-open + injects the result via
|
|
9
|
-
* LiveDataProvider("page-data"), making panel-embedded data blocks (e.g. the
|
|
10
|
-
* workload Chart) self-sufficient: each panel carries its own data, no host
|
|
11
|
-
* coupling + no preloading the host route.
|
|
12
|
-
*
|
|
13
|
-
* Flow (registry reads via the loadRegistryConfig helper — registry-via-helper):
|
|
14
|
-
* panel_view.content.loader_slug → resource_loader config → orgId derived from
|
|
15
|
-
* the serving website (builda_websites.organisation_id) → resolveFromLoaderConfig
|
|
16
|
-
* → the loader's resolved sources. Panels without a loader_slug (e.g. the AI
|
|
17
|
-
* panel, whose block self-loads) return null — no extra round-trip, no behavior
|
|
18
|
-
* change.
|
|
19
|
-
*/
|
|
20
|
-
import { os } from "@orpc/server";
|
|
21
|
-
import * as v from "valibot";
|
|
22
|
-
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
23
|
-
import type { PanelViewKey } from "@teamscala/db/registry/generated/panel-view";
|
|
24
|
-
import type { ResourceLoaderKey } from "@teamscala/db/registry/generated/resource-loader";
|
|
25
|
-
import { resolveFromLoaderConfig } from "../resolve-from-loader-config.ts";
|
|
26
|
-
|
|
27
|
-
interface Context {
|
|
28
|
-
prisma: {
|
|
29
|
-
builda_websites: {
|
|
30
|
-
findFirst: (args: unknown) => Promise<{
|
|
31
|
-
organisation_id?: string | null;
|
|
32
|
-
} | null>;
|
|
33
|
-
};
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const panelViewSchema = v.looseObject({
|
|
38
|
-
content: v.looseObject({ loader_slug: v.optional(v.string()) }),
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
export const resolvePanelDataHandler = os
|
|
42
|
-
.$context<Context>()
|
|
43
|
-
.input(
|
|
44
|
-
v.object({
|
|
45
|
-
panelSlug: v.string(),
|
|
46
|
-
websiteSlug: v.string(),
|
|
47
|
-
/** Tenant orgId (preferred over the website's organisation_id, which is
|
|
48
|
-
* the platform org on a multi-tenant portal — not the tenant). The
|
|
49
|
-
* caller (RegionPageSwap) threads this from __PAGE_DATA__.orgId. */
|
|
50
|
-
orgId: v.optional(v.string()),
|
|
51
|
-
}),
|
|
52
|
-
)
|
|
53
|
-
.handler(async ({ input, context }) => {
|
|
54
|
-
// panel_view → loader_slug (null when missing or the panel has no loader).
|
|
55
|
-
let loaderSlug: string | undefined;
|
|
56
|
-
try {
|
|
57
|
-
// THE SLUG IS RUNTIME-SUPPLIED, so it cannot be statically a
|
|
58
|
-
// PanelViewKey: it arrives from the browser as the opened panel's
|
|
59
|
-
// name. The lookup itself is the validation — loadRegistryConfig
|
|
60
|
-
// throws when no such row exists, and the catch below turns that into
|
|
61
|
-
// the documented null. Narrowing here states that contract rather
|
|
62
|
-
// than asserting the value is known good.
|
|
63
|
-
const pv = await loadRegistryConfig(
|
|
64
|
-
"panel_view",
|
|
65
|
-
input.panelSlug as PanelViewKey,
|
|
66
|
-
panelViewSchema,
|
|
67
|
-
);
|
|
68
|
-
loaderSlug = pv.content?.loader_slug;
|
|
69
|
-
} catch {
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
if (!loaderSlug) return null;
|
|
73
|
-
// resource_loader → its config (loose schema; resolveFromLoaderConfig owns
|
|
74
|
-
// the typed LoaderConfig contract).
|
|
75
|
-
let loader: unknown;
|
|
76
|
-
try {
|
|
77
|
-
loader = await loadRegistryConfig(
|
|
78
|
-
"resource_loader",
|
|
79
|
-
// Same contract: loader_slug is read from the panel row's loose
|
|
80
|
-
// content schema as a plain string, and a missing row throws into
|
|
81
|
-
// the catch below.
|
|
82
|
-
loaderSlug as ResourceLoaderKey,
|
|
83
|
-
v.unknown(),
|
|
84
|
-
);
|
|
85
|
-
} catch {
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
// Tenant orgId: prefer the caller-provided value (the route's tenant org,
|
|
89
|
-
// threaded from __PAGE_DATA__.orgId); fall back to the website's
|
|
90
|
-
// organisation_id (the platform org — wrong for multi-tenant portals, but
|
|
91
|
-
// the only derivable value when the caller omits it).
|
|
92
|
-
let orgId = input.orgId;
|
|
93
|
-
if (!orgId) {
|
|
94
|
-
const site = await context.prisma.builda_websites.findFirst({
|
|
95
|
-
where: { OR: [{ slug: input.websiteSlug }, { id: input.websiteSlug }] },
|
|
96
|
-
select: { organisation_id: true },
|
|
97
|
-
});
|
|
98
|
-
orgId = site?.organisation_id ?? undefined;
|
|
99
|
-
}
|
|
100
|
-
return await resolveFromLoaderConfig(
|
|
101
|
-
loader as Parameters<typeof resolveFromLoaderConfig>[0],
|
|
102
|
-
{ orgId },
|
|
103
|
-
);
|
|
104
|
-
});
|