mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:51:13 -05:00
Add comprehensive edge-function error handling
Enhance error handling and logging across all edge functions: - Introduce a shared edgeFunctionWrapper with standardized error handling, request/response logging, tracing, and validation hooks. - Add runtime type validation utilities (ValidationError, validators, and parse/validate helpers) and integrate into edge flow. - Implement robust validation for incoming requests and known type mismatches, with detailed logs and structured responses. - Add post-RPC and post-database error logging to surface type/mismatch issues early. - Update approval/rejection entry points to leverage new validators and centralized error handling.
This commit is contained in:
347
supabase/functions/_shared/README.md
Normal file
347
supabase/functions/_shared/README.md
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
# Edge Function Shared Utilities
|
||||||
|
|
||||||
|
Comprehensive error handling, logging, and type validation utilities for Supabase Edge Functions.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Using the Edge Function Wrapper (Recommended)
|
||||||
|
|
||||||
|
The easiest way to create a new edge function with full error handling:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
||||||
|
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
|
import { corsHeaders } from '../_shared/cors.ts';
|
||||||
|
import { validateUUID, validateString } from '../_shared/typeValidation.ts';
|
||||||
|
|
||||||
|
serve(createEdgeFunction({
|
||||||
|
name: 'my-function',
|
||||||
|
requireAuth: true,
|
||||||
|
corsHeaders,
|
||||||
|
}, async (req, { requestId, span, userId }) => {
|
||||||
|
// Parse and validate request
|
||||||
|
const body = await req.json();
|
||||||
|
validateUUID(body.id, 'id', { requestId });
|
||||||
|
validateString(body.name, 'name', { requestId });
|
||||||
|
|
||||||
|
// Your business logic here
|
||||||
|
const result = await processRequest(body);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: true, data: result }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
This automatically provides:
|
||||||
|
- ✅ CORS handling
|
||||||
|
- ✅ Authentication validation
|
||||||
|
- ✅ Request/response logging
|
||||||
|
- ✅ Distributed tracing
|
||||||
|
- ✅ Comprehensive error handling
|
||||||
|
- ✅ Performance monitoring
|
||||||
|
|
||||||
|
## Type Validation
|
||||||
|
|
||||||
|
### Basic Validation Functions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
validateRequired,
|
||||||
|
validateString,
|
||||||
|
validateUUID,
|
||||||
|
validateArray,
|
||||||
|
validateUUIDArray,
|
||||||
|
validateEntityType,
|
||||||
|
validateActionType,
|
||||||
|
validateObject,
|
||||||
|
} from '../_shared/typeValidation.ts';
|
||||||
|
|
||||||
|
// Validate required field
|
||||||
|
validateRequired(value, 'fieldName', { requestId });
|
||||||
|
|
||||||
|
// Validate string
|
||||||
|
validateString(value, 'name', { requestId });
|
||||||
|
|
||||||
|
// Validate UUID
|
||||||
|
validateUUID(value, 'userId', { requestId });
|
||||||
|
|
||||||
|
// Validate array with minimum length
|
||||||
|
validateArray(value, 'itemIds', 1, { requestId });
|
||||||
|
|
||||||
|
// Validate array of UUIDs
|
||||||
|
validateUUIDArray(value, 'submissionIds', 1, { requestId });
|
||||||
|
|
||||||
|
// Validate entity type (park, ride, company, etc.)
|
||||||
|
validateEntityType(value, 'item_type', { requestId });
|
||||||
|
|
||||||
|
// Validate action type (create, edit, delete)
|
||||||
|
validateActionType(value, 'action_type', { requestId });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Submission Item Validation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { validateSubmissionItem } from '../_shared/typeValidation.ts';
|
||||||
|
|
||||||
|
const item = validateSubmissionItem(rawData, { requestId });
|
||||||
|
// Returns: { id: string, item_type: ValidEntityType, action_type: ValidActionType }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Valid Entity Types
|
||||||
|
|
||||||
|
The following entity types are recognized by the system:
|
||||||
|
|
||||||
|
- `park`
|
||||||
|
- `ride`
|
||||||
|
- `manufacturer`
|
||||||
|
- `operator`
|
||||||
|
- `property_owner`
|
||||||
|
- `designer`
|
||||||
|
- `company` (consolidated type)
|
||||||
|
- `ride_model`
|
||||||
|
- `photo`
|
||||||
|
- `milestone`
|
||||||
|
- `timeline_event`
|
||||||
|
|
||||||
|
### Type Guards
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
isString,
|
||||||
|
isUUID,
|
||||||
|
isArray,
|
||||||
|
isObject,
|
||||||
|
isValidEntityType,
|
||||||
|
isValidActionType,
|
||||||
|
} from '../_shared/typeValidation.ts';
|
||||||
|
|
||||||
|
if (isString(value)) {
|
||||||
|
// TypeScript now knows value is a string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValidEntityType(type)) {
|
||||||
|
// TypeScript knows type is ValidEntityType
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Automatic Error Categorization
|
||||||
|
|
||||||
|
The edge function wrapper automatically handles:
|
||||||
|
|
||||||
|
#### Validation Errors (400 Bad Request)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Invalid entity type: operator_invalid",
|
||||||
|
"field": "item_type",
|
||||||
|
"expected": "one of: park, ride, manufacturer, ...",
|
||||||
|
"received": "operator_invalid",
|
||||||
|
"requestId": "abc-123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Database Errors
|
||||||
|
- **23505** → 409 Conflict (unique constraint violation)
|
||||||
|
- **23503** → 400 Bad Request (foreign key violation)
|
||||||
|
- **23514** → 400 Bad Request (check constraint violation)
|
||||||
|
- **P0001** → 400 Bad Request (raised exception)
|
||||||
|
- **42501** → 403 Forbidden (insufficient privilege)
|
||||||
|
|
||||||
|
#### Authentication Errors (401 Unauthorized)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Missing Authorization header",
|
||||||
|
"requestId": "abc-123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Error Formatting
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { formatEdgeError, toError } from '../_shared/errorFormatter.ts';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Some operation
|
||||||
|
} catch (error) {
|
||||||
|
// Get human-readable error message
|
||||||
|
const message = formatEdgeError(error);
|
||||||
|
|
||||||
|
// Convert to Error instance
|
||||||
|
const err = toError(error);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
### Structured Logging
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { edgeLogger } from '../_shared/logger.ts';
|
||||||
|
|
||||||
|
edgeLogger.info('Processing submission', {
|
||||||
|
requestId,
|
||||||
|
submissionId,
|
||||||
|
itemCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
edgeLogger.warn('Slow query detected', {
|
||||||
|
requestId,
|
||||||
|
queryTime: 1500,
|
||||||
|
query: 'fetch_submissions',
|
||||||
|
});
|
||||||
|
|
||||||
|
edgeLogger.error('Failed to process item', {
|
||||||
|
requestId,
|
||||||
|
error: formatEdgeError(error),
|
||||||
|
itemId,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sensitive Data Protection
|
||||||
|
|
||||||
|
The logger automatically redacts sensitive fields:
|
||||||
|
- `password`
|
||||||
|
- `token`
|
||||||
|
- `secret`
|
||||||
|
- `api_key`
|
||||||
|
- `authorization`
|
||||||
|
- `email`
|
||||||
|
- `phone`
|
||||||
|
- `ssn`
|
||||||
|
- `credit_card`
|
||||||
|
- `ip_address`
|
||||||
|
- `session_id`
|
||||||
|
|
||||||
|
## Distributed Tracing
|
||||||
|
|
||||||
|
### Using Spans
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
startSpan,
|
||||||
|
endSpan,
|
||||||
|
addSpanEvent,
|
||||||
|
setSpanAttributes,
|
||||||
|
logSpan,
|
||||||
|
} from '../_shared/logger.ts';
|
||||||
|
|
||||||
|
// Create a child span for a database operation
|
||||||
|
const dbSpan = startSpan(
|
||||||
|
'fetch_submissions',
|
||||||
|
'DATABASE',
|
||||||
|
getSpanContext(parentSpan),
|
||||||
|
{
|
||||||
|
'db.operation': 'select',
|
||||||
|
'db.table': 'content_submissions',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
addSpanEvent(dbSpan, 'query_start');
|
||||||
|
|
||||||
|
const result = await supabase
|
||||||
|
.from('content_submissions')
|
||||||
|
.select('*');
|
||||||
|
|
||||||
|
addSpanEvent(dbSpan, 'query_complete', {
|
||||||
|
rowCount: result.data?.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
endSpan(dbSpan, 'ok');
|
||||||
|
} catch (error) {
|
||||||
|
addSpanEvent(dbSpan, 'query_failed', {
|
||||||
|
error: formatEdgeError(error),
|
||||||
|
});
|
||||||
|
endSpan(dbSpan, 'error', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
logSpan(dbSpan);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### Before (Manual Error Handling)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return new Response(null, { status: 204, headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.get('Authorization');
|
||||||
|
if (!authHeader) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'Unauthorized' }),
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
// Business logic...
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: true }),
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: String(error) }),
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (Using Wrapper)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createEdgeFunction } from '../_shared/edgeFunctionWrapper.ts';
|
||||||
|
import { validateString } from '../_shared/typeValidation.ts';
|
||||||
|
|
||||||
|
serve(createEdgeFunction({
|
||||||
|
name: 'my-function',
|
||||||
|
requireAuth: true,
|
||||||
|
corsHeaders,
|
||||||
|
}, async (req, { requestId, userId }) => {
|
||||||
|
const body = await req.json();
|
||||||
|
validateString(body.name, 'name', { requestId });
|
||||||
|
|
||||||
|
// Business logic...
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: true }),
|
||||||
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Consistent Error Handling**: All errors are formatted consistently
|
||||||
|
2. **Better Debugging**: Request IDs and trace IDs link errors across services
|
||||||
|
3. **Type Safety**: Catch type mismatches early with clear error messages
|
||||||
|
4. **Security**: Automatic redaction of sensitive data in logs
|
||||||
|
5. **Performance Monitoring**: Built-in timing and span tracking
|
||||||
|
6. **Maintainability**: Less boilerplate code in each function
|
||||||
|
|
||||||
|
## Example: Moderation Approval Function
|
||||||
|
|
||||||
|
See `process-selective-approval/index.ts` for a complete example of using these utilities in a production edge function.
|
||||||
|
|
||||||
|
Key features demonstrated:
|
||||||
|
- Type validation for submission items
|
||||||
|
- Entity type checking
|
||||||
|
- Database error handling
|
||||||
|
- Distributed tracing across RPC calls
|
||||||
|
- Performance monitoring
|
||||||
341
supabase/functions/_shared/edgeFunctionWrapper.ts
Normal file
341
supabase/functions/_shared/edgeFunctionWrapper.ts
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
/**
|
||||||
|
* Edge Function Wrapper with Comprehensive Error Handling
|
||||||
|
*
|
||||||
|
* Provides standardized:
|
||||||
|
* - Request/response logging
|
||||||
|
* - Error handling and formatting
|
||||||
|
* - Distributed tracing
|
||||||
|
* - Type validation
|
||||||
|
* - Performance monitoring
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
edgeLogger,
|
||||||
|
startSpan,
|
||||||
|
endSpan,
|
||||||
|
addSpanEvent,
|
||||||
|
logSpan,
|
||||||
|
extractSpanContextFromHeaders,
|
||||||
|
type Span
|
||||||
|
} from './logger.ts';
|
||||||
|
import { formatEdgeError, toError } from './errorFormatter.ts';
|
||||||
|
import { ValidationError, logValidationError } from './typeValidation.ts';
|
||||||
|
|
||||||
|
export interface EdgeFunctionConfig {
|
||||||
|
name: string;
|
||||||
|
requireAuth?: boolean;
|
||||||
|
corsHeaders?: HeadersInit;
|
||||||
|
logRequests?: boolean;
|
||||||
|
logResponses?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EdgeFunctionContext {
|
||||||
|
requestId: string;
|
||||||
|
span: Span;
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EdgeFunctionHandler = (
|
||||||
|
req: Request,
|
||||||
|
context: EdgeFunctionContext
|
||||||
|
) => Promise<Response>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap an edge function with comprehensive error handling
|
||||||
|
*/
|
||||||
|
export function wrapEdgeFunction(
|
||||||
|
config: EdgeFunctionConfig,
|
||||||
|
handler: EdgeFunctionHandler
|
||||||
|
): (req: Request) => Promise<Response> {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
requireAuth = true,
|
||||||
|
corsHeaders = {},
|
||||||
|
logRequests = true,
|
||||||
|
logResponses = true,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
return async (req: Request): Promise<Response> => {
|
||||||
|
// ========================================================================
|
||||||
|
// STEP 1: Handle CORS preflight
|
||||||
|
// ========================================================================
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return new Response(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: corsHeaders
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// STEP 2: Initialize tracing
|
||||||
|
// ========================================================================
|
||||||
|
const parentSpanContext = extractSpanContextFromHeaders(req.headers);
|
||||||
|
const span = startSpan(
|
||||||
|
name,
|
||||||
|
'SERVER',
|
||||||
|
parentSpanContext,
|
||||||
|
{
|
||||||
|
'http.method': req.method,
|
||||||
|
'http.url': req.url,
|
||||||
|
'function.name': name,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const requestId = span.spanId;
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// STEP 3: Log incoming request
|
||||||
|
// ========================================================================
|
||||||
|
if (logRequests) {
|
||||||
|
edgeLogger.info('Request received', {
|
||||||
|
requestId,
|
||||||
|
action: name,
|
||||||
|
method: req.method,
|
||||||
|
url: req.url,
|
||||||
|
hasAuth: req.headers.has('Authorization'),
|
||||||
|
contentType: req.headers.get('Content-Type'),
|
||||||
|
userAgent: req.headers.get('User-Agent'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ====================================================================
|
||||||
|
// STEP 4: Authentication (if required)
|
||||||
|
// ====================================================================
|
||||||
|
let userId: string | undefined;
|
||||||
|
|
||||||
|
if (requireAuth) {
|
||||||
|
addSpanEvent(span, 'authentication_start');
|
||||||
|
const authHeader = req.headers.get('Authorization');
|
||||||
|
|
||||||
|
if (!authHeader) {
|
||||||
|
addSpanEvent(span, 'authentication_failed', { reason: 'missing_header' });
|
||||||
|
endSpan(span, 'error');
|
||||||
|
logSpan(span);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Missing Authorization header',
|
||||||
|
requestId
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract user ID from JWT (simplified - extend as needed)
|
||||||
|
try {
|
||||||
|
// Note: In production, validate the JWT properly
|
||||||
|
const token = authHeader.replace('Bearer ', '');
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
userId = payload.sub;
|
||||||
|
|
||||||
|
addSpanEvent(span, 'authentication_success', { userId });
|
||||||
|
span.attributes['user.id'] = userId;
|
||||||
|
} catch (error) {
|
||||||
|
addSpanEvent(span, 'authentication_failed', {
|
||||||
|
reason: 'invalid_token',
|
||||||
|
error: formatEdgeError(error)
|
||||||
|
});
|
||||||
|
endSpan(span, 'error', error);
|
||||||
|
logSpan(span);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Invalid authentication token',
|
||||||
|
requestId
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// STEP 5: Execute handler
|
||||||
|
// ====================================================================
|
||||||
|
addSpanEvent(span, 'handler_start');
|
||||||
|
|
||||||
|
const context: EdgeFunctionContext = {
|
||||||
|
requestId,
|
||||||
|
span,
|
||||||
|
userId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await handler(req, context);
|
||||||
|
|
||||||
|
// ====================================================================
|
||||||
|
// STEP 6: Log success response
|
||||||
|
// ====================================================================
|
||||||
|
addSpanEvent(span, 'handler_complete', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText
|
||||||
|
});
|
||||||
|
|
||||||
|
if (logResponses) {
|
||||||
|
edgeLogger.info('Request completed', {
|
||||||
|
requestId,
|
||||||
|
action: name,
|
||||||
|
status: response.status,
|
||||||
|
duration: span.endTime ? span.duration : Date.now() - span.startTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
endSpan(span, 'ok');
|
||||||
|
logSpan(span);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
// ====================================================================
|
||||||
|
// STEP 7: Handle errors
|
||||||
|
// ====================================================================
|
||||||
|
|
||||||
|
// Validation errors (client error)
|
||||||
|
if (error instanceof ValidationError) {
|
||||||
|
addSpanEvent(span, 'validation_error', {
|
||||||
|
field: error.field,
|
||||||
|
expected: error.expected,
|
||||||
|
received: error.received,
|
||||||
|
});
|
||||||
|
|
||||||
|
logValidationError(error, requestId, name);
|
||||||
|
endSpan(span, 'error', error);
|
||||||
|
logSpan(span);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: error.message,
|
||||||
|
field: error.field,
|
||||||
|
expected: error.expected,
|
||||||
|
received: error.received,
|
||||||
|
requestId,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database errors (check for specific codes)
|
||||||
|
const errorObj = error as any;
|
||||||
|
if (errorObj?.code) {
|
||||||
|
addSpanEvent(span, 'database_error', {
|
||||||
|
code: errorObj.code,
|
||||||
|
message: errorObj.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle specific error codes
|
||||||
|
let status = 500;
|
||||||
|
let message = formatEdgeError(error);
|
||||||
|
|
||||||
|
if (errorObj.code === '23505') {
|
||||||
|
// Unique constraint violation
|
||||||
|
status = 409;
|
||||||
|
message = 'A record with this information already exists';
|
||||||
|
} else if (errorObj.code === '23503') {
|
||||||
|
// Foreign key violation
|
||||||
|
status = 400;
|
||||||
|
message = 'Referenced record does not exist';
|
||||||
|
} else if (errorObj.code === '23514') {
|
||||||
|
// Check constraint violation
|
||||||
|
status = 400;
|
||||||
|
message = 'Data violates database constraints';
|
||||||
|
} else if (errorObj.code === 'P0001') {
|
||||||
|
// Raised exception
|
||||||
|
status = 400;
|
||||||
|
message = errorObj.message || 'Database validation failed';
|
||||||
|
} else if (errorObj.code === '42501') {
|
||||||
|
// Insufficient privilege
|
||||||
|
status = 403;
|
||||||
|
message = 'Permission denied';
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeLogger.error('Database error', {
|
||||||
|
requestId,
|
||||||
|
action: name,
|
||||||
|
errorCode: errorObj.code,
|
||||||
|
errorMessage: errorObj.message,
|
||||||
|
errorDetails: errorObj.details,
|
||||||
|
errorHint: errorObj.hint,
|
||||||
|
});
|
||||||
|
|
||||||
|
endSpan(span, 'error', error);
|
||||||
|
logSpan(span);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: message,
|
||||||
|
code: errorObj.code,
|
||||||
|
details: errorObj.details,
|
||||||
|
requestId,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic errors
|
||||||
|
const errorMessage = formatEdgeError(error);
|
||||||
|
|
||||||
|
addSpanEvent(span, 'unhandled_error', {
|
||||||
|
error: errorMessage,
|
||||||
|
errorType: error instanceof Error ? error.name : typeof error,
|
||||||
|
});
|
||||||
|
|
||||||
|
edgeLogger.error('Unhandled error', {
|
||||||
|
requestId,
|
||||||
|
action: name,
|
||||||
|
error: errorMessage,
|
||||||
|
errorType: error instanceof Error ? error.name : typeof error,
|
||||||
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
endSpan(span, 'error', error);
|
||||||
|
logSpan(span);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: errorMessage,
|
||||||
|
requestId,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a simple edge function with standard error handling
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
* ```ts
|
||||||
|
* serve(createEdgeFunction({
|
||||||
|
* name: 'my-function',
|
||||||
|
* requireAuth: true,
|
||||||
|
* corsHeaders: myCorsHeaders,
|
||||||
|
* }, async (req, { requestId, span, userId }) => {
|
||||||
|
* // Your handler logic here
|
||||||
|
* return new Response(JSON.stringify({ success: true }), {
|
||||||
|
* status: 200,
|
||||||
|
* headers: { 'Content-Type': 'application/json' }
|
||||||
|
* });
|
||||||
|
* }));
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createEdgeFunction(
|
||||||
|
config: EdgeFunctionConfig,
|
||||||
|
handler: EdgeFunctionHandler
|
||||||
|
): (req: Request) => Promise<Response> {
|
||||||
|
return wrapEdgeFunction(config, handler);
|
||||||
|
}
|
||||||
196
supabase/functions/_shared/submissionValidation.ts
Normal file
196
supabase/functions/_shared/submissionValidation.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* Submission-Specific Validation Utilities
|
||||||
|
*
|
||||||
|
* Validates submission and moderation request structures
|
||||||
|
* Ensures type safety across the submission pipeline
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
validateUUID,
|
||||||
|
validateUUIDArray,
|
||||||
|
validateEntityType,
|
||||||
|
validateActionType,
|
||||||
|
validateObject,
|
||||||
|
validateString,
|
||||||
|
validateArray,
|
||||||
|
type ValidEntityType,
|
||||||
|
type ValidActionType,
|
||||||
|
} from './typeValidation.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validated approval request structure
|
||||||
|
*/
|
||||||
|
export interface ValidatedApprovalRequest {
|
||||||
|
submissionId: string;
|
||||||
|
itemIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validated rejection request structure
|
||||||
|
*/
|
||||||
|
export interface ValidatedRejectionRequest {
|
||||||
|
submissionId: string;
|
||||||
|
itemIds: string[];
|
||||||
|
rejectionReason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validated submission item
|
||||||
|
*/
|
||||||
|
export interface ValidatedSubmissionItem {
|
||||||
|
id: string;
|
||||||
|
item_type: ValidEntityType;
|
||||||
|
action_type: ValidActionType;
|
||||||
|
entity_id?: string | null;
|
||||||
|
item_data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate approval request body
|
||||||
|
*/
|
||||||
|
export function validateApprovalRequest(
|
||||||
|
body: unknown,
|
||||||
|
requestId?: string
|
||||||
|
): ValidatedApprovalRequest {
|
||||||
|
validateObject(body, 'request_body', { requestId });
|
||||||
|
const obj = body as Record<string, unknown>;
|
||||||
|
|
||||||
|
validateUUID(obj.submissionId, 'submissionId', { requestId });
|
||||||
|
validateUUIDArray(obj.itemIds, 'itemIds', 1, { requestId });
|
||||||
|
|
||||||
|
return {
|
||||||
|
submissionId: obj.submissionId as string,
|
||||||
|
itemIds: obj.itemIds as string[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate rejection request body
|
||||||
|
*/
|
||||||
|
export function validateRejectionRequest(
|
||||||
|
body: unknown,
|
||||||
|
requestId?: string
|
||||||
|
): ValidatedRejectionRequest {
|
||||||
|
validateObject(body, 'request_body', { requestId });
|
||||||
|
const obj = body as Record<string, unknown>;
|
||||||
|
|
||||||
|
validateUUID(obj.submissionId, 'submissionId', { requestId });
|
||||||
|
validateUUIDArray(obj.itemIds, 'itemIds', 1, { requestId });
|
||||||
|
validateString(obj.rejectionReason, 'rejectionReason', { requestId });
|
||||||
|
|
||||||
|
return {
|
||||||
|
submissionId: obj.submissionId as string,
|
||||||
|
itemIds: obj.itemIds as string[],
|
||||||
|
rejectionReason: obj.rejectionReason as string,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate submission item from database
|
||||||
|
*/
|
||||||
|
export function validateSubmissionItemFromDB(
|
||||||
|
item: unknown,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): ValidatedSubmissionItem {
|
||||||
|
validateObject(item, 'submission_item', context);
|
||||||
|
const obj = item as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
validateUUID(obj.id, 'submission_item.id', context);
|
||||||
|
validateEntityType(obj.item_type, 'submission_item.item_type', {
|
||||||
|
...context,
|
||||||
|
itemId: obj.id,
|
||||||
|
});
|
||||||
|
validateActionType(obj.action_type, 'submission_item.action_type', {
|
||||||
|
...context,
|
||||||
|
itemId: obj.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: obj.id as string,
|
||||||
|
item_type: obj.item_type as ValidEntityType,
|
||||||
|
action_type: obj.action_type as ValidActionType,
|
||||||
|
entity_id: obj.entity_id as string | null | undefined,
|
||||||
|
item_data: obj.item_data as Record<string, unknown> | undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate array of submission items
|
||||||
|
*/
|
||||||
|
export function validateSubmissionItems(
|
||||||
|
items: unknown,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): ValidatedSubmissionItem[] {
|
||||||
|
validateArray(items, 'submission_items', 1, context);
|
||||||
|
|
||||||
|
const itemArray = items as unknown[];
|
||||||
|
return itemArray.map((item, index) =>
|
||||||
|
validateSubmissionItemFromDB(item, {
|
||||||
|
...context,
|
||||||
|
itemIndex: index,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that entity type matches the expected submission table
|
||||||
|
* Helps catch data model mismatches early
|
||||||
|
*/
|
||||||
|
export function validateEntityTypeConsistency(
|
||||||
|
item: ValidatedSubmissionItem,
|
||||||
|
expectedTypes: ValidEntityType[],
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
if (!expectedTypes.includes(item.item_type)) {
|
||||||
|
throw new Error(
|
||||||
|
`Entity type mismatch: expected one of [${expectedTypes.join(', ')}] but got '${item.item_type}' ` +
|
||||||
|
`for item ${item.id}. This may indicate a data model inconsistency. ` +
|
||||||
|
`Context: ${JSON.stringify(context)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map entity type to submission table name
|
||||||
|
* Useful for debugging and error messages
|
||||||
|
*/
|
||||||
|
export function getSubmissionTableName(entityType: ValidEntityType): string {
|
||||||
|
const tableMap: Record<ValidEntityType, string> = {
|
||||||
|
park: 'park_submissions',
|
||||||
|
ride: 'ride_submissions',
|
||||||
|
manufacturer: 'company_submissions',
|
||||||
|
operator: 'company_submissions',
|
||||||
|
property_owner: 'company_submissions',
|
||||||
|
designer: 'company_submissions',
|
||||||
|
company: 'company_submissions',
|
||||||
|
ride_model: 'ride_model_submissions',
|
||||||
|
photo: 'photo_submissions',
|
||||||
|
milestone: 'timeline_event_submissions',
|
||||||
|
timeline_event: 'timeline_event_submissions',
|
||||||
|
};
|
||||||
|
|
||||||
|
return tableMap[entityType] || 'unknown_submissions';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map entity type to main table name
|
||||||
|
* Useful for debugging and error messages
|
||||||
|
*/
|
||||||
|
export function getMainTableName(entityType: ValidEntityType): string {
|
||||||
|
const tableMap: Record<ValidEntityType, string> = {
|
||||||
|
park: 'parks',
|
||||||
|
ride: 'rides',
|
||||||
|
manufacturer: 'companies',
|
||||||
|
operator: 'companies',
|
||||||
|
property_owner: 'companies',
|
||||||
|
designer: 'companies',
|
||||||
|
company: 'companies',
|
||||||
|
ride_model: 'ride_models',
|
||||||
|
photo: 'photos',
|
||||||
|
milestone: 'timeline_events',
|
||||||
|
timeline_event: 'timeline_events',
|
||||||
|
};
|
||||||
|
|
||||||
|
return tableMap[entityType] || 'unknown_table';
|
||||||
|
}
|
||||||
333
supabase/functions/_shared/typeValidation.ts
Normal file
333
supabase/functions/_shared/typeValidation.ts
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
/**
|
||||||
|
* Type Validation Utilities for Edge Functions
|
||||||
|
*
|
||||||
|
* Provides runtime type checking to catch mismatches early
|
||||||
|
* Generates clear error messages for debugging
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { edgeLogger } from './logger.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation error with structured details
|
||||||
|
*/
|
||||||
|
export class ValidationError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly field: string,
|
||||||
|
public readonly expected: string,
|
||||||
|
public readonly received: unknown,
|
||||||
|
public readonly context?: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ValidationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid entity types in the system
|
||||||
|
*/
|
||||||
|
export const VALID_ENTITY_TYPES = [
|
||||||
|
'park',
|
||||||
|
'ride',
|
||||||
|
'manufacturer',
|
||||||
|
'operator',
|
||||||
|
'property_owner',
|
||||||
|
'designer',
|
||||||
|
'company', // Consolidated type
|
||||||
|
'ride_model',
|
||||||
|
'photo',
|
||||||
|
'milestone',
|
||||||
|
'timeline_event',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ValidEntityType = typeof VALID_ENTITY_TYPES[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid action types
|
||||||
|
*/
|
||||||
|
export const VALID_ACTION_TYPES = ['create', 'edit', 'update', 'delete'] as const;
|
||||||
|
export type ValidActionType = typeof VALID_ACTION_TYPES[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guards
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function isString(value: unknown): value is string {
|
||||||
|
return typeof value === 'string';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUUID(value: unknown): value is string {
|
||||||
|
if (!isString(value)) return false;
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
return uuidRegex.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isArray<T>(value: unknown): value is T[] {
|
||||||
|
return Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidEntityType(value: unknown): value is ValidEntityType {
|
||||||
|
return isString(value) && (VALID_ENTITY_TYPES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidActionType(value: unknown): value is ValidActionType {
|
||||||
|
return isString(value) && (VALID_ACTION_TYPES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation functions that throw on error
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function validateRequired(
|
||||||
|
value: unknown,
|
||||||
|
fieldName: string,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): void {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Missing required field: ${fieldName}`,
|
||||||
|
fieldName,
|
||||||
|
'non-empty value',
|
||||||
|
value,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateString(
|
||||||
|
value: unknown,
|
||||||
|
fieldName: string,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): asserts value is string {
|
||||||
|
validateRequired(value, fieldName, context);
|
||||||
|
if (!isString(value)) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Invalid type for ${fieldName}: expected string`,
|
||||||
|
fieldName,
|
||||||
|
'string',
|
||||||
|
typeof value,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateUUID(
|
||||||
|
value: unknown,
|
||||||
|
fieldName: string,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): asserts value is string {
|
||||||
|
validateString(value, fieldName, context);
|
||||||
|
if (!isUUID(value)) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Invalid UUID format for ${fieldName}`,
|
||||||
|
fieldName,
|
||||||
|
'valid UUID',
|
||||||
|
value,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateArray<T>(
|
||||||
|
value: unknown,
|
||||||
|
fieldName: string,
|
||||||
|
minLength = 0,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): asserts value is T[] {
|
||||||
|
validateRequired(value, fieldName, context);
|
||||||
|
if (!isArray<T>(value)) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Invalid type for ${fieldName}: expected array`,
|
||||||
|
fieldName,
|
||||||
|
'array',
|
||||||
|
typeof value,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (value.length < minLength) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`${fieldName} must have at least ${minLength} items`,
|
||||||
|
fieldName,
|
||||||
|
`array with ${minLength}+ items`,
|
||||||
|
`array with ${value.length} items`,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateUUIDArray(
|
||||||
|
value: unknown,
|
||||||
|
fieldName: string,
|
||||||
|
minLength = 0,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): asserts value is string[] {
|
||||||
|
validateArray<string>(value, fieldName, minLength, context);
|
||||||
|
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
if (!isUUID(value[i])) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Invalid UUID at index ${i} in ${fieldName}`,
|
||||||
|
`${fieldName}[${i}]`,
|
||||||
|
'valid UUID',
|
||||||
|
value[i],
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEntityType(
|
||||||
|
value: unknown,
|
||||||
|
fieldName: string,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): asserts value is ValidEntityType {
|
||||||
|
validateString(value, fieldName, context);
|
||||||
|
if (!isValidEntityType(value)) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Invalid entity type: ${value}. Must be one of: ${VALID_ENTITY_TYPES.join(', ')}`,
|
||||||
|
fieldName,
|
||||||
|
`one of: ${VALID_ENTITY_TYPES.join(', ')}`,
|
||||||
|
value,
|
||||||
|
{ ...context, validTypes: VALID_ENTITY_TYPES }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateActionType(
|
||||||
|
value: unknown,
|
||||||
|
fieldName: string,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): asserts value is ValidActionType {
|
||||||
|
validateString(value, fieldName, context);
|
||||||
|
if (!isValidActionType(value)) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Invalid action type: ${value}. Must be one of: ${VALID_ACTION_TYPES.join(', ')}`,
|
||||||
|
fieldName,
|
||||||
|
`one of: ${VALID_ACTION_TYPES.join(', ')}`,
|
||||||
|
value,
|
||||||
|
{ ...context, validActions: VALID_ACTION_TYPES }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateObject(
|
||||||
|
value: unknown,
|
||||||
|
fieldName: string,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): asserts value is Record<string, unknown> {
|
||||||
|
validateRequired(value, fieldName, context);
|
||||||
|
if (!isObject(value)) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`Invalid type for ${fieldName}: expected object`,
|
||||||
|
fieldName,
|
||||||
|
'object',
|
||||||
|
typeof value,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate submission item data structure
|
||||||
|
*/
|
||||||
|
export interface SubmissionItemValidation {
|
||||||
|
id: string;
|
||||||
|
item_type: ValidEntityType;
|
||||||
|
action_type: ValidActionType;
|
||||||
|
item_data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSubmissionItem(
|
||||||
|
item: unknown,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): SubmissionItemValidation {
|
||||||
|
validateObject(item, 'submission_item', context);
|
||||||
|
|
||||||
|
const obj = item as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Validate ID
|
||||||
|
validateUUID(obj.id, 'submission_item.id', { ...context, item });
|
||||||
|
|
||||||
|
// Validate item_type
|
||||||
|
validateEntityType(obj.item_type, 'submission_item.item_type', {
|
||||||
|
...context,
|
||||||
|
item,
|
||||||
|
itemId: obj.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate action_type
|
||||||
|
validateActionType(obj.action_type, 'submission_item.action_type', {
|
||||||
|
...context,
|
||||||
|
item,
|
||||||
|
itemId: obj.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: obj.id,
|
||||||
|
item_type: obj.item_type,
|
||||||
|
action_type: obj.action_type,
|
||||||
|
item_data: isObject(obj.item_data) ? obj.item_data : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log validation error for monitoring
|
||||||
|
*/
|
||||||
|
export function logValidationError(
|
||||||
|
error: ValidationError,
|
||||||
|
requestId?: string,
|
||||||
|
action?: string
|
||||||
|
): void {
|
||||||
|
edgeLogger.error('Validation error', {
|
||||||
|
requestId,
|
||||||
|
action,
|
||||||
|
errorType: 'ValidationError',
|
||||||
|
field: error.field,
|
||||||
|
expected: error.expected,
|
||||||
|
received: error.received,
|
||||||
|
message: error.message,
|
||||||
|
context: error.context,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate request body structure
|
||||||
|
*/
|
||||||
|
export async function parseAndValidateJSON<T = unknown>(
|
||||||
|
req: Request,
|
||||||
|
schema: (data: unknown) => T,
|
||||||
|
context?: Record<string, unknown>
|
||||||
|
): Promise<T> {
|
||||||
|
let body: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch (error) {
|
||||||
|
throw new ValidationError(
|
||||||
|
'Invalid JSON in request body',
|
||||||
|
'request.body',
|
||||||
|
'valid JSON',
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return schema(body);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ValidationError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new ValidationError(
|
||||||
|
'Request body validation failed',
|
||||||
|
'request.body',
|
||||||
|
'valid request structure',
|
||||||
|
body,
|
||||||
|
{ ...context, originalError: error }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,14 @@ import {
|
|||||||
type Span
|
type Span
|
||||||
} from '../_shared/logger.ts';
|
} from '../_shared/logger.ts';
|
||||||
import { formatEdgeError, toError } from '../_shared/errorFormatter.ts';
|
import { formatEdgeError, toError } from '../_shared/errorFormatter.ts';
|
||||||
|
import {
|
||||||
|
validateApprovalRequest,
|
||||||
|
validateSubmissionItems,
|
||||||
|
getSubmissionTableName,
|
||||||
|
getMainTableName,
|
||||||
|
type ValidatedSubmissionItem
|
||||||
|
} from '../_shared/submissionValidation.ts';
|
||||||
|
import { ValidationError } from '../_shared/typeValidation.ts';
|
||||||
|
|
||||||
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') || 'https://api.thrillwiki.com';
|
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') || 'https://api.thrillwiki.com';
|
||||||
const SUPABASE_ANON_KEY = Deno.env.get('SUPABASE_ANON_KEY');
|
const SUPABASE_ANON_KEY = Deno.env.get('SUPABASE_ANON_KEY');
|
||||||
@@ -140,10 +148,48 @@ const handler = async (req: Request) => {
|
|||||||
action: 'process_approval'
|
action: 'process_approval'
|
||||||
});
|
});
|
||||||
|
|
||||||
// STEP 2: Parse request
|
// STEP 2: Parse and validate request
|
||||||
addSpanEvent(rootSpan, 'validation_start');
|
addSpanEvent(rootSpan, 'validation_start');
|
||||||
const body: ApprovalRequest = await req.json();
|
|
||||||
const { submissionId, itemIds } = body;
|
let submissionId: string;
|
||||||
|
let itemIds: string[];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const validated = validateApprovalRequest(body, requestId);
|
||||||
|
submissionId = validated.submissionId;
|
||||||
|
itemIds = validated.itemIds;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ValidationError) {
|
||||||
|
addSpanEvent(rootSpan, 'validation_failed', {
|
||||||
|
field: error.field,
|
||||||
|
expected: error.expected,
|
||||||
|
received: error.received,
|
||||||
|
});
|
||||||
|
edgeLogger.warn('Request validation failed', {
|
||||||
|
requestId,
|
||||||
|
field: error.field,
|
||||||
|
expected: error.expected,
|
||||||
|
received: error.received,
|
||||||
|
action: 'process_approval'
|
||||||
|
});
|
||||||
|
endSpan(rootSpan, 'error', error);
|
||||||
|
logSpan(rootSpan);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: error.message,
|
||||||
|
field: error.field,
|
||||||
|
requestId
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const idempotencyKey = req.headers.get('x-idempotency-key');
|
const idempotencyKey = req.headers.get('x-idempotency-key');
|
||||||
|
|
||||||
if (!idempotencyKey) {
|
if (!idempotencyKey) {
|
||||||
@@ -160,33 +206,6 @@ const handler = async (req: Request) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!submissionId || !itemIds || itemIds.length === 0) {
|
|
||||||
addSpanEvent(rootSpan, 'validation_failed', {
|
|
||||||
hasSubmissionId: !!submissionId,
|
|
||||||
hasItemIds: !!itemIds,
|
|
||||||
itemCount: itemIds?.length || 0,
|
|
||||||
});
|
|
||||||
edgeLogger.warn('Invalid request payload', {
|
|
||||||
requestId,
|
|
||||||
hasSubmissionId: !!submissionId,
|
|
||||||
hasItemIds: !!itemIds,
|
|
||||||
itemCount: itemIds?.length || 0,
|
|
||||||
action: 'process_approval'
|
|
||||||
});
|
|
||||||
endSpan(rootSpan, 'error');
|
|
||||||
logSpan(rootSpan);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Missing required fields: submissionId, itemIds' }),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSpanAttributes(rootSpan, {
|
setSpanAttributes(rootSpan, {
|
||||||
'submission.id': submissionId,
|
'submission.id': submissionId,
|
||||||
'submission.item_count': itemIds.length,
|
'submission.item_count': itemIds.length,
|
||||||
@@ -422,6 +441,25 @@ const handler = async (req: Request) => {
|
|||||||
error: rpcError.message,
|
error: rpcError.message,
|
||||||
errorCode: rpcError.code
|
errorCode: rpcError.code
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Enhanced error logging for type mismatches
|
||||||
|
if (rpcError.code === 'P0001' && rpcError.message?.includes('Unknown item type')) {
|
||||||
|
// Extract the unknown type from error message
|
||||||
|
const typeMatch = rpcError.message.match(/Unknown item type: (\w+)/);
|
||||||
|
const unknownType = typeMatch ? typeMatch[1] : 'unknown';
|
||||||
|
|
||||||
|
edgeLogger.error('Entity type mismatch detected', {
|
||||||
|
requestId,
|
||||||
|
submissionId,
|
||||||
|
unknownType,
|
||||||
|
error: rpcError.message,
|
||||||
|
hint: `The submission contains an item with type '${unknownType}' which is not recognized by process_approval_transaction. ` +
|
||||||
|
`Valid types are: park, ride, manufacturer, operator, property_owner, designer, company, ride_model, photo. ` +
|
||||||
|
`This indicates a data model inconsistency between submission_items and the RPC function.`,
|
||||||
|
action: 'process_approval'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import {
|
|||||||
type Span
|
type Span
|
||||||
} from '../_shared/logger.ts';
|
} from '../_shared/logger.ts';
|
||||||
import { formatEdgeError, toError } from '../_shared/errorFormatter.ts';
|
import { formatEdgeError, toError } from '../_shared/errorFormatter.ts';
|
||||||
|
import {
|
||||||
|
validateRejectionRequest,
|
||||||
|
type ValidatedRejectionRequest
|
||||||
|
} from '../_shared/submissionValidation.ts';
|
||||||
|
import { ValidationError } from '../_shared/typeValidation.ts';
|
||||||
|
|
||||||
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') || 'https://api.thrillwiki.com';
|
const SUPABASE_URL = Deno.env.get('SUPABASE_URL') || 'https://api.thrillwiki.com';
|
||||||
const SUPABASE_ANON_KEY = Deno.env.get('SUPABASE_ANON_KEY')!;
|
const SUPABASE_ANON_KEY = Deno.env.get('SUPABASE_ANON_KEY')!;
|
||||||
@@ -103,10 +108,50 @@ const handler = async (req: Request) => {
|
|||||||
action: 'process_rejection'
|
action: 'process_rejection'
|
||||||
});
|
});
|
||||||
|
|
||||||
// STEP 2: Parse request
|
// STEP 2: Parse and validate request
|
||||||
addSpanEvent(rootSpan, 'validation_start');
|
addSpanEvent(rootSpan, 'validation_start');
|
||||||
const body: RejectionRequest = await req.json();
|
|
||||||
const { submissionId, itemIds, rejectionReason } = body;
|
let submissionId: string;
|
||||||
|
let itemIds: string[];
|
||||||
|
let rejectionReason: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const validated = validateRejectionRequest(body, requestId);
|
||||||
|
submissionId = validated.submissionId;
|
||||||
|
itemIds = validated.itemIds;
|
||||||
|
rejectionReason = validated.rejectionReason;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ValidationError) {
|
||||||
|
addSpanEvent(rootSpan, 'validation_failed', {
|
||||||
|
field: error.field,
|
||||||
|
expected: error.expected,
|
||||||
|
received: error.received,
|
||||||
|
});
|
||||||
|
edgeLogger.warn('Request validation failed', {
|
||||||
|
requestId,
|
||||||
|
field: error.field,
|
||||||
|
expected: error.expected,
|
||||||
|
received: error.received,
|
||||||
|
action: 'process_rejection'
|
||||||
|
});
|
||||||
|
endSpan(rootSpan, 'error', error);
|
||||||
|
logSpan(rootSpan);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: error.message,
|
||||||
|
field: error.field,
|
||||||
|
requestId
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const idempotencyKey = req.headers.get('x-idempotency-key');
|
const idempotencyKey = req.headers.get('x-idempotency-key');
|
||||||
|
|
||||||
if (!idempotencyKey) {
|
if (!idempotencyKey) {
|
||||||
@@ -123,35 +168,6 @@ const handler = async (req: Request) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!submissionId || !itemIds || itemIds.length === 0 || !rejectionReason) {
|
|
||||||
addSpanEvent(rootSpan, 'validation_failed', {
|
|
||||||
hasSubmissionId: !!submissionId,
|
|
||||||
hasItemIds: !!itemIds,
|
|
||||||
itemCount: itemIds?.length || 0,
|
|
||||||
hasReason: !!rejectionReason,
|
|
||||||
});
|
|
||||||
edgeLogger.warn('Invalid request payload', {
|
|
||||||
requestId,
|
|
||||||
hasSubmissionId: !!submissionId,
|
|
||||||
hasItemIds: !!itemIds,
|
|
||||||
itemCount: itemIds?.length || 0,
|
|
||||||
hasReason: !!rejectionReason,
|
|
||||||
action: 'process_rejection'
|
|
||||||
});
|
|
||||||
endSpan(rootSpan, 'error');
|
|
||||||
logSpan(rootSpan);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Missing required fields: submissionId, itemIds, rejectionReason' }),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: {
|
|
||||||
...corsHeaders,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSpanAttributes(rootSpan, {
|
setSpanAttributes(rootSpan, {
|
||||||
'submission.id': submissionId,
|
'submission.id': submissionId,
|
||||||
'submission.item_count': itemIds.length,
|
'submission.item_count': itemIds.length,
|
||||||
|
|||||||
Reference in New Issue
Block a user