{{ theme.skipToContentLabel || 'Skip to content' }}

Edge Functions Comprehensive Refactor Design

Background

13 Supabase Edge Functions contain significant code duplication (CORS handling, authentication, error responses, Supabase configuration, etc.), and the send-email function has grown to 3,680 lines. This refactor aims to:

  • Introduce a withHandler() middleware to unify CORS, auth, and error handling
  • Extract shared modules to eliminate duplicate code
  • Standardize error response format
  • Split send-email into multiple sub-modules
  • Remove the obsolete send-calibration-reminder

Design Decisions

DecisionChoiceRationale
Refactor scopeFull refactor (middleware pattern)One-time effort, lowest long-term maintenance cost
Scraper functionsKeep 4 independent functions, share core logicNo frontend changes required
AI functionsKeep 3 independent functions, generic AI engineEach function only retains data fetching and prompt building
Auth strategyBuilt-in configurable (bearer/cron-secret/none)Auth modes are limited and well-defined; declarative is more intuitive
Error format{ success, error: { code, message } }Frontend can handle errors granularly by error code
main functionKeep as-isRouter/dispatcher has unique logic; forcing unification is counterproductive
send-calibration-reminderDeleteObsolete, was using Resend API which is no longer in use
send-emailSplit into 7 sub-modules3,680 lines with 5+ responsibilities needs separation of concerns

Shared Module Design

Directory Structure

supabase/functions/_shared/
├── cors.ts          # Existing, keep unchanged
├── handler.ts       # withHandler() middleware wrapper
├── response.ts      # jsonSuccess() / jsonError() unified responses
├── auth.ts          # Auth strategy implementations
├── supabase.ts      # Supabase config and client creation
├── errors.ts        # ErrorCode definitions and AppError class
├── ai.ts            # AI description generation engine
├── scrapers.ts      # Scraper pipeline invocation logic
└── types.ts         # Shared type definitions

errors.ts — Error Codes and Custom Error Class

typescript
type ErrorCode =
  | 'AUTH_ERROR'
  | 'FORBIDDEN'
  | 'VALIDATION_ERROR'
  | 'NOT_FOUND'
  | 'DATABASE_ERROR'
  | 'EXTERNAL_API_ERROR'
  | 'CONFIG_ERROR'
  | 'INTERNAL_ERROR';

class AppError extends Error {
  constructor(
    public code: ErrorCode,
    message: string,
    public status: number = 500,
  ) {
    super(message);
  }
}

response.ts — Unified Response Format

typescript
// Success
function jsonSuccess(data: unknown, corsHeaders: Record<string, string>, status = 200): Response

// Error
function jsonError(code: ErrorCode, message: string, status: number, corsHeaders: Record<string, string>): Response

Response format:

json
// Success
{ "success": true, "data": { ... } }

// Error
{ "success": false, "error": { "code": "AUTH_ERROR", "message": "Missing authorization header" } }

handler.ts — withHandler() Middleware

typescript
interface HandlerOptions {
  auth: 'bearer' | 'cron-secret' | 'none';
  supabase?: 'service' | 'user' | false;  // Defaults to false
  cronSecretHeader?: string;               // Required when auth: 'cron-secret'
}

interface HandlerContext {
  req: Request;
  corsHeaders: Record<string, string>;
  token?: string;
  supabase?: SupabaseClient;
}

type HandlerFn = (ctx: HandlerContext) => Promise<Response>;

function withHandler(options: HandlerOptions, handler: HandlerFn) {
  return async (req: Request): Promise<Response> => {
    const corsHeaders = getCorsHeaders(req);

    // 1. CORS preflight
    if (req.method === 'OPTIONS') {
      return new Response('ok', { headers: corsHeaders });
    }

    try {
      // 2. Authentication
      const token = resolveAuth(req, options, corsHeaders);

      // 3. Supabase client (created on demand)
      const supabase = options.supabase
        ? createSupabaseClient(options.supabase, token)
        : undefined;

      // 4. Execute business logic
      return await handler({ req, corsHeaders, token, supabase });

    } catch (err) {
      // 5. Unified error handling
      if (err instanceof AppError) {
        return jsonError(err.code, err.message, err.status, corsHeaders);
      }
      console.error('Unhandled error:', err);
      return jsonError('INTERNAL_ERROR', 'An unexpected error occurred', 500, corsHeaders);
    }
  };
}

auth.ts — Auth Strategies

