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-emailinto multiple sub-modules - Remove the obsolete
send-calibration-reminder
Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Refactor scope | Full refactor (middleware pattern) | One-time effort, lowest long-term maintenance cost |
| Scraper functions | Keep 4 independent functions, share core logic | No frontend changes required |
| AI functions | Keep 3 independent functions, generic AI engine | Each function only retains data fetching and prompt building |
| Auth strategy | Built-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 function | Keep as-is | Router/dispatcher has unique logic; forcing unification is counterproductive |
send-calibration-reminder | Delete | Obsolete, was using Resend API which is no longer in use |
send-email | Split into 7 sub-modules | 3,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 definitionserrors.ts — Error Codes and Custom Error Class
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
// 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>): ResponseResponse format:
// Success
{ "success": true, "data": { ... } }
// Error
{ "success": false, "error": { "code": "AUTH_ERROR", "message": "Missing authorization header" } }handler.ts — withHandler() Middleware
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
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
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 tokenscrapers.ts — Scraper Pipeline Invocation
async function triggerScraperPipeline(
pipelineName: string,
token: string,
params: Record<string, unknown>,
corsHeaders: Record<string, string>,
): Promise<Response>ai.ts — AI Description Generation Engine
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 handlingsend-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 definitionsModule 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 databasegenerateFlowMeterCSV()— CSV attachment generationaggregateDailySummaries()— Daily usage aggregationgenerateDailyUsageChartUrl()— QuickChart.io chart URLsformatSummariesAsHTML()— Complete branded HTML email template
tank-level-alert.ts — Tank Level Alerts
fetchTankLevelAlertData()— Alert data fetchingcalculateSvgTankValues()— SVG tank visualization calculationsformatTankAlertAsHTML()— Complete branded HTML email templategroupTanksBySite()— Group tanks by site
graph-api.ts — Microsoft Graph API
getGraphToken()— OAuth2 client credentials token acquisitionsendEmailViaGraph()— Send email with attachments, CC, BCC support
template.ts — Template Processing
replaceTemplateVariables()— Body template variable replacementreplaceSubjectTemplateVariables()— Subject template variable replacement
content.ts — Content Building
buildEmailContent()— Plain text to HTML conversionescapeHtml()— HTML entity escapingparseRecipientInput()— Recipient email parsingdownloadImageAsBase64()— Image downloadingconvertStoragePathsToBase64()— Storage path to base64 data URI conversion
schedule.ts — Schedule Management
calculateNextOccurrence()— Next send time calculationisRecurrenceComplete()— Recurrence end checklockScheduleForSend()— Atomic lock for concurrent send preventionupdateScheduleStatus()— Status updatesinsertEmailLog()— Audit loggingcalculateDateRange()— Date range calculation- AWST timezone conversion utilities
types.ts — Type Definitions
EmailRequestPayload,EmailScheduleRow,EmailSenderRow, etc.
Post-Refactor Function Overview
Scraper Functions (4)
// 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)
// 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
// 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
serve(withHandler({ auth: 'cron-secret', cronSecretHeader: 'x-tank-alert-cron-secret', supabase: 'service' }, async (ctx) => {
// Business logic
}));admin-manage-users
serve(withHandler({ auth: 'bearer', supabase: 'service' }, async (ctx) => {
// Business logic
}));generate-pdf-report
serve(withHandler({ auth: 'bearer', supabase: 'service' }, async (ctx) => {
// Business logic
}));Unchanged
mainfunction — 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 codesAuthStrategy—'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:
AppErrorclass extendingErrorwithcode: ErrorCodeandstatus: 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 strategiesbearer: ExtractsAuthorization: Bearer <token>, throwsAppError('AUTH_ERROR', ..., 401)if missingcron-secret: Reads header name fromcronSecretHeader, env var fromcronSecretEnvVar(or derives from header name), throwsAppError('FORBIDDEN', ..., 403)if mismatchnone: Returnsundefined
Step 5: _shared/supabase.ts (80 lines)
Depends on: types.ts (SupabaseConfig, SupabaseMode), errors.ts (AppError)
Exports:
getSupabaseConfig()— ReadsCUSTOM_DB_URL || SUPABASE_URL,SUPABASE_SERVICE_ROLE_KEY,SUPABASE_ANON_KEY; throwsAppError('CONFIG_ERROR')if missingcreateSupabaseClientFromMode(mode, token?)— Creates Supabase client'service'→ usesserviceKey, bypasses RLS'user'→ usesanonKey+ 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- CORS preflight handling (
OPTIONS→ 200) - Auth resolution via
resolveAuth() - Supabase client creation via
createSupabaseClientFromMode()(ifoptions.supabaseis set) - Execute handler with
HandlerContext - Catch
AppError→jsonError(), catch unknown →INTERNAL_ERROR
- CORS preflight handling (
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, returnsjsonSuccess({ run_id, status, message })checkScraperRunStatus(runId, token, corsHeaders)— GET scraper run status, returnsjsonSuccess({ run_id, status, ... })
Internal:
- Reads
SCRAPER_API_BASE_URLfrom 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 ClaudeisOpenAIModel(model)— Check if model string is OpenAIparseApiKeys(envValue)— Parse comma-separated API keysresolveAiConfig(model)— Returns{ apiUrl, apiKeys }from env vars based on model namecallAI(model, prompt, systemPrompt, apiUrl, apiKeys, maxTokens)— Multi-provider AI call with automatic key rotation on failure
Internal:
callClaudeAI()— Anthropic Messages API formatcallOpenAI()— 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:
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 byjsonSuccessinside 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,callAIfrom_shared/ai.ts - Add imports:
withHandler,jsonSuccess,AppErrorfrom_shared/ - Add
SYSTEM_PROMPTconstant (was inline before) - Update
callAI()calls to passsystemPromptparameter - Replace
throw new Error(...)withthrow new AppError('VALIDATION_ERROR', ..., 400) - Replace
new Response(JSON.stringify(...))withjsonSuccess() - 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:
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()andcreateSupabaseClientFromMode() - 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:
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
AppErrorinstead 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:
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
AppErrorfor 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
| Phase | Step | Module/Function | Status |
|---|---|---|---|
| 1 | 1-8 | Shared modules (_shared/) | ✅ Done |
| 2 | 9-12 | 4 scraper functions | ✅ Done |
| 3 | 13-15 | 3 AI functions | ✅ Done |
| 4 | 16-18 | process-tank-alerts, admin-manage-users, generate-pdf-report | ✅ Done |
| 5 | 19 | Delete send-calibration-reminder | ✅ Done |
| 6 | 20+ | 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)
- [ ]
mainfunction unchanged and still working
After all phases:
- [ ] Each shared module is independently importable
- [ ] All exports properly typed
- [ ]
send-emailsplit into 7 single-responsibility modules (~300 lines orchestration) - [ ] Total line count reduction ~40-50%
Expected Outcomes
- ~40-50% reduction in code duplication
send-emailsplit 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