713 lines
24 KiB
TypeScript
713 lines
24 KiB
TypeScript
import { createClient } from '@supabase/supabase-js'
|
|
import { requireUser } from '../_shared/auth.ts'
|
|
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
|
import {
|
|
buildPushNotification,
|
|
getFcmAccessToken,
|
|
isServiceRoleApiKey,
|
|
isServiceRoleAuthorization,
|
|
isUuid,
|
|
parsePushRequest,
|
|
PushContractError,
|
|
readFcmConfig,
|
|
sendFcmMessage,
|
|
type PushEventType,
|
|
type PushProvider,
|
|
type TeamInviteNotificationContext,
|
|
} from '../_shared/push-contract.ts'
|
|
import { createServiceRoleClient } from '../_shared/quota.ts'
|
|
|
|
const JSON_HEADERS = {
|
|
...corsHeaders,
|
|
'Content-Type': 'application/json',
|
|
'Cache-Control': 'no-store',
|
|
Pragma: 'no-cache',
|
|
}
|
|
const REQUEST_LIMIT_BYTES = 4 * 1024
|
|
const SEND_CONCURRENCY = 10
|
|
const DRAIN_CONCURRENCY = 4
|
|
|
|
type DispatchActor =
|
|
| { kind: 'user'; userId: string }
|
|
| { kind: 'system' }
|
|
|
|
interface ResolvedEvent {
|
|
recipientUserIds: string[]
|
|
inviteContext?: TeamInviteNotificationContext
|
|
}
|
|
|
|
interface ReservationResult {
|
|
reserved: boolean
|
|
duplicate: boolean
|
|
attemptId: string
|
|
status: string
|
|
dispatchLeaseToken: string | null
|
|
nextRetryAt: string | null
|
|
}
|
|
|
|
interface LeasedDelivery {
|
|
deliveryId: string
|
|
deliveryLeaseToken: string
|
|
pushTokenId: string
|
|
provider: PushProvider
|
|
registrationId: string
|
|
lastRegisteredAt: string
|
|
}
|
|
|
|
interface DispatchSummary {
|
|
attemptId: string
|
|
status: 'pending' | 'processing' | 'succeeded' | 'partial' | 'failed'
|
|
complete: boolean
|
|
delivered: number
|
|
stale: number
|
|
retryableFailed: number
|
|
permanentFailed: number
|
|
nextRetryAt: string | null
|
|
}
|
|
|
|
interface ClaimedDispatch {
|
|
attemptId: string
|
|
eventType: PushEventType
|
|
resourceId: string
|
|
dispatchLeaseToken: string
|
|
}
|
|
|
|
interface ProcessResult extends DispatchSummary {
|
|
attempted: number
|
|
transientError: string | null
|
|
transientStatus: number | null
|
|
}
|
|
|
|
function jsonResponse(body: Record<string, unknown>, status = 200): Response {
|
|
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS })
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
}
|
|
|
|
function readEndpointMode(url: URL): { mode: 'dispatch' } | { mode: 'drain'; batchLimit: number } {
|
|
const entries = [...url.searchParams.entries()]
|
|
if (entries.length === 0) return { mode: 'dispatch' }
|
|
if (
|
|
entries.some(([key]) => key !== 'mode' && key !== 'limit')
|
|
|| url.searchParams.getAll('mode').length !== 1
|
|
|| url.searchParams.get('mode') !== 'drain'
|
|
|| url.searchParams.getAll('limit').length > 1
|
|
) {
|
|
throw new PushContractError('invalid_push_request', 400)
|
|
}
|
|
const rawLimit = url.searchParams.get('limit') ?? '20'
|
|
const batchLimit = Number(rawLimit)
|
|
if (!Number.isSafeInteger(batchLimit) || batchLimit < 1 || batchLimit > 100) {
|
|
throw new PushContractError('invalid_batch_limit', 400)
|
|
}
|
|
return { mode: 'drain', batchLimit }
|
|
}
|
|
|
|
async function readJson(req: Request): Promise<unknown> {
|
|
if (req.headers.get('Content-Type')?.split(';', 1)[0].trim().toLowerCase() !== 'application/json') {
|
|
throw new PushContractError('unsupported_media_type', 415)
|
|
}
|
|
const declaredLength = Number(req.headers.get('Content-Length') ?? '0')
|
|
if (Number.isFinite(declaredLength) && declaredLength > REQUEST_LIMIT_BYTES) {
|
|
throw new PushContractError('request_too_large', 413)
|
|
}
|
|
const text = await req.text()
|
|
if (new TextEncoder().encode(text).byteLength > REQUEST_LIMIT_BYTES) {
|
|
throw new PushContractError('request_too_large', 413)
|
|
}
|
|
try {
|
|
return JSON.parse(text)
|
|
} catch {
|
|
throw new PushContractError('invalid_json', 400)
|
|
}
|
|
}
|
|
|
|
function createUserClient(req: Request) {
|
|
return createClient(
|
|
Deno.env.get('SUPABASE_URL') ?? '',
|
|
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
|
{
|
|
auth: { persistSession: false, autoRefreshToken: false },
|
|
global: { headers: { Authorization: req.headers.get('Authorization') ?? '' } },
|
|
},
|
|
)
|
|
}
|
|
|
|
async function authenticateDispatcher(req: Request): Promise<DispatchActor> {
|
|
const authorization = req.headers.get('Authorization')
|
|
const legacyServiceBearer = await isServiceRoleAuthorization(
|
|
authorization, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY'),
|
|
)
|
|
const serviceApiKey = await isServiceRoleApiKey(req.headers.get('apikey'))
|
|
if (legacyServiceBearer || serviceApiKey) {
|
|
return { kind: 'system' }
|
|
}
|
|
const user = await requireUser(req)
|
|
return { kind: 'user', userId: user.id }
|
|
}
|
|
|
|
async function resolvePushEvent(
|
|
eventType: PushEventType,
|
|
resourceId: string,
|
|
actor: DispatchActor,
|
|
userClient: ReturnType<typeof createUserClient> | null,
|
|
): Promise<ResolvedEvent> {
|
|
const serviceClient = createServiceRoleClient()
|
|
if (eventType === 'transcription.completed') {
|
|
const { data, error } = await serviceClient
|
|
.from('history')
|
|
.select('id,user_id,status')
|
|
.eq('id', resourceId)
|
|
.maybeSingle()
|
|
if (error) throw new PushContractError('push_resource_lookup_failed', 500)
|
|
if (!data || !isUuid(data.user_id)) {
|
|
throw new PushContractError('push_resource_unavailable', 404)
|
|
}
|
|
if (actor.kind === 'user' && data.user_id !== actor.userId) {
|
|
throw new PushContractError('push_resource_unavailable', 404)
|
|
}
|
|
if (data.status !== 'completed') {
|
|
throw new PushContractError('push_resource_not_ready', 409)
|
|
}
|
|
return { recipientUserIds: [data.user_id] }
|
|
}
|
|
|
|
if (eventType === 'billing.status.changed') {
|
|
const { data, error } = await serviceClient
|
|
.from('subscriptions')
|
|
.select('id,user_id')
|
|
.eq('id', resourceId)
|
|
.maybeSingle()
|
|
if (error) throw new PushContractError('push_resource_lookup_failed', 500)
|
|
if (!data || !isUuid(data.user_id)) {
|
|
throw new PushContractError('push_resource_unavailable', 404)
|
|
}
|
|
if (actor.kind === 'user' && data.user_id !== actor.userId) {
|
|
throw new PushContractError('push_resource_unavailable', 404)
|
|
}
|
|
return { recipientUserIds: [data.user_id] }
|
|
}
|
|
|
|
const { data: invite, error: inviteError } = await serviceClient
|
|
.from('team_invites')
|
|
.select('id,team_id,invited_by,token,accepted_at,expires_at')
|
|
.eq('id', resourceId)
|
|
.maybeSingle()
|
|
if (inviteError) throw new PushContractError('push_resource_lookup_failed', 500)
|
|
if (
|
|
!invite
|
|
|| (actor.kind === 'user' && invite.invited_by !== actor.userId)
|
|
|| invite.accepted_at !== null
|
|
|| typeof invite.expires_at !== 'string'
|
|
|| Date.parse(invite.expires_at) <= Date.now()
|
|
|| typeof invite.token !== 'string'
|
|
|| !isUuid(invite.team_id)
|
|
) {
|
|
throw new PushContractError('push_resource_unavailable', 404)
|
|
}
|
|
|
|
let recipientId: unknown
|
|
if (actor.kind === 'user') {
|
|
if (userClient === null) throw new PushContractError('push_contract_unavailable', 503)
|
|
const result = await userClient.rpc('resolve_team_invite_recipient', { invite_id: resourceId })
|
|
if (result.error) {
|
|
const unavailable = result.error.code === '42883'
|
|
|| result.error.message?.includes('resolve_team_invite_recipient')
|
|
throw new PushContractError(
|
|
unavailable ? 'push_contract_unavailable' : 'push_resource_lookup_failed',
|
|
unavailable ? 503 : 500,
|
|
)
|
|
}
|
|
recipientId = result.data
|
|
} else {
|
|
const result = await serviceClient.rpc('push_dispatch_target_user', {
|
|
target_event_type: eventType,
|
|
target_resource_id: resourceId,
|
|
})
|
|
if (result.error) throw new PushContractError('push_resource_lookup_failed', 500)
|
|
recipientId = result.data
|
|
}
|
|
if (recipientId !== null && !isUuid(recipientId)) {
|
|
throw new PushContractError('push_resource_lookup_failed', 500)
|
|
}
|
|
return {
|
|
recipientUserIds: recipientId === null ? [] : [recipientId],
|
|
inviteContext: { inviteToken: invite.token, teamId: invite.team_id },
|
|
}
|
|
}
|
|
|
|
function normalizeReservation(value: unknown): ReservationResult {
|
|
if (!isRecord(value)) throw new PushContractError('push_contract_invalid', 502)
|
|
const reserved = value.reserved
|
|
const duplicate = value.duplicate
|
|
const rawLeaseToken = value.dispatch_lease_token ?? null
|
|
const leaseToken = typeof rawLeaseToken === 'string' ? rawLeaseToken : null
|
|
const nextRetryAt = value.next_retry_at ?? null
|
|
if (
|
|
typeof reserved !== 'boolean'
|
|
|| typeof duplicate !== 'boolean'
|
|
|| reserved === duplicate
|
|
|| !isUuid(value.attempt_id)
|
|
|| typeof value.status !== 'string'
|
|
|| (reserved && !isUuid(rawLeaseToken))
|
|
|| (!reserved && rawLeaseToken !== null)
|
|
|| (nextRetryAt !== null && (
|
|
typeof nextRetryAt !== 'string' || !Number.isFinite(Date.parse(nextRetryAt))
|
|
))
|
|
) {
|
|
throw new PushContractError('push_contract_invalid', 502)
|
|
}
|
|
return {
|
|
reserved,
|
|
duplicate,
|
|
attemptId: value.attempt_id,
|
|
status: value.status,
|
|
dispatchLeaseToken: leaseToken,
|
|
nextRetryAt,
|
|
}
|
|
}
|
|
|
|
async function reserveDispatch(
|
|
actor: DispatchActor,
|
|
userClient: ReturnType<typeof createUserClient> | null,
|
|
eventType: PushEventType,
|
|
resourceId: string,
|
|
): Promise<ReservationResult> {
|
|
const client = actor.kind === 'system' ? createServiceRoleClient() : userClient
|
|
if (client === null) throw new PushContractError('push_contract_unavailable', 503)
|
|
const functionName = actor.kind === 'system'
|
|
? 'reserve_system_push_dispatch'
|
|
: 'reserve_push_dispatch'
|
|
const { data, error } = await client.rpc(functionName, {
|
|
push_event_type: eventType,
|
|
push_resource_id: resourceId,
|
|
})
|
|
if (error) {
|
|
const message = error.message?.toLowerCase() ?? ''
|
|
if (message.includes('push_rate_limited') || error.code === '54000') {
|
|
throw new PushContractError('push_rate_limited', 429)
|
|
}
|
|
if (message.includes('push_resource_unavailable') || error.code === 'P0002') {
|
|
throw new PushContractError('push_resource_unavailable', 404)
|
|
}
|
|
if (
|
|
message.includes('push_claim_busy')
|
|
|| error.code === '55P03'
|
|
|| error.code === '40001'
|
|
) {
|
|
throw new PushContractError('push_claim_busy', 409)
|
|
}
|
|
if (message.includes('invalid_push_event') || error.code === '22023') {
|
|
throw new PushContractError('unsupported_push_event', 400)
|
|
}
|
|
if (
|
|
error.code === '42883'
|
|
|| message.includes('reserve_push_dispatch')
|
|
|| message.includes('reserve_system_push_dispatch')
|
|
|| message.includes('service_role_required')
|
|
) {
|
|
throw new PushContractError('push_contract_unavailable', 503)
|
|
}
|
|
throw new PushContractError('push_reservation_failed', 500)
|
|
}
|
|
return normalizeReservation(data)
|
|
}
|
|
|
|
function normalizeLeasedDeliveries(value: unknown): LeasedDelivery[] {
|
|
if (!Array.isArray(value)) throw new PushContractError('push_contract_invalid', 502)
|
|
return value.map((row) => {
|
|
if (
|
|
!isRecord(row)
|
|
|| !isUuid(row.delivery_id)
|
|
|| !isUuid(row.delivery_lease_token)
|
|
|| !isUuid(row.push_token_id)
|
|
|| (row.provider !== 'fcm' && row.provider !== 'apns' && row.provider !== 'webpush')
|
|
|| typeof row.registration_id !== 'string'
|
|
|| row.registration_id.length < 20
|
|
|| row.registration_id.length > 4096
|
|
|| /\s/.test(row.registration_id)
|
|
|| typeof row.last_registered_at !== 'string'
|
|
|| !Number.isFinite(Date.parse(row.last_registered_at))
|
|
) {
|
|
throw new PushContractError('push_contract_invalid', 502)
|
|
}
|
|
return {
|
|
deliveryId: row.delivery_id,
|
|
deliveryLeaseToken: row.delivery_lease_token,
|
|
pushTokenId: row.push_token_id,
|
|
provider: row.provider,
|
|
registrationId: row.registration_id,
|
|
lastRegisteredAt: row.last_registered_at,
|
|
}
|
|
})
|
|
}
|
|
|
|
async function leaseDeliveries(
|
|
attemptId: string,
|
|
dispatchLeaseToken: string,
|
|
): Promise<LeasedDelivery[]> {
|
|
const { data, error } = await createServiceRoleClient().rpc('lease_push_deliveries', {
|
|
target_attempt_id: attemptId,
|
|
target_dispatch_lease_token: dispatchLeaseToken,
|
|
})
|
|
if (error) {
|
|
const conflict = error.code === '40001' || error.message?.includes('lease_conflict')
|
|
throw new PushContractError(conflict ? 'push_lease_conflict' : 'push_lease_failed', conflict ? 409 : 500)
|
|
}
|
|
return normalizeLeasedDeliveries(data)
|
|
}
|
|
|
|
async function finalizeDelivery(
|
|
delivery: LeasedDelivery,
|
|
outcome: 'delivered' | 'stale' | 'retryable_failure' | 'permanent_failure',
|
|
errorCode: string | null,
|
|
): Promise<void> {
|
|
const { error } = await createServiceRoleClient().rpc('finalize_push_delivery', {
|
|
target_delivery_id: delivery.deliveryId,
|
|
target_delivery_lease_token: delivery.deliveryLeaseToken,
|
|
delivery_outcome: outcome,
|
|
delivery_error_code: errorCode,
|
|
})
|
|
if (error) {
|
|
const conflict = error.code === '40001' || error.message?.includes('lease_conflict')
|
|
throw new PushContractError(
|
|
conflict ? 'push_delivery_lease_conflict' : 'push_delivery_finalize_failed',
|
|
conflict ? 409 : 500,
|
|
)
|
|
}
|
|
}
|
|
|
|
function normalizeDispatchSummary(value: unknown, attemptId: string): DispatchSummary {
|
|
if (!isRecord(value) || value.attempt_id !== attemptId) {
|
|
throw new PushContractError('push_contract_invalid', 502)
|
|
}
|
|
const status = value.status
|
|
if (
|
|
status !== 'pending' && status !== 'processing' && status !== 'succeeded'
|
|
&& status !== 'partial' && status !== 'failed'
|
|
) {
|
|
throw new PushContractError('push_contract_invalid', 502)
|
|
}
|
|
const complete = value.complete
|
|
if (typeof complete !== 'boolean') throw new PushContractError('push_contract_invalid', 502)
|
|
const count = (key: string): number => {
|
|
const candidate = value[key] ?? 0
|
|
if (typeof candidate !== 'number' || !Number.isSafeInteger(candidate) || candidate < 0) {
|
|
throw new PushContractError('push_contract_invalid', 502)
|
|
}
|
|
return candidate
|
|
}
|
|
const nextRetryAt = value.next_retry_at ?? null
|
|
if (nextRetryAt !== null && (
|
|
typeof nextRetryAt !== 'string' || !Number.isFinite(Date.parse(nextRetryAt))
|
|
)) {
|
|
throw new PushContractError('push_contract_invalid', 502)
|
|
}
|
|
return {
|
|
attemptId,
|
|
status,
|
|
complete,
|
|
delivered: count('delivered'),
|
|
stale: count('stale'),
|
|
retryableFailed: count('retryable_failed'),
|
|
permanentFailed: count('permanent_failed'),
|
|
nextRetryAt,
|
|
}
|
|
}
|
|
|
|
async function finalizeDispatch(
|
|
attemptId: string,
|
|
dispatchLeaseToken: string,
|
|
): Promise<DispatchSummary> {
|
|
const { data, error } = await createServiceRoleClient().rpc('finalize_push_dispatch', {
|
|
target_attempt_id: attemptId,
|
|
target_dispatch_lease_token: dispatchLeaseToken,
|
|
})
|
|
if (error) {
|
|
const conflict = error.code === '40001' || error.message?.includes('lease_conflict')
|
|
throw new PushContractError(
|
|
conflict ? 'push_dispatch_lease_conflict' : 'push_dispatch_finalize_failed',
|
|
conflict ? 409 : 500,
|
|
)
|
|
}
|
|
return normalizeDispatchSummary(data, attemptId)
|
|
}
|
|
|
|
function retryableFcmError(error: unknown): { code: string; status: number } {
|
|
if (!(error instanceof PushContractError)) return { code: 'fcm_send_failed', status: 502 }
|
|
const permanent = new Set(['invalid_push_payload'])
|
|
return {
|
|
code: permanent.has(error.code) ? 'push_payload_invalid' : error.code,
|
|
status: error.status,
|
|
}
|
|
}
|
|
|
|
async function processAttempt(input: {
|
|
eventType: PushEventType
|
|
resourceId: string
|
|
attemptId: string
|
|
dispatchLeaseToken: string
|
|
inviteContext?: TeamInviteNotificationContext
|
|
}): Promise<ProcessResult> {
|
|
const deliveries = await leaseDeliveries(input.attemptId, input.dispatchLeaseToken)
|
|
if (deliveries.length === 0) {
|
|
const summary = await finalizeDispatch(input.attemptId, input.dispatchLeaseToken)
|
|
return { ...summary, attempted: 0, transientError: null, transientStatus: null }
|
|
}
|
|
|
|
let inviteContext = input.inviteContext
|
|
if (input.eventType === 'team.invite.created' && !inviteContext) {
|
|
const { data: invite, error } = await createServiceRoleClient()
|
|
.from('team_invites')
|
|
.select('team_id,token,accepted_at,expires_at')
|
|
.eq('id', input.resourceId)
|
|
.maybeSingle()
|
|
if (
|
|
error || !invite || invite.accepted_at !== null
|
|
|| typeof invite.expires_at !== 'string' || Date.parse(invite.expires_at) <= Date.now()
|
|
|| !isUuid(invite.team_id) || typeof invite.token !== 'string'
|
|
) {
|
|
await mapWithConcurrency(deliveries, SEND_CONCURRENCY, (delivery) => (
|
|
finalizeDelivery(delivery, 'permanent_failure', 'push_resource_unavailable')
|
|
))
|
|
const summary = await finalizeDispatch(input.attemptId, input.dispatchLeaseToken)
|
|
return { ...summary, attempted: deliveries.length, transientError: null, transientStatus: null }
|
|
}
|
|
inviteContext = { teamId: invite.team_id, inviteToken: invite.token }
|
|
}
|
|
|
|
const unsupported = deliveries.filter((delivery) => delivery.provider !== 'fcm')
|
|
await mapWithConcurrency(unsupported, SEND_CONCURRENCY, (delivery) => (
|
|
finalizeDelivery(delivery, 'permanent_failure', 'push_provider_not_supported')
|
|
))
|
|
const fcmDeliveries = deliveries.filter((delivery) => delivery.provider === 'fcm')
|
|
|
|
let transientError: string | null = null
|
|
let transientStatus: number | null = null
|
|
if (fcmDeliveries.length > 0) {
|
|
let config: ReturnType<typeof readFcmConfig> | null = null
|
|
let accessToken: string | null = null
|
|
try {
|
|
config = readFcmConfig()
|
|
accessToken = await getFcmAccessToken(config)
|
|
} catch (error) {
|
|
const normalized = retryableFcmError(error)
|
|
transientError = normalized.code
|
|
transientStatus = normalized.status
|
|
await mapWithConcurrency(fcmDeliveries, SEND_CONCURRENCY, (delivery) => (
|
|
finalizeDelivery(delivery, 'retryable_failure', normalized.code)
|
|
))
|
|
}
|
|
|
|
if (config && accessToken) {
|
|
await mapWithConcurrency(fcmDeliveries, SEND_CONCURRENCY, async (delivery) => {
|
|
const notification = buildPushNotification(
|
|
input.eventType,
|
|
input.resourceId,
|
|
'en',
|
|
inviteContext,
|
|
)
|
|
try {
|
|
await sendFcmMessage(delivery.registrationId, notification, {
|
|
config,
|
|
getAccessToken: () => Promise.resolve(accessToken),
|
|
})
|
|
await finalizeDelivery(delivery, 'delivered', null)
|
|
} catch (error) {
|
|
if (error instanceof PushContractError && error.staleRegistration) {
|
|
await finalizeDelivery(delivery, 'stale', 'registration_stale')
|
|
return
|
|
}
|
|
const normalized = retryableFcmError(error)
|
|
if (normalized.code === 'push_payload_invalid') {
|
|
await finalizeDelivery(delivery, 'permanent_failure', normalized.code)
|
|
} else {
|
|
transientError ??= normalized.code
|
|
transientStatus ??= normalized.status
|
|
await finalizeDelivery(delivery, 'retryable_failure', normalized.code)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
const summary = await finalizeDispatch(input.attemptId, input.dispatchLeaseToken)
|
|
return {
|
|
...summary,
|
|
attempted: deliveries.length,
|
|
transientError,
|
|
transientStatus,
|
|
}
|
|
}
|
|
|
|
async function mapWithConcurrency<T, R>(
|
|
values: T[],
|
|
concurrency: number,
|
|
action: (value: T) => Promise<R>,
|
|
): Promise<R[]> {
|
|
const results = new Array<R>(values.length)
|
|
let cursor = 0
|
|
const workers = Array.from({ length: Math.min(concurrency, values.length) }, async () => {
|
|
while (cursor < values.length) {
|
|
const index = cursor++
|
|
results[index] = await action(values[index])
|
|
}
|
|
})
|
|
await Promise.all(workers)
|
|
return results
|
|
}
|
|
|
|
function normalizeClaims(value: unknown): ClaimedDispatch[] {
|
|
if (!Array.isArray(value)) throw new PushContractError('push_contract_invalid', 502)
|
|
return value.map((row) => {
|
|
if (
|
|
!isRecord(row)
|
|
|| !isUuid(row.attempt_id)
|
|
|| (row.event_type !== 'transcription.completed'
|
|
&& row.event_type !== 'team.invite.created'
|
|
&& row.event_type !== 'billing.status.changed')
|
|
|| !isUuid(row.resource_id)
|
|
|| !isUuid(row.dispatch_lease_token)
|
|
) {
|
|
throw new PushContractError('push_contract_invalid', 502)
|
|
}
|
|
return {
|
|
attemptId: row.attempt_id,
|
|
eventType: row.event_type,
|
|
resourceId: row.resource_id,
|
|
dispatchLeaseToken: row.dispatch_lease_token,
|
|
}
|
|
})
|
|
}
|
|
|
|
async function drainDueDispatches(batchLimit: number): Promise<Record<string, unknown>> {
|
|
const { data, error } = await createServiceRoleClient().rpc('claim_due_push_dispatches', {
|
|
batch_limit: batchLimit,
|
|
})
|
|
if (error) throw new PushContractError('push_drain_claim_failed', 500)
|
|
const claims = normalizeClaims(data)
|
|
const results = await mapWithConcurrency(claims, DRAIN_CONCURRENCY, async (claim) => {
|
|
try {
|
|
return await processAttempt({
|
|
eventType: claim.eventType,
|
|
resourceId: claim.resourceId,
|
|
attemptId: claim.attemptId,
|
|
dispatchLeaseToken: claim.dispatchLeaseToken,
|
|
})
|
|
} catch (error) {
|
|
return {
|
|
attemptId: claim.attemptId,
|
|
status: 'processing',
|
|
complete: false,
|
|
attempted: 0,
|
|
error: error instanceof PushContractError ? error.code : 'push_drain_failed',
|
|
}
|
|
}
|
|
})
|
|
return {
|
|
claimed: claims.length,
|
|
completed: results.filter((result) => result.complete === true).length,
|
|
pending: results.filter((result) => result.complete !== true).length,
|
|
results,
|
|
}
|
|
}
|
|
|
|
Deno.serve(async (req: Request) => {
|
|
const preflight = handleCorsPreflightRequest(req)
|
|
if (preflight) return preflight
|
|
if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405)
|
|
|
|
try {
|
|
const actor = await authenticateDispatcher(req)
|
|
const requestUrl = new URL(req.url)
|
|
const endpoint = readEndpointMode(requestUrl)
|
|
if (endpoint.mode === 'drain') {
|
|
if (actor.kind !== 'system') return jsonResponse({ error: 'forbidden' }, 403)
|
|
const body = await readJson(req)
|
|
if (!isRecord(body) || Object.keys(body).length !== 0) {
|
|
throw new PushContractError('invalid_push_request', 400)
|
|
}
|
|
return jsonResponse(await drainDueDispatches(endpoint.batchLimit))
|
|
}
|
|
|
|
const input = parsePushRequest(await readJson(req))
|
|
const userClient = actor.kind === 'user' ? createUserClient(req) : null
|
|
const resolved = await resolvePushEvent(
|
|
input.eventType,
|
|
input.resourceId,
|
|
actor,
|
|
userClient,
|
|
)
|
|
if (resolved.recipientUserIds.length === 0) {
|
|
return jsonResponse({
|
|
event_type: input.eventType,
|
|
resource_id: input.resourceId,
|
|
duplicate: false,
|
|
status: 'succeeded',
|
|
attempted: 0,
|
|
sent: 0,
|
|
stale_removed: 0,
|
|
})
|
|
}
|
|
|
|
const reservation = await reserveDispatch(
|
|
actor,
|
|
userClient,
|
|
input.eventType,
|
|
input.resourceId,
|
|
)
|
|
if (reservation.duplicate || reservation.dispatchLeaseToken === null) {
|
|
return jsonResponse({
|
|
event_type: input.eventType,
|
|
resource_id: input.resourceId,
|
|
duplicate: true,
|
|
status: reservation.status,
|
|
next_retry_at: reservation.nextRetryAt,
|
|
attempted: 0,
|
|
sent: 0,
|
|
stale_removed: 0,
|
|
})
|
|
}
|
|
|
|
const result = await processAttempt({
|
|
eventType: input.eventType,
|
|
resourceId: input.resourceId,
|
|
attemptId: reservation.attemptId,
|
|
dispatchLeaseToken: reservation.dispatchLeaseToken,
|
|
inviteContext: resolved.inviteContext,
|
|
})
|
|
const responseBody = {
|
|
event_type: input.eventType,
|
|
resource_id: input.resourceId,
|
|
duplicate: false,
|
|
status: result.status,
|
|
complete: result.complete,
|
|
attempted: result.attempted,
|
|
sent: result.delivered,
|
|
stale_removed: result.stale,
|
|
retryable_failed: result.retryableFailed,
|
|
permanent_failed: result.permanentFailed,
|
|
next_retry_at: result.nextRetryAt,
|
|
}
|
|
if (result.transientError) {
|
|
return jsonResponse(
|
|
{ error: result.transientError, ...responseBody },
|
|
result.transientStatus ?? 502,
|
|
)
|
|
}
|
|
if (result.status === 'partial' || result.status === 'failed') {
|
|
return jsonResponse({ error: 'push_delivery_failed', ...responseBody }, 502)
|
|
}
|
|
return jsonResponse(responseBody, result.complete ? 200 : 202)
|
|
} catch (error) {
|
|
if (error instanceof PushContractError) {
|
|
return jsonResponse({ error: error.code, message: error.code }, error.status)
|
|
}
|
|
if (error && typeof error === 'object' && 'status' in error && 'message' in error) {
|
|
return jsonResponse({ error: 'unauthorized', message: 'unauthorized' }, 401)
|
|
}
|
|
return jsonResponse({ error: 'push_dispatch_failed', message: 'push_dispatch_failed' }, 500)
|
|
}
|
|
})
|