typescript
function resolveAuth(req: Request, options: HandlerOptions, corsHeaders: Record<string, string>): string | undefined {
  switch (options.auth) {
    case 'bearer':
      // Extract Authorization: Bearer <token>, throw AppError('AUTH_ERROR', ..., 401) if missing
    case 'cron-secret':
      // Validate specified header, throw AppError('FORBIDDEN', ..., 403) if mismatch
    case 'none':
      return undefined;
  }
}

supabase.ts — Supabase Config and Client

typescript
interface SupabaseConfig {
  url: string;
  serviceKey: string;
  anonKey: string;
}

function getSupabaseConfig(): SupabaseConfig
// Reads CUSTOM_DB_URL || SUPABASE_URL etc., throws AppError('CONFIG_ERROR') if missing

function createSupabaseClient(mode: 'service' | 'user', token?: string): SupabaseClient
// mode: 'service' → uses serviceKey
// mode: 'user' → uses anonKey + sets auth header with token

scrapers.ts — Scraper Pipeline Invocation

typescript
async function triggerScraperPipeline(
  pipelineName: string,
  token: string,
  params: Record<string, unknown>,
  corsHeaders: Record<string, string>,
): Promise<Response>

ai.ts — AI Description Generation Engine

typescript
interface AiDescriptionConfig {
  supabase: SupabaseClient;
  fetchData: () => Promise<unknown>;
  buildPrompt: (data: unknown) => string;
  model?: string;  // Defaults to DeepSeek V3.2
}

async function generateDescriptions(config: AiDescriptionConfig): Promise<string>
// Handles AI API calls, API key management, and error handling

send-email Split Design

Current Problem

send-email/index.ts is 3,680 lines with 5+ distinct responsibilities.

Post-Split Structure

supabase/functions/send-email/
├── index.ts                    # HTTP handler + orchestration (~300 lines)
├── flow-meter-report.ts        # Flow meter data fetching, CSV generation, charts, HTML templates
├── tank-level-alert.ts         # Tank level alert data, SVG tank visualization, HTML templates
├── graph-api.ts                # Microsoft Graph API OAuth authentication and email sending
├── template.ts                 # Template variable replacement (subject + body)
├── content.ts                  # Email content building (HTML escaping, image embedding, storage path to base64)
├── schedule.ts                 # Recurrence calculation, schedule DB operations (locking, status updates, logging)
└── types.ts                    # Type definitions

Module Responsibilities

index.ts — Orchestration Layer

  • HTTP handler (manual send / cron processing modes)
  • Calls modules to assemble email content
  • Wrapped with withHandler()

flow-meter-report.ts — Flow Meter Reports

  • fetchFlowMeterRecords() — Query data from database
  • generateFlowMeterCSV() — CSV attachment generation
  • aggregateDailySummaries() — Daily usage aggregation
  • generateDailyUsageChartUrl() — QuickChart.io chart URLs
  • formatSummariesAsHTML() — Complete branded HTML email template

tank-level-alert.ts — Tank Level Alerts

  • fetchTankLevelAlertData() — Alert data fetching
  • calculateSvgTankValues() — SVG tank visualization calculations
  • formatTankAlertAsHTML() — Complete branded HTML email template
  • groupTanksBySite() — Group tanks by site

graph-api.ts — Microsoft Graph API

  • getGraphToken() — OAuth2 client credentials token acquisition
  • sendEmailViaGraph() — Send email with attachments, CC, BCC support

template.ts — Template Processing

  • replaceTemplateVariables() — Body template variable replacement
  • replaceSubjectTemplateVariables() — Subject template variable replacement

content.ts — Content Building

  • buildEmailContent() — Plain text to HTML conversion
  • escapeHtml() — HTML entity escaping
  • parseRecipientInput() — Recipient email parsing
  • downloadImageAsBase64() — Image downloading
  • convertStoragePathsToBase64() — Storage path to base64 data URI conversion

schedule.ts — Schedule Management

  • calculateNextOccurrence() — Next send time calculation
  • isRecurrenceComplete() — Recurrence end check
  • lockScheduleForSend() — Atomic lock for concurrent send prevention
  • updateScheduleStatus() — Status updates
  • insertEmailLog() — Audit logging
  • calculateDateRange() — Date range calculation
  • AWST timezone conversion utilities

types.ts — Type Definitions

  • EmailRequestPayload, EmailScheduleRow, EmailSenderRow, etc.

Post-Refactor Function Overview

Scraper Functions (4)

