Skip to content
Merged
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
14 changes: 14 additions & 0 deletions docs/BENTO_EMAIL_PREFERENCES_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
19 changes: 19 additions & 0 deletions supabase/functions/_backend/triggers/on_user_org_access.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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: [],
})
Comment on lines +20 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Return a retryable failure when invite-accepted tagging cannot complete.

maybeSingle() errors and syncBentoSubscriberTags() returning false currently produce a successful trigger response. The queue then completes without applying org:invite_accepted, so Bento can continue sending invite reminders.

  • supabase/functions/_backend/triggers/on_user_org_access.ts#L20-L31: check the Supabase error, and throw it. Capture the tag result, and throw only when it is false so an unconfigured Bento integration can still return undefined.
  • tests/bento-first-org-route.unit.test.ts#L144-L152: add cases where the lookup returns an error and where syncBentoSubscriberTagsMock resolves false; both must return a 5xx response.
Proposed fix
-    const { data: user } = await supabaseAdmin(c)
+    const { data: user, error } = await supabaseAdmin(c)
       .from('users')
       .select('email')
       .eq('id', record.principal_id)
       .maybeSingle()
+    if (error)
+      throw error
     if (user?.email) {
-      await syncBentoSubscriberTags(c, {
+      const synced = await syncBentoSubscriberTags(c, {
         email: user.email.trim().toLowerCase(),
         segments: ['org:invite_accepted'],
         deleteSegments: [],
       })
+      if (synced === false)
+        throw new Error('Could not sync Bento invite acceptance tag')
     }

As per coding guidelines, unfinished queue-backed work must be re-enqueued through sweepers or cron.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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: [],
})
if (record.reason === 'Accepted invitation' && record.principal_id) {
const { data: user, error } = await supabaseAdmin(c)
.from('users')
.select('email')
.eq('id', record.principal_id)
.maybeSingle()
if (error)
throw error
if (user?.email) {
const synced = await syncBentoSubscriberTags(c, {
email: user.email.trim().toLowerCase(),
segments: ['org:invite_accepted'],
deleteSegments: [],
})
if (synced === false)
throw new Error('Could not sync Bento invite acceptance tag')
📍 Affects 2 files
  • supabase/functions/_backend/triggers/on_user_org_access.ts#L20-L31 (this comment)
  • tests/bento-first-org-route.unit.test.ts#L144-L152
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/functions/_backend/triggers/on_user_org_access.ts` around lines 20 -
31, The invite-accepted trigger must propagate lookup and tagging failures as
retryable errors. In
supabase/functions/_backend/triggers/on_user_org_access.ts:20-31, check the
Supabase lookup error and throw it; capture syncBentoSubscriberTags’ result and
throw only when it is false, preserving undefined for unconfigured Bento. In
tests/bento-first-org-route.unit.test.ts:144-152, add coverage for both cases
and assert each produces a 5xx response.

Source: Coding guidelines

}
}

return c.json(BRES)
})
67 changes: 60 additions & 7 deletions tests/bento-first-org-route.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -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(() => {
Expand All @@ -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 () => {
Expand All @@ -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([
Expand All @@ -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([
Expand All @@ -126,6 +177,7 @@ describe('first-organization lifecycle trigger route', () => {
error: 'invalid_payload',
})
expect(syncBentoFirstOrgOnRoleBindingWriteMock).not.toHaveBeenCalled()
expect(syncBentoSubscriberTagsMock).not.toHaveBeenCalled()
})

it.each([
Expand All @@ -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 () => {
Expand Down
Loading