Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
31 changes: 31 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ jobs:
FILTER_CLI: ${{ steps.filter.outputs.cli }}
FILTER_NOTIFICATIONS: ${{ steps.filter.outputs.notifications }}
FILTER_SHARED: ${{ steps.filter.outputs.shared }}
GH_TOKEN: ${{ github.token }}
BRANCH_NAME: ${{ github.ref_name }}
Comment thread
cursor[bot] marked this conversation as resolved.
run: |
if [ "$EVENT_NAME" = "workflow_call" ]; then
echo "capgo=$INPUT_RUN_CAPGO" >> "$GITHUB_OUTPUT"
Expand All @@ -140,6 +142,35 @@ jobs:
exit 0
fi

# Push + pull_request both run this workflow on feature branches. When an open
# PR already covers the branch, the push suite is redundant and its flakes
# (Docker port binds, transient 502/503) stick on the PR check list.
if [ "$EVENT_NAME" = "push" ]; then
set +e
pr_json="$(gh pr list \
--repo "$GITHUB_REPOSITORY" \
--state open \
--json number,headRefName,headRepository 2>/tmp/gh-pr-list.err)"
gh_status=$?
set -e
if [ "$gh_status" -ne 0 ]; then
echo "Warning: gh pr list failed; not skipping push tests"
cat /tmp/gh-pr-list.err || true
else
pr_count="$(printf '%s' "$pr_json" | jq \
--arg branch "$BRANCH_NAME" \
--arg repo "$GITHUB_REPOSITORY" \
'[.[] | select(.headRefName == $branch and .headRepository.nameWithOwner == $repo)] | length')"
if [ "${pr_count:-0}" -gt 0 ]; then
echo "Skipping push test suite; open PR already covers branch $BRANCH_NAME in $GITHUB_REPOSITORY"
echo "capgo=false" >> "$GITHUB_OUTPUT"
echo "cli=false" >> "$GITHUB_OUTPUT"
echo "notifications=false" >> "$GITHUB_OUTPUT"
exit 0
fi
fi
fi

run_capgo=false
run_cli=false
run_notifications=false
Expand Down
130 changes: 130 additions & 0 deletions supabase/functions/_backend/triggers/canceled_org_retention_alerts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { MiddlewareKeyVariables } from '../utils/hono.ts'
import { Hono } from 'hono/tiny'
import { BRES, middlewareAPISecret, parseBody, simpleError } from '../utils/hono.ts'
import { cloudlog } from '../utils/logging.ts'
import { sendEventToTracking } from '../utils/tracking.ts'

type RetentionAlertType = 'bundles_deletion_warning' | 'app_deletion_warning'

interface CanceledOrgRetentionAlertPayload {
org_id: string
org_name?: string
management_email?: string
alert_type: RetentionAlertType
access_end?: string
days_until_deletion?: number
app_ids?: string[]
}

const ALERT_CONFIG = {
bundles_deletion_warning: {
bentoEvent: 'org:bundles_will_be_deleted',
trackingEvent: 'Bundles will be deleted',
icon: '📦',
},
app_deletion_warning: {
bentoEvent: 'org:apps_will_be_deleted',
trackingEvent: 'Apps will be deleted',
icon: '🗑️',
},
} as const satisfies Record<RetentionAlertType, {
bentoEvent: string
trackingEvent: string
icon: string
}>

const ORG_ID_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i

function isRetentionAlertType(value: unknown): value is RetentionAlertType {
return value === 'bundles_deletion_warning' || value === 'app_deletion_warning'
}

/** Cycle key matching SQL to_char(access_end AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'). */
function accessEndCycleKey(accessEnd: unknown): string {
if (typeof accessEnd !== 'string' || !accessEnd.trim())
return 'unknown'
const parsed = new Date(accessEnd)
Comment thread
cursor[bot] marked this conversation as resolved.
if (Number.isNaN(parsed.getTime()))
return 'invalid'
return parsed.toISOString().replace(/\.\d{3}Z$/, 'Z')
}

export const app = new Hono<MiddlewareKeyVariables>()