typescript
// trigger-dust-level-scraper/index.ts (example)
serve(withHandler({ auth: 'bearer' }, async (ctx) => {
  const body = await ctx.req.json();
  return triggerScraperPipeline('dust-level', ctx.token!, body.params, ctx.corsHeaders);
}));

~8 lines, down from 60-80 lines.

AI Functions (3)

typescript
// generate-chart-descriptions/index.ts (example)
serve(withHandler({ auth: 'bearer', supabase: 'service' }, async (ctx) => {
  const { mineSiteId, dateRange } = await ctx.req.json();
  return jsonSuccess(
    await generateDescriptions({
      supabase: ctx.supabase!,
      fetchData: () => fetchDustLevelChartData(ctx.supabase!, mineSiteId, dateRange),
      buildPrompt: buildChartPrompt,
    }),
    ctx.corsHeaders,
  );
}));

Each function only retains its data fetching and prompt building logic.

send-email

typescript
// send-email/index.ts
serve(withHandler({ auth: 'bearer', supabase: 'service' }, async (ctx) => {
  const payload = await ctx.req.json();

  if (payload.action === 'process_due') {
    return await processDueSchedules(ctx);
  }

  return await handleManualSend(ctx, payload);
}));

~300 lines of orchestration logic, with business details distributed across sub-modules.

process-tank-alerts

typescript
serve(withHandler({ auth: 'cron-secret', cronSecretHeader: 'x-tank-alert-cron-secret', supabase: 'service' }, async (ctx) => {
  // Business logic
}));

admin-manage-users

typescript
serve(withHandler({ auth: 'bearer', supabase: 'service' }, async (ctx) => {
  // Business logic
}));

generate-pdf-report

typescript
serve(withHandler({ auth: 'bearer', supabase: 'service' }, async (ctx) => {
  // Business logic
}));

Unchanged

  • main function — Router/dispatcher with unique logic, kept as-is
  • _shared/cors.ts — Existing, kept unchanged
  • Frontend API calls — No breaking changes

Deleted

  • send-calibration-reminder — Obsolete

Implementation Plan

Phase 1: Shared Modules (leaf modules first, then composites)

Execute in dependency order — leaf modules with no internal dependencies first.

Step 1: _shared/types.ts (86 lines)

No dependencies. Foundation for all other modules.

Exports:

  • ErrorCode — Union type of 8 error codes
  • AuthStrategy'bearer' | 'cron-secret' | 'none'
  • SupabaseMode'service' | 'user'
  • HandlerOptions{ auth, supabase?, cronSecretHeader?, cronSecretEnvVar? }
  • HandlerContext{ req, corsHeaders, token?, supabase? }
  • HandlerFn(ctx: HandlerContext) => Promise<Response>
  • SupabaseConfig{ url, serviceKey, anonKey }
  • AiModelConfig{ apiUrl, apiKeys }
  • AiDescriptionConfig, ScraperPipelineConfig

Step 2: _shared/errors.ts (24 lines)

Depends on: types.ts (ErrorCode)

Exports:

  • AppError class extending Error with code: ErrorCode and status: number
  • Default status 500, overridable per throw site

Step 3: _shared/response.ts (36 lines)

Depends on: types.ts (ErrorCode)

Exports:

  • jsonSuccess(data, corsHeaders, status?){ success: true, data }
  • jsonError(code, message, status, corsHeaders){ success: false, error: { code, message } }

Step 4: _shared/auth.ts (71 lines)

Depends on: types.ts (HandlerOptions), errors.ts (AppError)

Exports:

  • resolveAuth(req, options) — Dispatches to bearer/cron-secret/none strategies
    • bearer: Extracts Authorization: Bearer <token>, throws AppError('AUTH_ERROR', ..., 401) if missing
    • cron-secret: Reads header name from cronSecretHeader, env var from cronSecretEnvVar (or derives from header name), throws AppError('FORBIDDEN', ..., 403) if mismatch
    • none: Returns undefined

Step 5: _shared/supabase.ts (80 lines)

Depends on: types.ts (SupabaseConfig, SupabaseMode), errors.ts (AppError)

Exports:

  • getSupabaseConfig() — Reads CUSTOM_DB_URL || SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SUPABASE_ANON_KEY; throws AppError('CONFIG_ERROR') if missing
  • createSupabaseClientFromMode(mode, token?) — Creates Supabase client
    • 'service' → uses serviceKey, bypasses RLS
    • 'user' → uses anonKey + sets auth header with bearer token

Step 6: _shared/handler.ts (59 lines)

Depends on: cors.ts (existing), auth.ts, supabase.ts, errors.ts, response.ts, types.ts

