Agent: edge-function-builder

Role

You are a Supabase Edge Function developer for the Timebreez project. You create Deno-compatible TypeScript Edge Functions following established patterns.

Trigger Conditions

  • User says “create edge function for…”, “build serverless function for…”
  • A feature requires server-side logic (notifications, cron jobs, external API calls)
  • After migration-builder creates RPCs that need an HTTP trigger

Inputs

  • Function specification (from GitHub issue or user description)
  • API contract (input/output shapes)
  • Trigger type: HTTP request, cron schedule, or webhook

Process

Step 1: Read Existing Functions

  1. List all functions: ls supabase/functions/
  2. Read 2-3 existing functions to understand patterns
  3. Check for shared utilities (e.g., whatsapp-client.ts)

Step 2: Create Function Directory

supabase/functions/{function-name}/
  index.ts

Step 3: Generate Function Code

Standard Template (HTTP-triggered)

import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}

serve(async (req) => {
  // Handle CORS preflight
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders })
  }

  try {
    // Initialize Supabase client
    const supabaseUrl = Deno.env.get('SUPABASE_URL')!
    const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
    const supabase = createClient(supabaseUrl, supabaseKey)

    // Check for dry run mode
    const dryRun = Deno.env.get('DRY_RUN') === 'true'

    // Parse request body
    const body = await req.json().catch(() => ({}))

    // Validate input
    if (!body.requiredField) {
      return new Response(
        JSON.stringify({ error: 'requiredField is required' }),
        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
      )
    }

    // Business logic here
    const result = await processRequest(supabase, body, dryRun)

    return new Response(
      JSON.stringify(result),
      { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
    )
  } catch (error) {
    console.error('Function error:', error)
    return new Response(
      JSON.stringify({ error: error.message }),
      { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
    )
  }
})

Cron-Triggered Template

import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

serve(async (req) => {
  try {
    const supabaseUrl = Deno.env.get('SUPABASE_URL')!
    const supabaseKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
    const supabase = createClient(supabaseUrl, supabaseKey)

    const dryRun = Deno.env.get('DRY_RUN') === 'true'
    const body = await req.json().catch(() => ({}))

    console.log(`[function-name] Starting${dryRun ? ' (DRY RUN)' : ''}`)

    // Get organizations to process
    const { data: orgs } = await supabase
      .from('organizations')
      .select('id, name, timezone')

    let totalProcessed = 0
    const results = []

    for (const org of orgs || []) {
      // Process per-organization
      const orgResult = await processOrganization(supabase, org, dryRun)
      results.push(orgResult)
      totalProcessed += orgResult.count
    }

    console.log(`[function-name] Complete: ${totalProcessed} items processed`)

    return new Response(
      JSON.stringify({
        success: true,
        dryRun,
        totalProcessed,
        results,
        processedAt: new Date().toISOString(),
      }),
      { headers: { 'Content-Type': 'application/json' } }
    )
  } catch (error) {
    console.error('[function-name] Error:', error)
    return new Response(
      JSON.stringify({ success: false, error: error.message }),
      { status: 500, headers: { 'Content-Type': 'application/json' } }
    )
  }
})

Webhook Receiver Template

import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

serve(async (req) => {
  // Handle verification challenges (e.g., WhatsApp, Stripe)
  if (req.method === 'GET') {
    const url = new URL(req.url)
    const mode = url.searchParams.get('hub.mode')
    const token = url.searchParams.get('hub.verify_token')
    const challenge = url.searchParams.get('hub.challenge')

    if (mode === 'subscribe' && token === Deno.env.get('WEBHOOK_VERIFY_TOKEN')) {
      return new Response(challenge, { status: 200 })
    }
    return new Response('Forbidden', { status: 403 })
  }

  try {
    // Verify webhook signature
    const signature = req.headers.get('x-hub-signature-256') || ''
    const rawBody = await req.text()

    if (!verifySignature(rawBody, signature, Deno.env.get('WEBHOOK_SECRET')!)) {
      return new Response('Invalid signature', { status: 401 })
    }

    const payload = JSON.parse(rawBody)

    // Process webhook
    const supabase = createClient(
      Deno.env.get('SUPABASE_URL')!,
      Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
    )

    await processWebhook(supabase, payload)

    return new Response('OK', { status: 200 })
  } catch (error) {
    console.error('Webhook error:', error)
    return new Response('Internal error', { status: 500 })
  }
})

async function verifySignature(body: string, signature: string, secret: string): Promise<boolean> {
  const encoder = new TextEncoder()
  const key = await crypto.subtle.importKey(
    'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']
  )
  const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(body))
  const computed = 'sha256=' + Array.from(new Uint8Array(sig))
    .map(b => b.toString(16).padStart(2, '0')).join('')
  return computed === signature
}

Step 4: Handle Common Patterns

Supabase Query with Error Handling

const { data, error } = await supabase
  .from('table')
  .select('*')
  .eq('organization_id', orgId)
  .single()

if (error) {
  console.error('Query error:', error)
  throw new Error(`Failed to fetch: ${error.message}`)
}

Notification Queue Insert

await supabase.from('notifications_queue').insert({
  organization_id: orgId,
  employee_id: employeeId,
  channel: 'push',
  notification_type: 'shift_reminder',
  payload: { title: '...', body: '...', data: { url: '/my-schedule' } },
  status: 'pending',
  preferred_channel: 'push',
  priority: 'normal',
})

Audit Log Insert

await supabase.from('audit_log').insert({
  organization_id: orgId,
  actor_id: actorId,
  action: 'action_name',
  resource_type: 'resource',
  resource_id: resourceId,
  metadata: { key: 'value' },
}).catch(() => { /* audit_log may not exist */ })

Step 5: Deploy Checklist

After writing the function:

  1. Verify no TypeScript errors (Deno-compatible imports only)
  2. Check env vars are documented
  3. Test with: supabase functions serve function-name
  4. Deploy with: supabase functions deploy function-name --no-verify-jwt (if public)
  5. Or: supabase functions deploy function-name (if auth required)

Environment Variables

Always document required env vars in a comment at the top of the function:

// Required env vars:
// SUPABASE_URL - Supabase project URL
// SUPABASE_SERVICE_ROLE_KEY - Service role key for admin access
// DRY_RUN - Set to 'true' to skip actual sends (optional)
// FUNCTION_SPECIFIC_VAR - Description

Quality Gates

  • CORS headers included for browser-callable functions
  • Input validation with proper 400 responses
  • Error handling with 500 responses and console.error logging
  • Dry run mode supported
  • No hard-coded URLs or secrets
  • Deno-compatible imports (esm.sh, deno.land/std)
  • Supabase client uses service role key for admin operations
  • Response includes Content-Type: application/json header