app.post('/', middlewareAPISecret, async (c) => {
const payload = await parseBody<CanceledOrgRetentionAlertPayload | null>(c)
if (!payload || typeof payload !== 'object') {
throw simpleError('invalid_payload', 'Missing retention alert payload', {
hasPayload: false,
})
}

const orgId = typeof payload.org_id === 'string' ? payload.org_id.trim() : ''
const alertType = payload.alert_type

if (!orgId || !ORG_ID_UUID_RE.test(orgId) || !isRetentionAlertType(alertType)) {
throw simpleError(
'invalid_payload',
'Missing or invalid org_id/alert_type in retention alert payload',
{
hasOrgId: orgId.length > 0,
orgIdValid: orgId.length > 0 && ORG_ID_UUID_RE.test(orgId),
alertType: typeof alertType === 'string' ? alertType : typeof alertType,
},
)
}

const config = ALERT_CONFIG[alertType]

const parsedDays = Number(payload.days_until_deletion ?? 5)
const daysUntilDeletion = Number.isFinite(parsedDays) ? parsedDays : 5
const appIds = Array.isArray(payload.app_ids) ? payload.app_ids : []
const accessEndKey = accessEndCycleKey(payload.access_end)
const uniqId = `retention:${alertType}:${accessEndKey}`

const metadata = {
org_id: orgId,
org_name: payload.org_name ?? '',
management_email: payload.management_email ?? '',
alert_type: alertType,
access_end: typeof payload.access_end === 'string' ? payload.access_end : '',
days_until_deletion: daysUntilDeletion,
app_ids: appIds,
app_count: appIds.length,
}

cloudlog({
requestId: c.get('requestId'),
message: 'canceled org retention alert',
eventName: config.bentoEvent,
orgId,
alertType,
daysUntilDeletion,
appCount: appIds.length,
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

await sendEventToTracking(c, {
bento: {
once: true,
data: metadata,
event: config.bentoEvent,
preferenceKey: 'usage_limit',
uniqId,
audience: 'billing',
},
channel: 'usage',
event: config.trackingEvent,
icon: config.icon,
user_id: orgId,
groups: { organization: orgId },
notify: false,
sentToBento: true,
tags: {
alert_type: alertType,
days_until_deletion: String(daysUntilDeletion),
app_count: String(appIds.length),
},
}, { background: false, strict: true })
Comment thread
cursor[bot] marked this conversation as resolved.

return c.json(BRES)
})
2 changes: 2 additions & 0 deletions supabase/functions/_backend/utils/org_email_notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,8 @@ export async function sendNotifToOrgMembersOnce(
preferenceKey,
orgId,
})
// Claim anyway so once-dedup (SQL + retries) does not re-queue forever.
await claimNotifOrgOnce(c, eventName, orgId, uniqId, writeClient)
return false
}

Expand Down
39 changes: 22 additions & 17 deletions supabase/functions/_backend/utils/tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ async function executeTracking(c: Context, payload: SendEventToTrackingPayload,
await Promise.all(tasks)
}

async function executeBentoTracking(c: Context, payload: SendEventToTrackingPayload) {
async function executeBentoTracking(c: Context, payload: SendEventToTrackingPayload, strict = false) {
if (!payload.sentToBento)
return

Expand All @@ -119,6 +119,8 @@ async function executeBentoTracking(c: Context, payload: SendEventToTrackingPayl
event: payload.event,
user_id: payload.user_id,
})
if (strict)
throw new Error('sendEventToTracking missing Bento payload')
return
}

Expand All @@ -133,6 +135,8 @@ async function executeBentoTracking(c: Context, payload: SendEventToTrackingPayl
event: payload.event,
user_id: payload.user_id,
})
if (strict)
throw new Error('sendEventToTracking missing org id for Bento notification')
return
}

Expand All @@ -142,6 +146,8 @@ async function executeBentoTracking(c: Context, payload: SendEventToTrackingPayl
if (bento.once) {
// Permanent per-(event, org, uniqId) claim: per-entity alerts (e.g. an
// incompatible bundle version) must not re-email org admins on retries.
// Discard the boolean: false is often benign (already claimed, no
// recipients, Bento unset). Under strict, only thrown errors fail closed.
await sendNotifToOrgMembersOnce(
c,
bento.event,
Expand All @@ -152,35 +158,34 @@ async function executeBentoTracking(c: Context, payload: SendEventToTrackingPayl
getDrizzleClient(pgClient),
bento.audience,
)
return
}
else {
await sendNotifToOrgMembers(
c,
bento.event,
bento.preferenceKey,
bento.data,
orgId,
bento.uniqId,
bento.cron ?? '* * * * *',
getDrizzleClient(pgClient),
bento.audience,
)
}
await sendNotifToOrgMembers(
c,
bento.event,
bento.preferenceKey,
bento.data,
orgId,
bento.uniqId,
bento.cron ?? '* * * * *',
getDrizzleClient(pgClient),
bento.audience,
)
}
finally {
await pgClient.end()
}
})
}, strict)
}

export async function sendEventToTracking(c: Context, payload: SendEventToTrackingPayload, options: SendEventToTrackingOptions = {}) {
const trackingTask = executeTracking(c, payload, options)
if (options.background === false) {
await trackingTask
await executeBentoTracking(c, payload)
await executeBentoTracking(c, payload, options.strict === true)
return
}

await backgroundTask(c, trackingTask)
await backgroundTask(c, executeBentoTracking(c, payload))
await backgroundTask(c, executeBentoTracking(c, payload, options.strict === true))
}
2 changes: 2 additions & 0 deletions supabase/functions/triggers/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { app as canceled_org_retention_alerts } from '../_backend/triggers/canceled_org_retention_alerts.ts'
import { app as credit_usage_alerts } from '../_backend/triggers/credit_usage_alerts.ts'
import { app as credit_usage_posthog } from '../_backend/triggers/credit_usage_posthog.ts'
import { app as cron_clean_orphan_images } from '../_backend/triggers/cron_clean_orphan_images.ts'
Expand Down Expand Up @@ -77,6 +78,7 @@ appGlobal.route('/cron_clear_versions', cron_clear_versions)
appGlobal.route('/cron_clean_orphan_images', cron_clean_orphan_images)
appGlobal.route('/cron_reconcile_build_status', cron_reconcile_build_status)
appGlobal.route('/cron_rollout_auto_pause', cron_rollout_auto_pause)
appGlobal.route('/canceled_org_retention_alerts', canceled_org_retention_alerts)
appGlobal.route('/credit_usage_alerts', credit_usage_alerts)
appGlobal.route('/credit_usage_posthog', credit_usage_posthog)
appGlobal.route('/on_organization_delete', on_organization_delete)
Expand Down
Loading
Loading