Exports:

  • withHandler(options, handler) — Core middleware wrapper
    1. CORS preflight handling (OPTIONS → 200)
    2. Auth resolution via resolveAuth()
    3. Supabase client creation via createSupabaseClientFromMode() (if options.supabase is set)
    4. Execute handler with HandlerContext
    5. Catch AppErrorjsonError(), catch unknown → INTERNAL_ERROR

Step 7: _shared/scrapers.ts (123 lines)

Depends on: errors.ts (AppError), response.ts (jsonSuccess)

Exports:

  • triggerScraperPipeline(pipelineName, token, params, corsHeaders, reason?) — POST to external scraper API, returns jsonSuccess({ run_id, status, message })
  • checkScraperRunStatus(runId, token, corsHeaders) — GET scraper run status, returns jsonSuccess({ run_id, status, ... })

Internal:

  • Reads SCRAPER_API_BASE_URL from env
  • Constructs pipeline-specific endpoint URLs
  • Handles HTTP errors from scraper API

Step 8: _shared/ai.ts (258 lines)

No _shared dependencies (standalone module).

Exports:

  • DEFAULT_MODEL'deepseek-v3-2'
  • isClaudeModel(model) — Check if model string is Claude
  • isOpenAIModel(model) — Check if model string is OpenAI
  • parseApiKeys(envValue) — Parse comma-separated API keys
  • resolveAiConfig(model) — Returns { apiUrl, apiKeys } from env vars based on model name
  • callAI(model, prompt, systemPrompt, apiUrl, apiKeys, maxTokens) — Multi-provider AI call with automatic key rotation on failure

Internal:

  • callClaudeAI() — Anthropic Messages API format
  • callOpenAI() — OpenAI Chat Completions format (also used for DeepSeek)
  • Key rotation: tries each API key in sequence, falls back on 429/5xx errors

Phase 2: Refactor Scraper Functions (4 functions)

Each scraper function is refactored to use withHandler() + triggerScraperPipeline().

Step 9: trigger-dust-level-scraper/index.ts (33 lines, down from ~80)

Before: Manual CORS, manual auth extraction, manual fetch to scraper API, manual error handling.

After:

typescript
serve(withHandler({ auth: 'bearer' }, async (ctx) => {
  const body = await ctx.req.json();
  return triggerScraperPipeline('dust-level', ctx.token!, body, ctx.corsHeaders);
}));

Changes:

  • Remove CORS handling (handled by withHandler)
  • Remove auth extraction (handled by withHandler)
  • Remove manual fetch + error handling (handled by triggerScraperPipeline)
  • Remove manual new Response(JSON.stringify(...)) (handled by jsonSuccess inside scrapers.ts)

Step 10: trigger-flow-meter-scraper/index.ts (76 lines, down from ~120)

Same pattern as dust-level. Keeps local ScraperParams interface and buildApiParams() helper for flow-meter-specific parameter construction.

Step 11: trigger-asset-location-scraper/index.ts (62 lines, down from ~100)

Supports both GET (status check) and POST (trigger):

  • GET → checkScraperRunStatus(runId, token, corsHeaders)
  • POST → triggerScraperPipeline('asset-location', token, body, corsHeaders)

Step 12: trigger-heatmap-scraper/index.ts (62 lines, down from ~100)

Same GET/POST pattern as asset-location-scraper.

Phase 3: Refactor AI Functions (3 functions)

Each AI function removes ~200 lines of duplicated AI infrastructure and uses shared callAI() + resolveAiConfig().

Step 13: generate-chart-descriptions/index.ts (901 lines, down from ~1,100)

Changes:

  • Remove: parseApiKeys(), callAI(), callAISingle(), callClaudeAI(), callOpenAI(), model config constants, env var reading (~200 lines)
  • Add imports: DEFAULT_MODEL, resolveAiConfig, callAI from _shared/ai.ts
  • Add imports: withHandler, jsonSuccess, AppError from _shared/
  • Add SYSTEM_PROMPT constant (was inline before)
  • Update callAI() calls to pass systemPrompt parameter
  • Replace throw new Error(...) with throw new AppError('VALIDATION_ERROR', ..., 400)
  • Replace new Response(JSON.stringify(...)) with jsonSuccess()
  • Wrap handler with withHandler({ auth: 'bearer' })

Preserved: All chart-specific logic (data context building, prompt templates, chart info configs, style configs).

Step 14: generate-flow-meter-descriptions/index.ts (507 lines, down from ~700)

