From 82b3ad97173befcf1e95443a393644c99b751fba Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Sun, 9 Aug 2026 23:58:13 +0300 Subject: [PATCH 1/2] feat(bento): tag invitees with org:invite_accepted on accept Stop Invite-to-org reminder workflows when a role binding is written with reason Accepted invitation. Co-authored-by: Cursor --- docs/BENTO_EMAIL_PREFERENCES_SETUP.md | 14 ++ .../_backend/triggers/on_user_org_access.ts | 3 + .../_backend/utils/bento_org_invite.ts | 115 ++++++++++++++++ tests/bento-first-org-route.unit.test.ts | 24 +++- tests/bento-org-invite-accepted.unit.test.ts | 125 ++++++++++++++++++ 5 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 supabase/functions/_backend/utils/bento_org_invite.ts create mode 100644 tests/bento-org-invite-accepted.unit.test.ts diff --git a/docs/BENTO_EMAIL_PREFERENCES_SETUP.md b/docs/BENTO_EMAIL_PREFERENCES_SETUP.md index 26375916b0..1d392ac7eb 100644 --- a/docs/BENTO_EMAIL_PREFERENCES_SETUP.md +++ b/docs/BENTO_EMAIL_PREFERENCES_SETUP.md @@ -93,6 +93,20 @@ Tag does NOT contain: onboarding_disabled Do not check the suppression tag only when the workflow starts. It is a monotonic safety opt-out that can be added while the workflow is waiting. Keep this recovery email non-transactional so Bento's global unsubscribe state remains a delivery gate. +#### 3b. Invite-to-org reminders + +**Entry events**: `org:invite_new_capgo_user_to_org`, `org:invite_existing_capgo_user_to_org` + +**Exit tag**: `org:invite_accepted` + +Capgo adds `org:invite_accepted` when a user-org `role_bindings` row is written with reason `Accepted invitation` (dashboard RPC `accept_invitation_to_org` and the private accept-invitation API). Wire the Invite-to-org workflow with a Match before each reminder send: + +```text +Tag contains: org:invite_accepted → End +``` + +Queue delivery is at least once, so the tag sync can repeat; treat the tag as a sticky exit condition, not an entry trigger. + #### 4. Weekly Statistics **Events**: `user:weekly_stats` diff --git a/supabase/functions/_backend/triggers/on_user_org_access.ts b/supabase/functions/_backend/triggers/on_user_org_access.ts index d0183a5ef8..6969a2af74 100644 --- a/supabase/functions/_backend/triggers/on_user_org_access.ts +++ b/supabase/functions/_backend/triggers/on_user_org_access.ts @@ -1,6 +1,7 @@ import type { Database } from '../utils/supabase.types.ts' import { z } from 'zod' import { syncBentoFirstOrgOnRoleBindingWrite } from '../utils/bento_first_org.ts' +import { syncBentoOrgInviteAcceptedOnRoleBindingWrite } from '../utils/bento_org_invite.ts' import { BRES, createHono, middlewareAPISecret, simpleError, triggerValidator } from '../utils/hono.ts' import { version } from '../utils/version.ts' @@ -13,5 +14,7 @@ app.post('/', middlewareAPISecret, triggerValidator('role_bindings', ['INSERT', throw simpleError('invalid_payload', 'Invalid role binding id', { id: record.id }) await syncBentoFirstOrgOnRoleBindingWrite(c, bindingId.data) + // Tag invitees after accept so Invite-to-org Bento reminders can Match-and-exit. + await syncBentoOrgInviteAcceptedOnRoleBindingWrite(c, bindingId.data) return c.json(BRES) }) diff --git a/supabase/functions/_backend/utils/bento_org_invite.ts b/supabase/functions/_backend/utils/bento_org_invite.ts new file mode 100644 index 0000000000..9e9eebcd56 --- /dev/null +++ b/supabase/functions/_backend/utils/bento_org_invite.ts @@ -0,0 +1,115 @@ +import type { Context } from 'hono' +import type { MiddlewareKeyVariables } from './hono.ts' +import { syncBentoSubscriberTags } from './bento.ts' +import { cloudlog, cloudlogErr, serializeError } from './logging.ts' +import { closeClient, getPgClient } from './pg.ts' +import { normalizeBentoEmail } from './bento_first_org.ts' + +/** Bento Match on Invite-to-org reminders: stop when this tag is present. */ +export const BENTO_ORG_INVITE_ACCEPTED_TAG = 'org:invite_accepted' + +export const BENTO_ORG_INVITE_ACCEPTED_BINDING_REASON = 'Accepted invitation' + +interface InviteAcceptedBinding { + email: string | null + id: string + is_active: boolean + is_direct: boolean + org_id: string | null + principal_id: string + principal_type: string + reason: string | null + scope_type: string +} + +/** + * When a user-org role binding is created with reason "Accepted invitation", + * tag the invitee in Bento so invite-reminder workflows can exit. + */ +export async function syncBentoOrgInviteAcceptedOnRoleBindingWrite( + c: Context, + roleBindingId: string, +) { + const pgPool = getPgClient(c) + try { + let binding: InviteAcceptedBinding | undefined + const pgClient = await pgPool.connect() + try { + const result = await pgClient.query( + `SELECT + binding.id, + binding.principal_type, + binding.principal_id, + binding.scope_type, + binding.org_id, + binding.reason, + binding.is_direct, + (binding.expires_at IS NULL OR binding.expires_at > pg_catalog.now()) AS is_active, + users.email + FROM public.role_bindings AS binding + LEFT JOIN public.users AS users + ON users.id = binding.principal_id + WHERE binding.id = $1::uuid + LIMIT 1`, + [roleBindingId], + ) + binding = result.rows[0] + } + finally { + // Destroy this request-scoped socket before crossing into Bento. + pgClient.release(true) + } + + if ( + !binding + || binding.principal_type !== 'user' + || binding.scope_type !== 'org' + || !binding.org_id + || binding.is_direct !== true + || binding.is_active !== true + || binding.reason !== BENTO_ORG_INVITE_ACCEPTED_BINDING_REASON + || !binding.email + ) { + return + } + + const email = normalizeBentoEmail(binding.email) + const result = await syncBentoSubscriberTags(c, { + email, + segments: [BENTO_ORG_INVITE_ACCEPTED_TAG], + deleteSegments: [], + }) + + if (result === false) { + cloudlogErr({ + requestId: c.get('requestId'), + message: 'syncBentoOrgInviteAcceptedOnRoleBindingWrite failed', + roleBindingId, + orgId: binding.org_id, + principalId: binding.principal_id, + }) + return + } + + cloudlog({ + requestId: c.get('requestId'), + message: 'bento_org_invite_accepted_tagged', + roleBindingId, + orgId: binding.org_id, + principalId: binding.principal_id, + email, + tag: BENTO_ORG_INVITE_ACCEPTED_TAG, + }) + } + catch (error) { + cloudlogErr({ + requestId: c.get('requestId'), + message: 'syncBentoOrgInviteAcceptedOnRoleBindingWrite error', + roleBindingId, + error: serializeError(error), + }) + } + finally { + await closeClient(c, pgPool) + } +} diff --git a/tests/bento-first-org-route.unit.test.ts b/tests/bento-first-org-route.unit.test.ts index 4f7a51cd9d..6b3ff238dd 100644 --- a/tests/bento-first-org-route.unit.test.ts +++ b/tests/bento-first-org-route.unit.test.ts @@ -4,9 +4,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { syncBentoFirstOrgOnRoleBindingWriteMock, syncBentoFirstOrgOnUserCreateMock, + syncBentoOrgInviteAcceptedOnRoleBindingWriteMock, } = vi.hoisted(() => ({ syncBentoFirstOrgOnRoleBindingWriteMock: vi.fn(async () => undefined), syncBentoFirstOrgOnUserCreateMock: vi.fn(async () => undefined), + syncBentoOrgInviteAcceptedOnRoleBindingWriteMock: vi.fn(async () => undefined), })) vi.mock('../supabase/functions/_backend/utils/bento_first_org.ts', () => ({ @@ -14,6 +16,9 @@ vi.mock('../supabase/functions/_backend/utils/bento_first_org.ts', () => ({ syncBentoFirstOrgOnUserCreate: syncBentoFirstOrgOnUserCreateMock, })) +vi.mock('../supabase/functions/_backend/utils/bento_org_invite.ts', () => ({ + syncBentoOrgInviteAcceptedOnRoleBindingWrite: syncBentoOrgInviteAcceptedOnRoleBindingWriteMock, +})) const apiWorker = (await import('../cloudflare_workers/api/index.ts')).default const API_SECRET = 'test-secret' @@ -73,6 +78,7 @@ describe('first-organization lifecycle trigger route', () => { process.env.API_SECRET = API_SECRET vi.clearAllMocks() syncBentoFirstOrgOnRoleBindingWriteMock.mockResolvedValue(undefined) + syncBentoOrgInviteAcceptedOnRoleBindingWriteMock.mockResolvedValue(undefined) }) afterEach(() => { @@ -90,6 +96,8 @@ describe('first-organization lifecycle trigger route', () => { await expect(response.json()).resolves.toEqual({ status: 'ok' }) expect(syncBentoFirstOrgOnRoleBindingWriteMock).toHaveBeenCalledOnce() expect(syncBentoFirstOrgOnRoleBindingWriteMock).toHaveBeenCalledWith(expect.anything(), ROLE_BINDING_ID) + expect(syncBentoOrgInviteAcceptedOnRoleBindingWriteMock).toHaveBeenCalledOnce() + expect(syncBentoOrgInviteAcceptedOnRoleBindingWriteMock).toHaveBeenCalledWith(expect.anything(), ROLE_BINDING_ID) }) it('returns 5xx so the queue retries when lifecycle delivery fails', async () => { @@ -100,6 +108,18 @@ describe('first-organization lifecycle trigger route', () => { expect(response.status).toBeGreaterThanOrEqual(500) expect(response.status).toBeLessThan(600) expect(syncBentoFirstOrgOnRoleBindingWriteMock).toHaveBeenCalledOnce() + expect(syncBentoOrgInviteAcceptedOnRoleBindingWriteMock).not.toHaveBeenCalled() + }) + + it('returns 5xx so the queue retries when invite-accepted tagging fails', async () => { + syncBentoOrgInviteAcceptedOnRoleBindingWriteMock.mockRejectedValueOnce(new Error('Bento unavailable')) + + const response = await requestRoleBindingWrite('INSERT') + + expect(response.status).toBeGreaterThanOrEqual(500) + expect(response.status).toBeLessThan(600) + expect(syncBentoFirstOrgOnRoleBindingWriteMock).toHaveBeenCalledOnce() + expect(syncBentoOrgInviteAcceptedOnRoleBindingWriteMock).toHaveBeenCalledOnce() }) it.each([ @@ -110,6 +130,7 @@ describe('first-organization lifecycle trigger route', () => { expect(response.status).toBe(400) expect(syncBentoFirstOrgOnRoleBindingWriteMock).not.toHaveBeenCalled() + expect(syncBentoOrgInviteAcceptedOnRoleBindingWriteMock).not.toHaveBeenCalled() }) it.each([ @@ -126,6 +147,7 @@ describe('first-organization lifecycle trigger route', () => { error: 'invalid_payload', }) expect(syncBentoFirstOrgOnRoleBindingWriteMock).not.toHaveBeenCalled() + expect(syncBentoOrgInviteAcceptedOnRoleBindingWriteMock).not.toHaveBeenCalled() }) it.each([ @@ -139,8 +161,8 @@ describe('first-organization lifecycle trigger route', () => { expect(response.status).toBe(400) await expect(response.json()).resolves.toMatchObject({ error }) expect(syncBentoFirstOrgOnRoleBindingWriteMock).not.toHaveBeenCalled() + expect(syncBentoOrgInviteAcceptedOnRoleBindingWriteMock).not.toHaveBeenCalled() }) - it('registers the same route in the Supabase trigger router', async () => { // Importing this deployment entry point evaluates Deno.serve and starts a // server, so verify its static wiring without executing the module. diff --git a/tests/bento-org-invite-accepted.unit.test.ts b/tests/bento-org-invite-accepted.unit.test.ts new file mode 100644 index 0000000000..1a3361c210 --- /dev/null +++ b/tests/bento-org-invite-accepted.unit.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + closeClientMock, + getPgClientMock, + pgConnectMock, + pgQueryMock, + pgReleaseMock, + syncBentoSubscriberTagsMock, +} = vi.hoisted(() => { + const pgQueryMock = vi.fn<(query: string, params?: unknown[]) => Promise<{ rows: Record[] }>>(async () => ({ rows: [] })) + const pgReleaseMock = vi.fn<(destroy?: Error | boolean) => void>(() => undefined) + const pgConnectMock = vi.fn(async () => ({ query: pgQueryMock, release: pgReleaseMock })) + return { + closeClientMock: vi.fn(async () => undefined), + getPgClientMock: vi.fn(() => ({ connect: pgConnectMock, query: pgQueryMock })), + pgConnectMock, + pgQueryMock, + pgReleaseMock, + syncBentoSubscriberTagsMock: vi.fn<( + c: unknown, + update: { deleteSegments: string[], email: string, segments: string[] }, + ) => Promise>(async () => true), + } +}) + +vi.mock('../supabase/functions/_backend/utils/bento.ts', () => ({ + syncBentoSubscriberTags: syncBentoSubscriberTagsMock, +})) + +vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ + closeClient: closeClientMock, + getPgClient: getPgClientMock, +})) + +const { + BENTO_ORG_INVITE_ACCEPTED_TAG, + syncBentoOrgInviteAcceptedOnRoleBindingWrite, +} = await import('../supabase/functions/_backend/utils/bento_org_invite.ts') + +const ROLE_BINDING_ID = '33333333-3333-4333-8333-333333333333' +const ORG_ID = '22222222-2222-4222-8222-222222222222' +const USER_ID = '11111111-1111-4111-8111-111111111111' + +function acceptedBinding(overrides: Record = {}) { + return { + email: ' Invitee@Example.COM ', + id: ROLE_BINDING_ID, + is_active: true, + is_direct: true, + org_id: ORG_ID, + principal_id: USER_ID, + principal_type: 'user', + reason: 'Accepted invitation', + scope_type: 'org', + ...overrides, + } +} + +async function syncInviteAccepted(roleBindingId = ROLE_BINDING_ID) { + return await syncBentoOrgInviteAcceptedOnRoleBindingWrite( + { get: vi.fn(() => 'request-id') } as never, + roleBindingId, + ) +} + +describe('bento org invite accepted tag', () => { + beforeEach(() => { + vi.clearAllMocks() + syncBentoSubscriberTagsMock.mockResolvedValue(true) + pgQueryMock.mockResolvedValue({ rows: [acceptedBinding()] }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('tags the invitee email when the binding reason is Accepted invitation', async () => { + await syncInviteAccepted() + + expect(pgQueryMock).toHaveBeenCalledOnce() + expect(pgQueryMock.mock.calls[0]?.[1]).toEqual([ROLE_BINDING_ID]) + expect(pgReleaseMock).toHaveBeenCalledWith(true) + expect(syncBentoSubscriberTagsMock).toHaveBeenCalledOnce() + expect(syncBentoSubscriberTagsMock).toHaveBeenCalledWith(expect.anything(), { + deleteSegments: [], + email: 'invitee@example.com', + segments: [BENTO_ORG_INVITE_ACCEPTED_TAG], + }) + expect(closeClientMock).toHaveBeenCalledOnce() + }) + + it.each([ + ['wrong reason', { reason: 'SSO org membership provisioning' }], + ['inactive binding', { is_active: false }], + ['non-direct binding', { is_direct: false }], + ['non-org scope', { scope_type: 'app' }], + ['non-user principal', { principal_type: 'apikey' }], + ['missing email', { email: null }], + ['missing org', { org_id: null }], + ])('skips Bento when %s', async (_label, overrides) => { + pgQueryMock.mockResolvedValue({ rows: [acceptedBinding(overrides)] }) + + await syncInviteAccepted() + + expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled() + expect(closeClientMock).toHaveBeenCalledOnce() + }) + + it('skips Bento when the role binding row is missing', async () => { + pgQueryMock.mockResolvedValue({ rows: [] }) + + await syncInviteAccepted() + + expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled() + expect(closeClientMock).toHaveBeenCalledOnce() + }) + + it('swallows Bento sync failures without throwing', async () => { + syncBentoSubscriberTagsMock.mockResolvedValue(false) + + await expect(syncInviteAccepted()).resolves.toBeUndefined() + expect(closeClientMock).toHaveBeenCalledOnce() + }) +}) From 566dbfffe75772cdd5c6556065666b50fd41242e Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Mon, 10 Aug 2026 00:03:16 +0300 Subject: [PATCH 2/2] refactor(bento): inline org:invite_accepted tag on role binding accept Drop the dedicated helper; look up email and tag from the webhook payload. Co-authored-by: Cursor --- .../_backend/triggers/on_user_org_access.ts | 22 ++- .../_backend/utils/bento_org_invite.ts | 115 ---------------- tests/bento-first-org-route.unit.test.ts | 87 ++++++++---- tests/bento-org-invite-accepted.unit.test.ts | 125 ------------------ 4 files changed, 78 insertions(+), 271 deletions(-) delete mode 100644 supabase/functions/_backend/utils/bento_org_invite.ts delete mode 100644 tests/bento-org-invite-accepted.unit.test.ts diff --git a/supabase/functions/_backend/triggers/on_user_org_access.ts b/supabase/functions/_backend/triggers/on_user_org_access.ts index 6969a2af74..26ba138aa6 100644 --- a/supabase/functions/_backend/triggers/on_user_org_access.ts +++ b/supabase/functions/_backend/triggers/on_user_org_access.ts @@ -1,8 +1,9 @@ import type { Database } from '../utils/supabase.types.ts' import { z } from 'zod' +import { syncBentoSubscriberTags } from '../utils/bento.ts' import { syncBentoFirstOrgOnRoleBindingWrite } from '../utils/bento_first_org.ts' -import { syncBentoOrgInviteAcceptedOnRoleBindingWrite } from '../utils/bento_org_invite.ts' import { BRES, createHono, middlewareAPISecret, simpleError, triggerValidator } from '../utils/hono.ts' +import { supabaseAdmin } from '../utils/supabase.ts' import { version } from '../utils/version.ts' export const app = createHono('', version) @@ -14,7 +15,22 @@ app.post('/', middlewareAPISecret, triggerValidator('role_bindings', ['INSERT', throw simpleError('invalid_payload', 'Invalid role binding id', { id: record.id }) await syncBentoFirstOrgOnRoleBindingWrite(c, bindingId.data) - // Tag invitees after accept so Invite-to-org Bento reminders can Match-and-exit. - await syncBentoOrgInviteAcceptedOnRoleBindingWrite(c, bindingId.data) + + // Stop Invite-to-org Bento reminders after accept. + if (record.reason === 'Accepted invitation' && record.principal_id) { + const { data: user } = await supabaseAdmin(c) + .from('users') + .select('email') + .eq('id', record.principal_id) + .maybeSingle() + if (user?.email) { + await syncBentoSubscriberTags(c, { + email: user.email.trim().toLowerCase(), + segments: ['org:invite_accepted'], + deleteSegments: [], + }) + } + } + return c.json(BRES) }) diff --git a/supabase/functions/_backend/utils/bento_org_invite.ts b/supabase/functions/_backend/utils/bento_org_invite.ts deleted file mode 100644 index 9e9eebcd56..0000000000 --- a/supabase/functions/_backend/utils/bento_org_invite.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { Context } from 'hono' -import type { MiddlewareKeyVariables } from './hono.ts' -import { syncBentoSubscriberTags } from './bento.ts' -import { cloudlog, cloudlogErr, serializeError } from './logging.ts' -import { closeClient, getPgClient } from './pg.ts' -import { normalizeBentoEmail } from './bento_first_org.ts' - -/** Bento Match on Invite-to-org reminders: stop when this tag is present. */ -export const BENTO_ORG_INVITE_ACCEPTED_TAG = 'org:invite_accepted' - -export const BENTO_ORG_INVITE_ACCEPTED_BINDING_REASON = 'Accepted invitation' - -interface InviteAcceptedBinding { - email: string | null - id: string - is_active: boolean - is_direct: boolean - org_id: string | null - principal_id: string - principal_type: string - reason: string | null - scope_type: string -} - -/** - * When a user-org role binding is created with reason "Accepted invitation", - * tag the invitee in Bento so invite-reminder workflows can exit. - */ -export async function syncBentoOrgInviteAcceptedOnRoleBindingWrite( - c: Context, - roleBindingId: string, -) { - const pgPool = getPgClient(c) - try { - let binding: InviteAcceptedBinding | undefined - const pgClient = await pgPool.connect() - try { - const result = await pgClient.query( - `SELECT - binding.id, - binding.principal_type, - binding.principal_id, - binding.scope_type, - binding.org_id, - binding.reason, - binding.is_direct, - (binding.expires_at IS NULL OR binding.expires_at > pg_catalog.now()) AS is_active, - users.email - FROM public.role_bindings AS binding - LEFT JOIN public.users AS users - ON users.id = binding.principal_id - WHERE binding.id = $1::uuid - LIMIT 1`, - [roleBindingId], - ) - binding = result.rows[0] - } - finally { - // Destroy this request-scoped socket before crossing into Bento. - pgClient.release(true) - } - - if ( - !binding - || binding.principal_type !== 'user' - || binding.scope_type !== 'org' - || !binding.org_id - || binding.is_direct !== true - || binding.is_active !== true - || binding.reason !== BENTO_ORG_INVITE_ACCEPTED_BINDING_REASON - || !binding.email - ) { - return - } - - const email = normalizeBentoEmail(binding.email) - const result = await syncBentoSubscriberTags(c, { - email, - segments: [BENTO_ORG_INVITE_ACCEPTED_TAG], - deleteSegments: [], - }) - - if (result === false) { - cloudlogErr({ - requestId: c.get('requestId'), - message: 'syncBentoOrgInviteAcceptedOnRoleBindingWrite failed', - roleBindingId, - orgId: binding.org_id, - principalId: binding.principal_id, - }) - return - } - - cloudlog({ - requestId: c.get('requestId'), - message: 'bento_org_invite_accepted_tagged', - roleBindingId, - orgId: binding.org_id, - principalId: binding.principal_id, - email, - tag: BENTO_ORG_INVITE_ACCEPTED_TAG, - }) - } - catch (error) { - cloudlogErr({ - requestId: c.get('requestId'), - message: 'syncBentoOrgInviteAcceptedOnRoleBindingWrite error', - roleBindingId, - error: serializeError(error), - }) - } - finally { - await closeClient(c, pgPool) - } -} diff --git a/tests/bento-first-org-route.unit.test.ts b/tests/bento-first-org-route.unit.test.ts index 6b3ff238dd..22f301ac3d 100644 --- a/tests/bento-first-org-route.unit.test.ts +++ b/tests/bento-first-org-route.unit.test.ts @@ -4,21 +4,42 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { syncBentoFirstOrgOnRoleBindingWriteMock, syncBentoFirstOrgOnUserCreateMock, - syncBentoOrgInviteAcceptedOnRoleBindingWriteMock, -} = vi.hoisted(() => ({ - syncBentoFirstOrgOnRoleBindingWriteMock: vi.fn(async () => undefined), - syncBentoFirstOrgOnUserCreateMock: vi.fn(async () => undefined), - syncBentoOrgInviteAcceptedOnRoleBindingWriteMock: vi.fn(async () => undefined), -})) + syncBentoSubscriberTagsMock, + supabaseFromMock, +} = vi.hoisted(() => { + const maybeSingle = vi.fn(async () => ({ data: { email: 'invitee@example.com' }, error: null })) + const eq = vi.fn(() => ({ maybeSingle })) + const select = vi.fn(() => ({ eq })) + const from = vi.fn(() => ({ select })) + return { + syncBentoFirstOrgOnRoleBindingWriteMock: vi.fn(async () => undefined), + syncBentoFirstOrgOnUserCreateMock: vi.fn(async () => undefined), + syncBentoSubscriberTagsMock: vi.fn(async () => true), + supabaseFromMock: { eq, from, maybeSingle, select }, + } +}) vi.mock('../supabase/functions/_backend/utils/bento_first_org.ts', () => ({ syncBentoFirstOrgOnRoleBindingWrite: syncBentoFirstOrgOnRoleBindingWriteMock, syncBentoFirstOrgOnUserCreate: syncBentoFirstOrgOnUserCreateMock, })) -vi.mock('../supabase/functions/_backend/utils/bento_org_invite.ts', () => ({ - syncBentoOrgInviteAcceptedOnRoleBindingWrite: syncBentoOrgInviteAcceptedOnRoleBindingWriteMock, -})) +vi.mock('../supabase/functions/_backend/utils/bento.ts', async () => { + const actual = await vi.importActual('../supabase/functions/_backend/utils/bento.ts') + return { + ...actual, + syncBentoSubscriberTags: syncBentoSubscriberTagsMock, + } +}) + +vi.mock('../supabase/functions/_backend/utils/supabase.ts', async () => { + const actual = await vi.importActual('../supabase/functions/_backend/utils/supabase.ts') + return { + ...actual, + supabaseAdmin: () => ({ from: supabaseFromMock.from }), + } +}) + const apiWorker = (await import('../cloudflare_workers/api/index.ts')).default const API_SECRET = 'test-secret' @@ -63,10 +84,14 @@ function requestPayload(payload: unknown, apiSecret: string | null = API_SECRET) })) } -function requestRoleBindingWrite(type: 'INSERT' | 'UPDATE', apiSecret: string | null = API_SECRET) { +function requestRoleBindingWrite( + type: 'INSERT' | 'UPDATE', + apiSecret: string | null = API_SECRET, + record = roleBindingRecord, +) { return requestPayload({ - old_record: type === 'UPDATE' ? roleBindingRecord : null, - record: roleBindingRecord, + old_record: type === 'UPDATE' ? record : null, + record, schema: 'public', table: 'role_bindings', type, @@ -78,7 +103,8 @@ describe('first-organization lifecycle trigger route', () => { process.env.API_SECRET = API_SECRET vi.clearAllMocks() syncBentoFirstOrgOnRoleBindingWriteMock.mockResolvedValue(undefined) - syncBentoOrgInviteAcceptedOnRoleBindingWriteMock.mockResolvedValue(undefined) + syncBentoSubscriberTagsMock.mockResolvedValue(true) + supabaseFromMock.maybeSingle.mockResolvedValue({ data: { email: 'invitee@example.com' }, error: null }) }) afterEach(() => { @@ -96,30 +122,34 @@ describe('first-organization lifecycle trigger route', () => { await expect(response.json()).resolves.toEqual({ status: 'ok' }) expect(syncBentoFirstOrgOnRoleBindingWriteMock).toHaveBeenCalledOnce() expect(syncBentoFirstOrgOnRoleBindingWriteMock).toHaveBeenCalledWith(expect.anything(), ROLE_BINDING_ID) - expect(syncBentoOrgInviteAcceptedOnRoleBindingWriteMock).toHaveBeenCalledOnce() - expect(syncBentoOrgInviteAcceptedOnRoleBindingWriteMock).toHaveBeenCalledWith(expect.anything(), ROLE_BINDING_ID) + expect(syncBentoSubscriberTagsMock).toHaveBeenCalledOnce() + expect(syncBentoSubscriberTagsMock).toHaveBeenCalledWith(expect.anything(), { + deleteSegments: [], + email: 'invitee@example.com', + segments: ['org:invite_accepted'], + }) }) - it('returns 5xx so the queue retries when lifecycle delivery fails', async () => { - syncBentoFirstOrgOnRoleBindingWriteMock.mockRejectedValueOnce(new Error('Bento unavailable')) - - const response = await requestRoleBindingWrite('INSERT') + it('skips invite-accepted tagging when reason is not Accepted invitation', async () => { + const response = await requestRoleBindingWrite('INSERT', API_SECRET, { + ...roleBindingRecord, + reason: 'SSO org membership provisioning', + }) - expect(response.status).toBeGreaterThanOrEqual(500) - expect(response.status).toBeLessThan(600) + expect(response.status).toBe(200) expect(syncBentoFirstOrgOnRoleBindingWriteMock).toHaveBeenCalledOnce() - expect(syncBentoOrgInviteAcceptedOnRoleBindingWriteMock).not.toHaveBeenCalled() + expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled() }) - it('returns 5xx so the queue retries when invite-accepted tagging fails', async () => { - syncBentoOrgInviteAcceptedOnRoleBindingWriteMock.mockRejectedValueOnce(new Error('Bento unavailable')) + it('returns 5xx so the queue retries when lifecycle delivery fails', async () => { + syncBentoFirstOrgOnRoleBindingWriteMock.mockRejectedValueOnce(new Error('Bento unavailable')) const response = await requestRoleBindingWrite('INSERT') expect(response.status).toBeGreaterThanOrEqual(500) expect(response.status).toBeLessThan(600) expect(syncBentoFirstOrgOnRoleBindingWriteMock).toHaveBeenCalledOnce() - expect(syncBentoOrgInviteAcceptedOnRoleBindingWriteMock).toHaveBeenCalledOnce() + expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled() }) it.each([ @@ -130,7 +160,7 @@ describe('first-organization lifecycle trigger route', () => { expect(response.status).toBe(400) expect(syncBentoFirstOrgOnRoleBindingWriteMock).not.toHaveBeenCalled() - expect(syncBentoOrgInviteAcceptedOnRoleBindingWriteMock).not.toHaveBeenCalled() + expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled() }) it.each([ @@ -147,7 +177,7 @@ describe('first-organization lifecycle trigger route', () => { error: 'invalid_payload', }) expect(syncBentoFirstOrgOnRoleBindingWriteMock).not.toHaveBeenCalled() - expect(syncBentoOrgInviteAcceptedOnRoleBindingWriteMock).not.toHaveBeenCalled() + expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled() }) it.each([ @@ -161,8 +191,9 @@ describe('first-organization lifecycle trigger route', () => { expect(response.status).toBe(400) await expect(response.json()).resolves.toMatchObject({ error }) expect(syncBentoFirstOrgOnRoleBindingWriteMock).not.toHaveBeenCalled() - expect(syncBentoOrgInviteAcceptedOnRoleBindingWriteMock).not.toHaveBeenCalled() + expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled() }) + it('registers the same route in the Supabase trigger router', async () => { // Importing this deployment entry point evaluates Deno.serve and starts a // server, so verify its static wiring without executing the module. diff --git a/tests/bento-org-invite-accepted.unit.test.ts b/tests/bento-org-invite-accepted.unit.test.ts deleted file mode 100644 index 1a3361c210..0000000000 --- a/tests/bento-org-invite-accepted.unit.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - closeClientMock, - getPgClientMock, - pgConnectMock, - pgQueryMock, - pgReleaseMock, - syncBentoSubscriberTagsMock, -} = vi.hoisted(() => { - const pgQueryMock = vi.fn<(query: string, params?: unknown[]) => Promise<{ rows: Record[] }>>(async () => ({ rows: [] })) - const pgReleaseMock = vi.fn<(destroy?: Error | boolean) => void>(() => undefined) - const pgConnectMock = vi.fn(async () => ({ query: pgQueryMock, release: pgReleaseMock })) - return { - closeClientMock: vi.fn(async () => undefined), - getPgClientMock: vi.fn(() => ({ connect: pgConnectMock, query: pgQueryMock })), - pgConnectMock, - pgQueryMock, - pgReleaseMock, - syncBentoSubscriberTagsMock: vi.fn<( - c: unknown, - update: { deleteSegments: string[], email: string, segments: string[] }, - ) => Promise>(async () => true), - } -}) - -vi.mock('../supabase/functions/_backend/utils/bento.ts', () => ({ - syncBentoSubscriberTags: syncBentoSubscriberTagsMock, -})) - -vi.mock('../supabase/functions/_backend/utils/pg.ts', () => ({ - closeClient: closeClientMock, - getPgClient: getPgClientMock, -})) - -const { - BENTO_ORG_INVITE_ACCEPTED_TAG, - syncBentoOrgInviteAcceptedOnRoleBindingWrite, -} = await import('../supabase/functions/_backend/utils/bento_org_invite.ts') - -const ROLE_BINDING_ID = '33333333-3333-4333-8333-333333333333' -const ORG_ID = '22222222-2222-4222-8222-222222222222' -const USER_ID = '11111111-1111-4111-8111-111111111111' - -function acceptedBinding(overrides: Record = {}) { - return { - email: ' Invitee@Example.COM ', - id: ROLE_BINDING_ID, - is_active: true, - is_direct: true, - org_id: ORG_ID, - principal_id: USER_ID, - principal_type: 'user', - reason: 'Accepted invitation', - scope_type: 'org', - ...overrides, - } -} - -async function syncInviteAccepted(roleBindingId = ROLE_BINDING_ID) { - return await syncBentoOrgInviteAcceptedOnRoleBindingWrite( - { get: vi.fn(() => 'request-id') } as never, - roleBindingId, - ) -} - -describe('bento org invite accepted tag', () => { - beforeEach(() => { - vi.clearAllMocks() - syncBentoSubscriberTagsMock.mockResolvedValue(true) - pgQueryMock.mockResolvedValue({ rows: [acceptedBinding()] }) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('tags the invitee email when the binding reason is Accepted invitation', async () => { - await syncInviteAccepted() - - expect(pgQueryMock).toHaveBeenCalledOnce() - expect(pgQueryMock.mock.calls[0]?.[1]).toEqual([ROLE_BINDING_ID]) - expect(pgReleaseMock).toHaveBeenCalledWith(true) - expect(syncBentoSubscriberTagsMock).toHaveBeenCalledOnce() - expect(syncBentoSubscriberTagsMock).toHaveBeenCalledWith(expect.anything(), { - deleteSegments: [], - email: 'invitee@example.com', - segments: [BENTO_ORG_INVITE_ACCEPTED_TAG], - }) - expect(closeClientMock).toHaveBeenCalledOnce() - }) - - it.each([ - ['wrong reason', { reason: 'SSO org membership provisioning' }], - ['inactive binding', { is_active: false }], - ['non-direct binding', { is_direct: false }], - ['non-org scope', { scope_type: 'app' }], - ['non-user principal', { principal_type: 'apikey' }], - ['missing email', { email: null }], - ['missing org', { org_id: null }], - ])('skips Bento when %s', async (_label, overrides) => { - pgQueryMock.mockResolvedValue({ rows: [acceptedBinding(overrides)] }) - - await syncInviteAccepted() - - expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled() - expect(closeClientMock).toHaveBeenCalledOnce() - }) - - it('skips Bento when the role binding row is missing', async () => { - pgQueryMock.mockResolvedValue({ rows: [] }) - - await syncInviteAccepted() - - expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled() - expect(closeClientMock).toHaveBeenCalledOnce() - }) - - it('swallows Bento sync failures without throwing', async () => { - syncBentoSubscriberTagsMock.mockResolvedValue(false) - - await expect(syncInviteAccepted()).resolves.toBeUndefined() - expect(closeClientMock).toHaveBeenCalledOnce() - }) -})