Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions apps/mesh/migrations/109-channels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { type Kysely, sql } from "kysely";

/**
* Org-chat channels. Each row is one configured chat-platform integration
* (Microsoft Teams, Discord, WhatsApp, ...). For per-org bot platforms
* (Teams/Discord) the row registers a synthetic bot org-member; for the shared
* WhatsApp concierge there is no bot (the real verified user answers), so
* `bot_user_id` is nullable. Inbound messages run a Decopilot agent turn and the
* reply is posted back to the platform.
*
* Mirrors the AI-provider-keys shape: org-scoped, secrets vault-encrypted into a
* single opaque blob (`encrypted_credentials`), never columnized. `metadata`
* carries only NON-secret display info (bot display name, etc.).
*
* Lifecycle: a channel is created as a `draft` (no credentials yet) so the
* inbound webhook URL — which embeds the channel id — exists before the admin
* configures the platform portal. `CHANNEL_TEST` flips it to `active`.
*/
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.createTable("channels")
.addColumn("id", "text", (col) => col.primaryKey())
.addColumn("organization_id", "text", (col) =>
col.notNull().references("organization.id").onDelete("cascade"),
)
// 'teams' | 'discord' — enforced at app level, not DB level.
.addColumn("channel_type", "text", (col) => col.notNull())
.addColumn("label", "text", (col) => col.notNull())
// Vault-encrypted JSON blob of the per-platform secret credentials.
// Nullable: a draft channel has no credentials until the configure step.
.addColumn("encrypted_credentials", "text")
// virtual_mcp_id of the Decopilot agent the bot runs. Nullable: bound during
// setup; runChannelTurn falls back to the org default home agent when unset.
.addColumn("agent_id", "text")
// Synthetic bot org-member (user.id) for Teams/Discord. Null for WhatsApp
// (no bot — the real verified user answers). App-managed (no FK cascade so
// the bot user/member teardown stays explicit in CHANNEL_DELETE).
.addColumn("bot_user_id", "text")
// JSON, non-secret display metadata (e.g. bot display name surfaced by TEST).
.addColumn("metadata", "text")
// 'draft' | 'active' | 'error' | 'disabled'
.addColumn("status", "text", (col) => col.notNull().defaultTo("draft"))
.addColumn("created_by", "text", (col) => col.notNull())
.addColumn("created_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
)
.execute();

await db.schema
.createIndex("idx_channels_org")
.on("channels")
.column("organization_id")
.execute();

await db.schema
.createIndex("idx_channels_org_type")
.on("channels")
.columns(["organization_id", "channel_type"])
.execute();
}

export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropTable("channels").execute();
}
51 changes: 51 additions & 0 deletions apps/mesh/migrations/110-user-phones.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { type Kysely, sql } from "kysely";

/**
* Verified WhatsApp phone link per user (for the shared concierge number).
*
* Verification is inbound-only: Studio issues a unique `code`, the user sends it
* from their WhatsApp to the concierge number, and the inbound proves ownership
* — so `phone` is null until the code arrives and `verified_at` is stamped then.
* One link per user; a verified phone maps to exactly one user.
* `selected_organization_id` remembers which org answers when the user belongs
* to several WhatsApp-enabled orgs.
*/
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.createTable("user_phones")
.addColumn("id", "text", (col) => col.primaryKey())
.addColumn("user_id", "text", (col) =>
col.notNull().references("user.id").onDelete("cascade").unique(),
)
// Canonical E.164 digits (no '+'); null until the verification code arrives.
.addColumn("phone", "text")
.addColumn("verified_at", "timestamptz")
// Studio-issued pending code the user must send to verify (then cleared).
.addColumn("code", "text")
.addColumn("code_expires_at", "timestamptz")
.addColumn("selected_organization_id", "text", (col) =>
col.references("organization.id").onDelete("set null"),
)
.addColumn("created_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
)
.execute();

// Codes are matched against inbound message text — must be unique & indexed.
await sql`
CREATE UNIQUE INDEX idx_user_phones_code
ON user_phones (code)
WHERE code IS NOT NULL
`.execute(db);

// A verified phone resolves to exactly one user (inbound routing key).
await sql`
CREATE UNIQUE INDEX idx_user_phones_verified
ON user_phones (phone)
WHERE verified_at IS NOT NULL
`.execute(db);
}

export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropTable("user_phones").execute();
}
4 changes: 4 additions & 0 deletions apps/mesh/migrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ import * as migration105orgfs from "./105-org-fs.ts";
import * as migration106automationtools from "./106-automation-tools.ts";
import * as migration107orgfspublicorg from "./107-org-fs-public-org.ts";
import * as migration108automationmaxagentsteps from "./108-automation-max-agent-steps.ts";
import * as migration109channels from "./109-channels.ts";
import * as migration110userphones from "./110-user-phones.ts";