Same pattern as chart-descriptions. Additionally fixes hardcoded corsHeaders = { "Access-Control-Allow-Origin": "*" } to use proper CORS from withHandler.

Step 15: generate-dust-ranger-descriptions/index.ts (619 lines, down from ~820)

Same pattern. Also fixes hardcoded CORS.

Phase 4: Refactor Remaining Functions (3 functions)

Step 16: process-tank-alerts/index.ts

Special case: Uses dual auth (cron secret header OR service role bearer token).

Solution: Use withHandler({ auth: 'none' }) and implement manual dual-auth inside the handler:

typescript
serve(withHandler({ auth: 'none' }, async (ctx) => {
  const config = getSupabaseConfig();
  // Manual dual-auth check
  const cronHeader = ctx.req.headers.get('x-tank-alert-cron-secret');
  const authHeader = ctx.req.headers.get('authorization');
  if (!isCronRequest && !isServiceRole) {
    throw new AppError('AUTH_ERROR', 'Unauthorized', 401);
  }
  // ... business logic
  const client = createSupabaseClientFromMode('service');
  return jsonSuccess({ ... }, ctx.corsHeaders);
}));

Changes:

  • Remove manual CORS handling
  • Remove manual try-catch error response formatting
  • Use getSupabaseConfig() and createSupabaseClientFromMode()
  • Use jsonSuccess() for response
  • Keep all business logic (tank level calculation, alert processing) unchanged

Step 17: admin-manage-users/index.ts

Special case: Has internal JWT verification + admin role check (not just bearer extraction).

Solution: Use withHandler({ auth: 'none' }) and keep internal auth logic:

typescript
serve(withHandler({ auth: 'none' }, async (ctx) => {
  // Internal JWT verification + admin check
  // Switch on action: list, create, update, delete, reset-password
  // Uses internal jsonResponse() helper for switch case responses
}));

Changes:

  • Remove outer try-catch (handled by withHandler)
  • POST-only check uses AppError instead of manual error response
  • Keep internal jsonResponse() helper (used in switch cases)
  • Keep internal JWT verification and admin role check

Step 18: generate-pdf-report/index.ts

Special case: Has inner try-catch that updates report status to 'failed' on error before re-throwing.

Solution: Use withHandler({ auth: 'bearer' }) with inner try-catch + re-throw:

typescript
serve(withHandler({ auth: 'bearer' }, async (ctx) => {
  const config = getSupabaseConfig();
  // ... auth verification, parse body
  try {
    // ... PDF generation, upload, update status
    return jsonSuccess({ ... }, ctx.corsHeaders);
  } catch (error) {
    // Update report status to 'failed'
    await supabase.from('rpt_reports').update({ status: 'failed', ... });
    throw error; // Re-throw so withHandler catches it
  }
}));

Changes:

  • Remove manual CORS handling
  • Use getSupabaseConfig() instead of inline env var reading
  • Use AppError for validation errors
  • Use jsonSuccess() for response
  • Keep inner try-catch for report status update + re-throw pattern

Phase 5: Delete Obsolete Function

Step 19: Delete send-calibration-reminder/

Remove entire directory. Was using Resend API which is no longer in use.

Phase 6: Split send-email (separate plan)

See send-email Split Implementation Plan for detailed module-by-module breakdown.

Implementation Order Summary

PhaseStepModule/FunctionStatus
11-8Shared modules (_shared/)✅ Done
29-124 scraper functions✅ Done
313-153 AI functions✅ Done
416-18process-tank-alerts, admin-manage-users, generate-pdf-report✅ Done
519Delete send-calibration-reminder✅ Done
620+Split send-email (7 sub-modules)⬜ Pending

Verification Checklist

After each phase:

  • [ ] All modified functions import from _shared/ correctly
  • [ ] No circular dependencies between shared modules
  • [ ] CORS preflight returns 200 for all functions
  • [ ] Auth errors return { success: false, error: { code: "AUTH_ERROR" } }
  • [ ] No breaking changes to frontend API calls (same request/response shape)
  • [ ] main function unchanged and still working

After all phases:

  • [ ] Each shared module is independently importable
  • [ ] All exports properly typed
  • [ ] send-email split into 7 single-responsibility modules (~300 lines orchestration)
  • [ ] Total line count reduction ~40-50%

Expected Outcomes

  • ~40-50% reduction in code duplication
  • send-email split from 3,680 lines into 7 single-responsibility modules
  • Unified error response format across all functions
  • New edge functions require only ~10 lines + business logic
  • Significantly lower maintenance cost