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..26ba138aa6 100644 --- a/supabase/functions/_backend/triggers/on_user_org_access.ts +++ b/supabase/functions/_backend/triggers/on_user_org_access.ts @@ -1,7 +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 { 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) @@ -13,5 +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) + + // 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/tests/bento-first-org-route.unit.test.ts b/tests/bento-first-org-route.unit.test.ts index 4f7a51cd9d..22f301ac3d 100644 --- a/tests/bento-first-org-route.unit.test.ts +++ b/tests/bento-first-org-route.unit.test.ts @@ -4,16 +4,42 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { syncBentoFirstOrgOnRoleBindingWriteMock, syncBentoFirstOrgOnUserCreateMock, -} = vi.hoisted(() => ({ - syncBentoFirstOrgOnRoleBindingWriteMock: vi.fn(async () => undefined), - syncBentoFirstOrgOnUserCreateMock: 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.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' @@ -58,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, @@ -73,6 +103,8 @@ describe('first-organization lifecycle trigger route', () => { process.env.API_SECRET = API_SECRET vi.clearAllMocks() syncBentoFirstOrgOnRoleBindingWriteMock.mockResolvedValue(undefined) + syncBentoSubscriberTagsMock.mockResolvedValue(true) + supabaseFromMock.maybeSingle.mockResolvedValue({ data: { email: 'invitee@example.com' }, error: null }) }) afterEach(() => { @@ -90,6 +122,23 @@ 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(syncBentoSubscriberTagsMock).toHaveBeenCalledOnce() + expect(syncBentoSubscriberTagsMock).toHaveBeenCalledWith(expect.anything(), { + deleteSegments: [], + email: 'invitee@example.com', + segments: ['org:invite_accepted'], + }) + }) + + 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).toBe(200) + expect(syncBentoFirstOrgOnRoleBindingWriteMock).toHaveBeenCalledOnce() + expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled() }) it('returns 5xx so the queue retries when lifecycle delivery fails', async () => { @@ -100,6 +149,7 @@ describe('first-organization lifecycle trigger route', () => { expect(response.status).toBeGreaterThanOrEqual(500) expect(response.status).toBeLessThan(600) expect(syncBentoFirstOrgOnRoleBindingWriteMock).toHaveBeenCalledOnce() + expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled() }) it.each([ @@ -110,6 +160,7 @@ describe('first-organization lifecycle trigger route', () => { expect(response.status).toBe(400) expect(syncBentoFirstOrgOnRoleBindingWriteMock).not.toHaveBeenCalled() + expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled() }) it.each([ @@ -126,6 +177,7 @@ describe('first-organization lifecycle trigger route', () => { error: 'invalid_payload', }) expect(syncBentoFirstOrgOnRoleBindingWriteMock).not.toHaveBeenCalled() + expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled() }) it.each([ @@ -139,6 +191,7 @@ describe('first-organization lifecycle trigger route', () => { expect(response.status).toBe(400) await expect(response.json()).resolves.toMatchObject({ error }) expect(syncBentoFirstOrgOnRoleBindingWriteMock).not.toHaveBeenCalled() + expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled() }) it('registers the same route in the Supabase trigger router', async () => {