@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.
Files changed (45) hide show
  1. package/package.json +1 -261
  2. package/src/agreement-gate/signing-session.ts +1 -1
  3. package/src/build-client-assets/styles-build.ts +11 -1
  4. package/src/mount-orpc-router.ts +1 -1
  5. package/src/register-page-server-client.ts +1 -15
  6. package/src/register-ws-routes.ts +1 -1
  7. package/src/fn-routers/agreement-certificate.ts +0 -224
  8. package/src/fn-routers/agreement-completion-email.ts +0 -114
  9. package/src/fn-routers/agreement-send-router.ts +0 -303
  10. package/src/fn-routers/agreement-sign-side-effects.ts +0 -192
  11. package/src/fn-routers/agreement-signing-evidence.ts +0 -105
  12. package/src/fn-routers/assessment-report-data.test.ts +0 -404
  13. package/src/fn-routers/assessment-report-data.ts +0 -485
  14. package/src/fn-routers/assessment-report-email.ts +0 -256
  15. package/src/fn-routers/assessment-report-pair.test.ts +0 -136
  16. package/src/fn-routers/assessment-report-pair.ts +0 -155
  17. package/src/fn-routers/assessment-research-report-data.test.ts +0 -233
  18. package/src/fn-routers/assessment-research-report-data.ts +0 -424
  19. package/src/fn-routers/assessment-scoring.ts +0 -105
  20. package/src/fn-routers/auth-router.ts +0 -65
  21. package/src/fn-routers/chat-session-router-shared.ts +0 -122
  22. package/src/fn-routers/chat-session-router.ts +0 -356
  23. package/src/fn-routers/conversation-ownership.ts +0 -24
  24. package/src/fn-routers/email-defaults.ts +0 -28
  25. package/src/fn-routers/email-link-ttl.ts +0 -28
  26. package/src/fn-routers/index.ts +0 -76
  27. package/src/fn-routers/linkedin-identity.test.ts +0 -27
  28. package/src/fn-routers/linkedin-identity.ts +0 -36
  29. package/src/fn-routers/messaging-whatsapp-router.test.ts +0 -121
  30. package/src/fn-routers/messaging-whatsapp-router.ts +0 -200
  31. package/src/fn-routers/panel-data-router.ts +0 -104
  32. package/src/fn-routers/persist-chat-messages.test.ts +0 -86
  33. package/src/fn-routers/persist-chat-messages.ts +0 -98
  34. package/src/fn-routers/proposal-send-router.ts +0 -207
  35. package/src/fn-routers/public-agreement-router.ts +0 -407
  36. package/src/fn-routers/public-assessment-router.test.ts +0 -437
  37. package/src/fn-routers/public-assessment-router.ts +0 -268
  38. package/src/fn-routers/public-gpt-session-router.ts +0 -378
  39. package/src/fn-routers/resend-client.ts +0 -39
  40. package/src/fn-routers/sign-in-flow-router.ts +0 -196
  41. package/src/fn-routers/user-preferences-router.ts +0 -130
  42. package/src/fn-routers/work-items-router.ts +0 -342
  43. package/src/page-server-orpc/audit-revert-router.ts +0 -102
  44. package/src/page-server-orpc/puck-pages-router.ts +0 -170
  45. 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
- }
@@ -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
- });