/**
* Core migrations for the Mesh application.
Expand Down Expand Up @@ -236,6 +238,8 @@ const migrations: Record<string, Migration> = {
"106-automation-tools": migration106automationtools,
"107-org-fs-public-org": migration107orgfspublicorg,
"108-automation-max-agent-steps": migration108automationmaxagentsteps,
"109-channels": migration109channels,
"110-user-phones": migration110userphones,
};

export default migrations;
10 changes: 10 additions & 0 deletions apps/mesh/src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
import { handleApiError } from "./error-handler";
import { resolveOrgFromPath } from "./middleware/resolve-org-from-path";
import { createOrgScopedApi } from "./routes/org-scoped";
import { createWhatsappIngestRoutes } from "./routes/whatsapp-ingest";
import { createLinkWorkRoutes } from "./routes/decopilot/link-work-routes";
import { createLinkControlRoutes } from "./routes/decopilot/link-control-routes";
import { createLinkProxyRoutes } from "./routes/decopilot/link-proxy-routes";
Expand Down Expand Up @@ -155,6 +156,7 @@ import {
sweepOrphanedWorkflows,
} from "../dispatch-queue/dbos-orphan-recovery";
import { backfillStudioPackForAllOrgs } from "../auth/install-studio-pack-workflow";
import { setChannelRuntime } from "../channels/runtime";
import { DBOS } from "@dbos-inc/dbos-sdk";
import {
dispatchRunAndWait,
Expand Down Expand Up @@ -1424,6 +1426,10 @@ export async function createApp(options: CreateAppOptions = {}) {
meshContextFactory: automationContextFactory,
});

// Channel inbound webhooks build a bot-scoped context the same way
// automations do (background context factory, no HTTP session).
setChannelRuntime({ meshContextFactory: automationContextFactory });

// Same deps shape as automations — the per-thread gate calls
// `dispatchRunAndWait` once the queue lets a message through. Wiring
// happens before `DBOS.launch()` for the same reasons.
Expand Down Expand Up @@ -2141,6 +2147,10 @@ export async function createApp(options: CreateAppOptions = {}) {
watchHandler,
betterAuthProtectedResourceHandler,
});
// WhatsApp concierge ingest is global (routes by phone, not org). Mount BEFORE
// the `/api/:org` catch-all so `/api/whatsapp/ingest` isn't treated as an org
// slug.
app.route("/api/whatsapp", createWhatsappIngestRoutes({ db: database.db }));
app.route("/api/:org", orgScopedApi);

// ============================================================================
Expand Down
2 changes: 1 addition & 1 deletion apps/mesh/src/api/routes/decopilot/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ function toModelInfo(resolved: Awaited<ReturnType<typeof resolveTier>>) {
* can compose a ModelsConfig the same way HTTP chat does, instead of
* duplicating the tier-resolution + tryResolve fallback logic.
*/
async function resolvePerRequestModels(
export async function resolvePerRequestModels(
ctx: StudioContext,
tier: SimpleModeTier | undefined,
harnessId: HarnessId | null | undefined,
Expand Down
60 changes: 60 additions & 0 deletions apps/mesh/src/api/routes/whatsapp-ingest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, expect, it } from "bun:test";
import { resolveTargetOrg, type WhatsappEnabledOrg } from "./whatsapp-ingest";

const A: WhatsappEnabledOrg = { orgId: "o-a", orgName: "Alpha", agentId: "ag" };
const B: WhatsappEnabledOrg = { orgId: "o-b", orgName: "Beta", agentId: "ag" };
const C: WhatsappEnabledOrg = { orgId: "o-c", orgName: "Gamma", agentId: "ag" };

describe("resolveTargetOrg", () => {
it("none when the user has no enabled orgs", () => {
expect(
resolveTargetOrg({ text: "hi", orgs: [], selectedOrgId: null }),
).toEqual({
kind: "none",
});
});

it("routes straight through with a single org", () => {
expect(
resolveTargetOrg({ text: "hello", orgs: [A], selectedOrgId: null }),
).toEqual({ kind: "route", org: A });
});

it("routes to the remembered selection when still enabled", () => {
expect(
resolveTargetOrg({ text: "hello", orgs: [A, B], selectedOrgId: "o-b" }),
).toEqual({ kind: "route", org: B });
});

it("asks the user to pick when multiple and none selected", () => {
expect(
resolveTargetOrg({ text: "hello", orgs: [A, B], selectedOrgId: null }),
).toEqual({ kind: "pick" });
});

it("selects by a bare number against the sorted list", () => {
expect(
resolveTargetOrg({ text: "2", orgs: [A, B, C], selectedOrgId: null }),
).toEqual({ kind: "select", org: B });
});

it("ignores out-of-range numbers (still pick)", () => {
expect(
resolveTargetOrg({ text: "9", orgs: [A, B], selectedOrgId: null }),
).toEqual({ kind: "pick" });
});

it("treats numeric input as chat once an org is selected (route, not re-select)", () => {
expect(
resolveTargetOrg({ text: "2", orgs: [A, B], selectedOrgId: "o-a" }),
).toEqual({ kind: "route", org: A });
});

it("recognizes switch commands (case-insensitive)", () => {
for (const t of ["switch", "/switch", "Orgs", " /orgs "]) {
expect(
resolveTargetOrg({ text: t, orgs: [A, B], selectedOrgId: "o-a" }),
).toEqual({ kind: "switch" });
}
});
});
Loading