Skip to content

Commit cfa9311

Browse files
authored
feat(channels): connect several pages and numbers in one flow (#1107)
1 parent 5a7d3eb commit cfa9311

294 files changed

Lines changed: 82845 additions & 7766 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/integration-channel/SKILL.md

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ description: >-
2020
7. [Post-Creation Verification](#post-creation-verification) — lint, install, build
2121
8. [Platform Credentials](#platform-credentials-only-if-needed) — optional OAuth app credentials
2222
9. [Webhook Flow](#webhook-flow)
23-
10. [Existing Integrations Reference](#existing-integrations-reference)
23+
10. [Multi-Account Pickers](#multi-account-pickers)
24+
11. [Existing Integrations Reference](#existing-integrations-reference)
2425

2526
---
2627

@@ -547,6 +548,64 @@ branded domain, so a webhook on the reseller domain silently fails. See
547548
`features/integration-whatsapp/actions/webhook-url.ts` and `docs/tenancy.md`
548549
(provider-console registration runbook).
549550

551+
## Multi-Account Pickers
552+
553+
Channels whose provider returns a list of connectable accounts — Messenger
554+
pages, Instagram-via-Facebook accounts, WhatsApp phone numbers — share one
555+
implementation instead of each building its own picker:
556+
`apps/builder/src/features/channel-connect/` (`ConnectSelectionForm` +
557+
`useConnectFlow` + `ConnectManyDialog` + `CoexistStep`).
558+
559+
- **Single-account server core + oRPC route** — one plain server function per
560+
provider id (e.g. `actions/connect-page.ts`'s `connectMessengerPage`),
561+
returning `ConnectActionResult<TOutcome>` (`{ kind: "outcome", outcome }` or
562+
`{ kind: "sessionError", code }`) — never a thrown exception the client has
563+
to classify, and never a 500 (the route would be unclassifiable). Build the
564+
three outcome literals with `lib/connect-action-outcomes.ts`'s
565+
`notSelectableOutcome` / `duplicatedOutcome` / `connectedOutcome`, wrap
566+
best-effort follow-ups (branding, tag scan, …) in `runConnectFollowUps`,
567+
and convert the core's outer catch with `toConnectActionFailure`. Expose it
568+
as `POST /api/channels/<channel>/connect` from the feature's `api/`
569+
folder (`authorizedAPI`, ids-only input, registered through the feature's
570+
`api/index.ts`) — **not** a server action: Next serializes server actions
571+
from one browser, so the picker's batch could only connect one account at a
572+
time. Add a server action only for a form that genuinely needs one (as
573+
WhatsApp's top-level connect form does), delegating to the same core.
574+
- **`resolveConnectSession`** (`lib/resolve-connect-session.ts`) — reads the
575+
pending-auth cookie or signup session for both legs (initial provider list
576+
fetch and the per-id connect call); returns the same session-error codes
577+
the outcome wire type carries.
578+
- **Client side** — every picker posts through `lib/connect-client.ts`'s
579+
`connectViaApi` (path from `CONNECT_CHANNEL_REGISTRY[channel].connectPath`),
580+
which turns any transport failure into the batch's own `failed`/`unknown`
581+
outcome. `useConnectFlow` runs a single pick inline (button spinner) and
582+
fans 2+ picks out through `ConnectManyDialog`'s status list,
583+
`CONNECT_CONCURRENCY` at a time.
584+
On a coexist-eligible channel (`isCoexistChannel`,
585+
`packages/utils/channel.ts`) the "sync existing history" opt-in is a
586+
**per-row switch in the picker** (`CoexistRowSwitch` /
587+
`CoexistOptionsPanel`), not a step: each row's call runs right after that
588+
row connects, via `lib/coexist-client.ts`'s `setCoexist`
589+
(`useConnectBatch`'s `afterConnect` for a batch, inline in `useConnectFlow`
590+
for a single pick). A channel with its own picker form gets the same rule
591+
by calling `hooks/use-coexist-selection.tsx`'s `useCoexistSelection`
592+
don't re-derive `coexistIds ⊆ selectedIds` by hand. The dialog's Continue
593+
skips every channel extra step on the session errors in
594+
`SESSION_ERRORS_SKIPPING_EXTRA_STEPS` (`lib/row-status.ts`), whose routes
595+
`workspaceAuthorizedMidddleware` would deny anyway.
596+
`CoexistStep`/`CoexistPopup` survive only for WhatsApp's
597+
manual/auto-select direct path.
598+
- **New channel registration** — add one entry to
599+
`lib/registry.ts`'s `CONNECT_CHANNEL_REGISTRY`, typed
600+
`satisfies Record<ConnectPickerChannel, ConnectChannelConfig>` so a missing
601+
channel fails to compile. That file is the only place in
602+
`channel-connect` allowed to hard-code a channel name.
603+
- **Row/warning copy**`lib/row-status.ts` (`ROW_STATUS`,
604+
`REASON_MESSAGE_KEYS`, `WARNING_MESSAGE_KEYS`,
605+
`SESSION_ERROR_MESSAGE_KEYS`) is the single source of i18n keys for every
606+
row state, failure reason, and outcome warning shown in the dialog — reuse
607+
these, don't add a channel-local copy of the same labels.
608+
550609
## Existing Integrations Reference
551610

552611
| Integration | Auth type | Platform credentials? | Notes |

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ For automatic context injection on every prompt, add the hook to your **own** `.
116116

117117
- Schema and migrations: **`packages/database`** (Drizzle). Use the **drizzle-database** skill in `.agents/skills/drizzle-database/SKILL.md` for migrations and query patterns.
118118
- **Migration safety:** Never run or apply `db:migrate` automatically. Generate and inspect migration SQL when needed, then wait for explicit user approval before applying it. This applies even when a plan lists `db:migrate` as a verification step.
119+
- **Drift guard (no database needed, runs in `pnpm lint`):** `pnpm --filter @chatbotx.io/database db:check-drift` runs `drizzle-kit generate` into a throwaway probe folder and fails if any SQL would be emitted — i.e. `src/schema` and the migration snapshot chain disagree. It is registered as that package's `lint` script, so `pnpm lint` covers it. The probe folder is swept on exit and on signals, and `run-migrations.mjs` refuses to apply a leftover one.
120+
- **Database-backed tests (opt-in):** `pnpm --filter @chatbotx.io/database test:db` runs `packages/database/__tests__/integration/`, which reads `information_schema.columns` from a real Postgres. They skip themselves under plain `pnpm test` (the vitest preset points `DATABASE_URL` at a non-routable sentinel) and insert no rows.
121+
- **A drizzle `.default()` is not proof of a database default.** `.default(...)` makes the column optional in `$inferInsert` and makes drizzle emit the bare `DEFAULT` keyword for an omitted key — it never inlines the value. drizzle-kit serializes `jsonb().default(sql`[]`)` as *no* default, so schema, snapshot and database all agree the column has none while TypeScript still calls it optional: omitting the key is a NOT NULL violation at runtime, and `db:check-drift` cannot see it. 19 columns are in that state today; they are pinned in `__tests__/integration/schema-default-parity.test.ts`, and every insert path for one of them must write the value explicitly.
119122

120123
### Workers & queues
121124

apps/builder/__tests__/channel-connect-credential-consistency.test.ts

Lines changed: 138 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -9,46 +9,120 @@ import { beforeEach, describe, expect, test, vi } from "vitest"
99
// request host. Those legs run post-relay on the broker or branded host
1010
// interchangeably, so a host-derived completion leg could silently pick a
1111
// different OAuth app than the one the start leg authorized against,
12-
// breaking the token exchange. This test pins that each completion leg
13-
// forwards `parsedInput.workspaceId` into `resolvePlatformOwnerId`/
14-
// `resolveOwnerForWorkspace` unchanged, rather than re-deriving it.
12+
// breaking the token exchange.
13+
//
14+
// Messenger's `connectMessengerPage` (plan §2.4/§4.7) gets its `workspaceId`
15+
// from the encrypted, httpOnly pending-auth cookie — never client input —
16+
// so this test pins that it forwards the COOKIE's workspaceId into
17+
// `resolvePlatformOwnerId`, and that a schema-invalid/missing cookie is
18+
// rejected with a `sessionError` before the resolver is ever called.
19+
// Instagram's two legs (phase 4) now go through the same
20+
// `resolveConnectSession` helper, so they get the identical treatment: the
21+
// wire payload carries only `igId`, never `workspaceId`.
1522
// ---------------------------------------------------------------------------
1623

17-
const { mockResolvePlatformOwnerId, mockResolveForOwner } = vi.hoisted(() => ({
24+
const {
25+
mockResolvePlatformOwnerId,
26+
mockResolveForOwner,
27+
mockReadPendingAuth,
28+
mockWorkspaceFind,
29+
mockIsMember,
30+
} = vi.hoisted(() => ({
1831
mockResolvePlatformOwnerId: vi.fn(async () => "resolved-owner-1"),
1932
mockResolveForOwner: vi.fn(async () => undefined),
33+
mockReadPendingAuth: vi.fn(
34+
async (): Promise<{
35+
userToken: string
36+
workspaceId: string
37+
referer: string
38+
version: string
39+
expiresAt: number
40+
} | null> => ({
41+
userToken: "user-token-1",
42+
workspaceId: "ws-1",
43+
referer: "/channels/create",
44+
version: "v23.0",
45+
expiresAt: Date.now() + 600_000,
46+
}),
47+
),
48+
mockWorkspaceFind: vi.fn(async () => ({
49+
id: "ws-1",
50+
ownerId: "owner-1",
51+
})),
52+
mockIsMember: vi.fn(async () => true),
2053
}))
2154

2255
// A passthrough action-client chain: `.inputSchema()`/`.action()` just
2356
// return their handler so the test can call it directly with a hand-built
2457
// `{ ctx, parsedInput }`, without instantiating the real safe-action /
2558
// next-safe-action machinery. Mirrors the pattern in
2659
// `instagram-facebook-settings-actions.test.ts`.
27-
vi.mock("@/lib/safe-action", () => {
28-
const chain: Record<string, unknown> = {}
29-
chain.inputSchema = () => chain
30-
chain.action = (handler: unknown) => handler
31-
return { authActionClient: chain }
32-
})
33-
3460
vi.mock("@/lib/platform-credential-owner", () => ({
3561
resolvePlatformOwnerId: mockResolvePlatformOwnerId,
3662
}))
3763

64+
// Bypassed entirely — this test is about credential-owner resolution, not
65+
// the trial/MAC gate (covered by `messenger-select-page-action.test.ts`).
66+
vi.mock("@/lib/workspace/authorize-workspace-access", () => ({
67+
checkWorkspaceOwnerAccess: vi.fn(async () => null),
68+
workspaceAccessDenialException: vi.fn(
69+
(reason: string) => new Error(`denied:${reason}`),
70+
),
71+
}))
72+
3873
vi.mock("@chatbotx.io/business", () => ({
3974
platformCredentialService: { resolveForOwner: mockResolveForOwner },
40-
workspaceService: { create: vi.fn() },
41-
resolveTenantSettings: vi.fn(),
75+
workspaceService: {
76+
create: vi.fn(),
77+
find: mockWorkspaceFind,
78+
},
79+
workspaceMemberService: { isMember: mockIsMember },
80+
resolveTenantSettings: vi.fn(async () => ({ appUrl: "https://app.test" })),
4281
updateInstagramIntegrationUserInfo: vi.fn(),
4382
updateMessengerIntegrationUserInfo: vi.fn(),
83+
messengerIntegrationService: {
84+
findConnectedPageIds: vi.fn(async () => new Set<string>()),
85+
connectPage: vi.fn(),
86+
updateUserInfo: vi.fn(),
87+
},
88+
instagramIntegrationService: {
89+
findConnectedIgIds: vi.fn(async () => new Set<string>()),
90+
connectAccount: vi.fn(),
91+
updateUserInfo: vi.fn(),
92+
},
4493
tagSyncService: { enqueueChannelScan: vi.fn() },
4594
userQuotaService: { getAccessState: vi.fn(async () => ({ blocked: false })) },
4695
connectChannelIntegration: vi.fn(),
96+
buildContext: vi.fn(async () => ({})),
4797
}))
4898

49-
vi.mock("@chatbotx.io/business/errors", () => ({
50-
ChatbotXException: class ChatbotXException extends Error {},
51-
}))
99+
// The REAL session/item-outcome mapping table — `resolveConnectSession`
100+
// (called by `connectMessengerPage`) throws genuine exceptions from the
101+
// (also real, below) `@chatbotx.io/business/errors`, so this file lets the
102+
// real mapping classify them instead of re-implementing that table as a
103+
// second source of truth that could silently drift from production.
104+
vi.mock("@chatbotx.io/business/inbox/connect-outcome", async (importOriginal) =>
105+
importOriginal(),
106+
)
107+
108+
vi.mock("@chatbotx.io/business/errors", () => {
109+
class ChatbotXException extends Error {
110+
code?: string
111+
constructor(message: string, code?: string) {
112+
super(message)
113+
this.code = code
114+
}
115+
}
116+
return {
117+
ChatbotXException,
118+
connectSessionExpiredException: (message: string) =>
119+
new ChatbotXException(message, "connectSessionExpired"),
120+
notWorkspaceMemberException: () =>
121+
new ChatbotXException("not a member", "notWorkspaceMember"),
122+
credentialMissingException: (message: string) =>
123+
new ChatbotXException(message, "credentialMissing"),
124+
}
125+
})
52126

53127
vi.mock("@chatbotx.io/database/client", () => ({
54128
db: { transaction: vi.fn(async () => undefined) },
@@ -67,6 +141,17 @@ vi.mock("@chatbotx.io/database/schema", async (importOriginal) => {
67141

68142
vi.mock("@chatbotx.io/integration-messenger", () => ({
69143
integration: { runChannelHandler: vi.fn() },
144+
getUserPages: vi.fn(async () => ({
145+
pages: [
146+
{
147+
id: "p1",
148+
name: "Page",
149+
access_token: "page-token",
150+
isConnectable: true,
151+
},
152+
],
153+
bmLookupFailed: false,
154+
})),
70155
}))
71156
vi.mock("@chatbotx.io/integration-messenger/apis/page", () => ({
72157
exchangeLongLivedToken: vi.fn(),
@@ -107,7 +192,8 @@ vi.mock("@/lib/facebook-pending-auth", () => ({
107192
FB_MESSENGER_PENDING_AUTH_COOKIE: "fb_messenger_pending_auth",
108193
FB_INSTAGRAM_FACEBOOK_PENDING_AUTH_COOKIE:
109194
"fb_instagram_facebook_pending_auth",
110-
readPendingAuth: vi.fn(async () => null),
195+
FB_INSTAGRAM_PENDING_AUTH_COOKIE: "fb_instagram_pending_auth",
196+
readPendingAuth: mockReadPendingAuth,
111197
}))
112198
vi.mock("@/lib/integration-user-info", () => ({
113199
persistIntegrationUserInfo: vi.fn(),
@@ -116,23 +202,16 @@ vi.mock("@/lib/log", () => ({
116202
logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
117203
}))
118204

119-
const { selectPageAction } = await import(
120-
"../src/features/integration-messenger/actions/select-page.action"
205+
const { connectMessengerPage } = await import(
206+
"../src/features/integration-messenger/actions/connect-page"
121207
)
122-
const { selectAccountAction } = await import(
123-
"../src/features/integration-instagram/actions/select-account.action"
208+
const { connectInstagramAccount } = await import(
209+
"../src/features/integration-instagram/actions/connect-account"
124210
)
125-
const { selectFacebookAccountAction } = await import(
126-
"../src/features/integration-instagram/actions/select-account-facebook.action"
211+
const { connectInstagramAccountViaFacebook } = await import(
212+
"../src/features/integration-instagram/actions/connect-account-facebook"
127213
)
128214

129-
type ActionHandler = (args: {
130-
parsedInput: Record<string, unknown>
131-
ctx: { user: { id: string } }
132-
}) => Promise<unknown>
133-
134-
const call = (action: unknown) => action as ActionHandler
135-
136215
describe("channel connect completion legs never re-derive the credential owner from the host", () => {
137216
beforeEach(() => {
138217
vi.clearAllMocks()
@@ -141,36 +220,43 @@ describe("channel connect completion legs never re-derive the credential owner f
141220
// resolver call — exactly the point this test needs to observe, without
142221
// running the rest of the (heavily mocked) connect transaction.
143222
mockResolveForOwner.mockResolvedValue(undefined)
223+
mockReadPendingAuth.mockResolvedValue({
224+
userToken: "user-token-1",
225+
workspaceId: "ws-1",
226+
referer: "/channels/create",
227+
version: "v23.0",
228+
expiresAt: Date.now() + 600_000,
229+
})
230+
mockWorkspaceFind.mockResolvedValue({ id: "ws-1", ownerId: "owner-1" })
231+
mockIsMember.mockResolvedValue(true)
144232
})
145233

146-
test("select-page.action (messenger) forwards workspaceId unchanged", async () => {
147-
await call(selectPageAction)({
148-
parsedInput: { workspaceId: "ws-1", pageId: "p1", pageName: "Page" },
149-
ctx: { user: { id: "user-1" } },
150-
}).catch(() => undefined)
234+
test("connectMessengerPage resolves the credential owner from the pending-auth cookie's workspaceId", async () => {
235+
await connectMessengerPage({ userId: "user-1", pageId: "p1" }).catch(
236+
() => undefined,
237+
)
151238

152239
expect(mockResolvePlatformOwnerId).toHaveBeenCalledWith({
153240
userId: "user-1",
154241
workspaceId: "ws-1",
155242
})
156243
})
157244

158-
test("select-account.action (instagram) forwards workspaceId unchanged", async () => {
159-
await call(selectAccountAction)({
160-
parsedInput: { workspaceId: "ws-1", igId: "ig1", igName: "IG" },
161-
ctx: { user: { id: "user-1" } },
162-
}).catch(() => undefined)
245+
test("connectInstagramAccount resolves the credential owner from the pending-auth cookie's workspaceId", async () => {
246+
await connectInstagramAccount({ userId: "user-1", igId: "ig1" }).catch(
247+
() => undefined,
248+
)
163249

164250
expect(mockResolvePlatformOwnerId).toHaveBeenCalledWith({
165251
userId: "user-1",
166252
workspaceId: "ws-1",
167253
})
168254
})
169255

170-
test("select-account-facebook.action (instagram via Facebook) forwards workspaceId unchanged", async () => {
171-
await call(selectFacebookAccountAction)({
172-
parsedInput: { workspaceId: "ws-1", igId: "ig1", igName: "IG" },
173-
ctx: { user: { id: "user-1" } },
256+
test("connectInstagramAccountViaFacebook resolves the credential owner from the pending-auth cookie's workspaceId", async () => {
257+
await connectInstagramAccountViaFacebook({
258+
userId: "user-1",
259+
igId: "ig1",
174260
}).catch(() => undefined)
175261

176262
expect(mockResolvePlatformOwnerId).toHaveBeenCalledWith({
@@ -179,15 +265,16 @@ describe("channel connect completion legs never re-derive the credential owner f
179265
})
180266
})
181267

182-
test("no-workspace-yet connects (first channel ever) forward a nullish workspaceId, not a guessed one", async () => {
183-
await call(selectPageAction)({
184-
parsedInput: { workspaceId: undefined, pageId: "p1", pageName: "Page" },
185-
ctx: { user: { id: "user-1" } },
186-
}).catch(() => undefined)
268+
test("connectMessengerPage never calls the resolver when the pending-auth cookie is missing/schema-invalid", async () => {
269+
mockReadPendingAuth.mockResolvedValue(null)
187270

188-
expect(mockResolvePlatformOwnerId).toHaveBeenCalledWith({
271+
const result = await connectMessengerPage({
189272
userId: "user-1",
190-
workspaceId: undefined,
273+
pageId: "p1",
191274
})
275+
276+
expect(result).toEqual({ kind: "sessionError", code: "sessionExpired" })
277+
expect(mockResolvePlatformOwnerId).not.toHaveBeenCalled()
278+
expect(mockWorkspaceFind).not.toHaveBeenCalled()
192279
})
193280
})

0 commit comments

Comments
 (0)