From 0c0d79754b108fe7a2620d9f5e02d2571f143509 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:05:04 +0000 Subject: [PATCH] Refactor: Update Supabase client --- src/lib/moderationStateMachine.ts | 201 +++++++++++++++++++++ src/lib/requestContext.ts | 66 +++++++ src/lib/requestTracking.ts | 141 +++++++++++++++ src/lib/submissionStateMachine.ts | 255 +++++++++++++++++++++++++++ src/types/statuses.ts | 164 +++++++++++++++++ supabase/functions/_shared/logger.ts | 22 +++ 6 files changed, 849 insertions(+) create mode 100644 src/lib/moderationStateMachine.ts create mode 100644 src/lib/requestContext.ts create mode 100644 src/lib/requestTracking.ts create mode 100644 src/lib/submissionStateMachine.ts create mode 100644 src/types/statuses.ts diff --git a/src/lib/moderationStateMachine.ts b/src/lib/moderationStateMachine.ts new file mode 100644 index 00000000..fe8ff047 --- /dev/null +++ b/src/lib/moderationStateMachine.ts @@ -0,0 +1,201 @@ +/** + * Moderation State Machine + * Manages moderation workflow with type-safe state transitions and lock coordination + */ + +import type { SubmissionItemData } from '@/types/photo-submissions'; + +// State definitions using discriminated unions +export type ModerationState = + | { status: 'idle' } + | { status: 'claiming'; itemId: string } + | { status: 'locked'; itemId: string; lockExpires: string } + | { status: 'loading_data'; itemId: string; lockExpires: string } + | { status: 'reviewing'; itemId: string; lockExpires: string; reviewData: SubmissionItemData[] } + | { status: 'approving'; itemId: string } + | { status: 'rejecting'; itemId: string } + | { status: 'complete'; itemId: string; result: 'approved' | 'rejected' } + | { status: 'error'; itemId: string; error: string } + | { status: 'lock_expired'; itemId: string }; + +// Action definitions using discriminated unions +export type ModerationAction = + | { type: 'CLAIM_ITEM'; payload: { itemId: string } } + | { type: 'LOCK_ACQUIRED'; payload: { lockExpires: string } } + | { type: 'LOCK_EXPIRED' } + | { type: 'LOAD_DATA' } + | { type: 'DATA_LOADED'; payload: { reviewData: SubmissionItemData[] } } + | { type: 'START_APPROVAL' } + | { type: 'START_REJECTION' } + | { type: 'COMPLETE'; payload: { result: 'approved' | 'rejected' } } + | { type: 'ERROR'; payload: { error: string } } + | { type: 'RELEASE_LOCK' } + | { type: 'RESET' }; + +/** + * Moderation reducer with strict transition validation + */ +export function moderationReducer( + state: ModerationState, + action: ModerationAction +): ModerationState { + switch (action.type) { + case 'CLAIM_ITEM': + if (state.status !== 'idle' && state.status !== 'complete' && state.status !== 'error') { + throw new Error(`Cannot claim item from state: ${state.status}`); + } + return { status: 'claiming', itemId: action.payload.itemId }; + + case 'LOCK_ACQUIRED': + if (state.status !== 'claiming') { + throw new Error(`Illegal transition: ${state.status} → locked`); + } + return { + status: 'locked', + itemId: (state as Extract).itemId, + lockExpires: action.payload.lockExpires + }; + + case 'LOCK_EXPIRED': + if (state.status !== 'locked' && state.status !== 'reviewing' && state.status !== 'loading_data') { + console.warn(`Lock expired notification in unexpected state: ${state.status}`); + return state; + } + return { + status: 'lock_expired', + itemId: (state as Extract).itemId + }; + + case 'LOAD_DATA': + if (state.status !== 'locked') { + throw new Error(`Illegal transition: ${state.status} → loading_data`); + } + return { + status: 'loading_data', + itemId: (state as Extract).itemId, + lockExpires: (state as Extract).lockExpires + }; + + case 'DATA_LOADED': + if (state.status !== 'loading_data') { + throw new Error(`Illegal transition: ${state.status} → reviewing`); + } + return { + status: 'reviewing', + itemId: (state as Extract).itemId, + lockExpires: (state as Extract).lockExpires, + reviewData: action.payload.reviewData + }; + + case 'START_APPROVAL': + if (state.status !== 'reviewing') { + throw new Error(`Illegal transition: ${state.status} → approving`); + } + return { + status: 'approving', + itemId: (state as Extract).itemId + }; + + case 'START_REJECTION': + if (state.status !== 'reviewing') { + throw new Error(`Illegal transition: ${state.status} → rejecting`); + } + return { + status: 'rejecting', + itemId: (state as Extract).itemId + }; + + case 'COMPLETE': + if (state.status !== 'approving' && state.status !== 'rejecting') { + throw new Error(`Illegal transition: ${state.status} → complete`); + } + return { + status: 'complete', + itemId: (state as Extract).itemId, + result: action.payload.result + }; + + case 'ERROR': + // Error can happen from most states + if (state.status === 'idle' || state.status === 'complete') { + console.warn('Error action in terminal state'); + return state; + } + return { + status: 'error', + itemId: (state as Extract).itemId, + error: action.payload.error + }; + + case 'RELEASE_LOCK': + // Can release lock from locked, reviewing, or error states + if (state.status !== 'locked' && state.status !== 'reviewing' && state.status !== 'error' && state.status !== 'lock_expired' && state.status !== 'loading_data') { + console.warn(`Cannot release lock from state: ${state.status}`); + return state; + } + return { status: 'idle' }; + + case 'RESET': + return { status: 'idle' }; + + default: + // Exhaustive check + const _exhaustive: never = action; + return state; + } +} + +// State transition guards +export function canClaimItem(state: ModerationState): boolean { + return state.status === 'idle' || state.status === 'complete' || state.status === 'error'; +} + +export function canLoadData(state: ModerationState): boolean { + return state.status === 'locked'; +} + +export function canStartReview(state: ModerationState): boolean { + return state.status === 'loading_data'; +} + +export function canApprove(state: ModerationState): boolean { + return state.status === 'reviewing'; +} + +export function canReject(state: ModerationState): boolean { + return state.status === 'reviewing'; +} + +export function canReleaseLock(state: ModerationState): boolean { + return state.status === 'locked' + || state.status === 'reviewing' + || state.status === 'error' + || state.status === 'lock_expired' + || state.status === 'loading_data'; +} + +export function hasActiveLock(state: ModerationState): boolean { + if (state.status !== 'locked' && state.status !== 'reviewing' && state.status !== 'loading_data') { + return false; + } + + const lockExpires = new Date((state as Extract).lockExpires); + return lockExpires > new Date(); +} + +export function isTerminalState(state: ModerationState): boolean { + return state.status === 'complete' || state.status === 'error'; +} + +export function needsLockRenewal(state: ModerationState): boolean { + if (state.status !== 'locked' && state.status !== 'reviewing' && state.status !== 'loading_data') { + return false; + } + + const lockExpires = new Date((state as Extract).lockExpires); + const now = new Date(); + const timeUntilExpiry = lockExpires.getTime() - now.getTime(); + + // Renew if less than 2 minutes remaining + return timeUntilExpiry < 120000; +} diff --git a/src/lib/requestContext.ts b/src/lib/requestContext.ts new file mode 100644 index 00000000..09950889 --- /dev/null +++ b/src/lib/requestContext.ts @@ -0,0 +1,66 @@ +/** + * Request Context Manager + * Provides correlation IDs and metadata for tracking requests across the system + */ + +export interface RequestContext { + requestId: string; + userId?: string; + timestamp: string; + userAgent?: string; + clientVersion?: string; + traceId?: string; // For distributed tracing across multiple requests +} + +export interface RequestMetadata { + endpoint: string; + method: string; + statusCode?: number; + duration?: number; + error?: { + type: string; + message: string; + }; +} + +class RequestContextManager { + private contexts = new Map(); + private readonly MAX_CONTEXTS = 1000; // Prevent memory leaks + + create(userId?: string, traceId?: string): RequestContext { + // Cleanup old contexts if limit reached + if (this.contexts.size >= this.MAX_CONTEXTS) { + const firstKey = this.contexts.keys().next().value; + if (firstKey) this.contexts.delete(firstKey); + } + + const context: RequestContext = { + requestId: crypto.randomUUID(), + userId, + timestamp: new Date().toISOString(), + userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined, + clientVersion: import.meta.env.VITE_APP_VERSION || '1.0.0', + traceId: traceId || crypto.randomUUID(), + }; + + this.contexts.set(context.requestId, context); + return context; + } + + get(requestId: string): RequestContext | undefined { + return this.contexts.get(requestId); + } + + cleanup(requestId: string): void { + this.contexts.delete(requestId); + } + + // Get current context from thread-local storage pattern + getCurrentContext(): RequestContext | undefined { + // This is a simplified version - in production you'd use AsyncLocalStorage equivalent + const keys = Array.from(this.contexts.keys()); + return keys.length > 0 ? this.contexts.get(keys[keys.length - 1]) : undefined; + } +} + +export const requestContext = new RequestContextManager(); diff --git a/src/lib/requestTracking.ts b/src/lib/requestTracking.ts new file mode 100644 index 00000000..a65dd936 --- /dev/null +++ b/src/lib/requestTracking.ts @@ -0,0 +1,141 @@ +/** + * Request Tracking Service + * Tracks API requests with correlation IDs and stores metadata for monitoring + */ + +import { supabase } from '@/integrations/supabase/client'; +import { requestContext, type RequestContext } from './requestContext'; + +export interface RequestTrackingOptions { + endpoint: string; + method: string; + userId?: string; + parentRequestId?: string; + traceId?: string; +} + +export interface RequestResult { + requestId: string; + statusCode: number; + duration: number; + error?: { + type: string; + message: string; + }; +} + +/** + * Track a request and store metadata + * Returns requestId for correlation and support + */ +export async function trackRequest( + options: RequestTrackingOptions, + fn: (context: RequestContext) => Promise +): Promise<{ result: T; requestId: string; duration: number }> { + const context = requestContext.create(options.userId, options.traceId); + const start = Date.now(); + + try { + const result = await fn(context); + const duration = Date.now() - start; + + // Log to database (fire and forget - don't block response) + logRequestMetadata({ + requestId: context.requestId, + userId: options.userId, + endpoint: options.endpoint, + method: options.method, + statusCode: 200, + duration, + userAgent: context.userAgent, + clientVersion: context.clientVersion, + parentRequestId: options.parentRequestId, + traceId: context.traceId, + }).catch(err => { + console.error('[RequestTracking] Failed to log metadata:', err); + }); + + // Cleanup context + requestContext.cleanup(context.requestId); + + return { result, requestId: context.requestId, duration }; + + } catch (error) { + const duration = Date.now() - start; + const errorInfo = error instanceof Error + ? { type: error.name, message: error.message } + : { type: 'UnknownError', message: String(error) }; + + // Log error to database (fire and forget) + logRequestMetadata({ + requestId: context.requestId, + userId: options.userId, + endpoint: options.endpoint, + method: options.method, + statusCode: 500, + duration, + errorType: errorInfo.type, + errorMessage: errorInfo.message, + userAgent: context.userAgent, + clientVersion: context.clientVersion, + parentRequestId: options.parentRequestId, + traceId: context.traceId, + }).catch(err => { + console.error('[RequestTracking] Failed to log error metadata:', err); + }); + + // Cleanup context + requestContext.cleanup(context.requestId); + + throw error; + } +} + +interface RequestMetadata { + requestId: string; + userId?: string; + endpoint: string; + method: string; + statusCode: number; + duration: number; + errorType?: string; + errorMessage?: string; + userAgent?: string; + clientVersion?: string; + parentRequestId?: string; + traceId?: string; +} + +async function logRequestMetadata(metadata: RequestMetadata): Promise { + await supabase.from('request_metadata').insert({ + request_id: metadata.requestId, + user_id: metadata.userId || null, + endpoint: metadata.endpoint, + method: metadata.method, + status_code: metadata.statusCode, + duration_ms: metadata.duration, + error_type: metadata.errorType || null, + error_message: metadata.errorMessage || null, + user_agent: metadata.userAgent || null, + client_version: metadata.clientVersion || null, + parent_request_id: metadata.parentRequestId || null, + trace_id: metadata.traceId || null, + }); +} + +/** + * Simple wrapper for tracking without async operations + */ +export function createRequestContext( + userId?: string, + traceId?: string +): RequestContext { + return requestContext.create(userId, traceId); +} + +/** + * Get request context for current operation + */ +export function getCurrentRequestContext(): RequestContext | undefined { + return requestContext.getCurrentContext(); +} diff --git a/src/lib/submissionStateMachine.ts b/src/lib/submissionStateMachine.ts new file mode 100644 index 00000000..2ae25597 --- /dev/null +++ b/src/lib/submissionStateMachine.ts @@ -0,0 +1,255 @@ +/** + * Submission State Machine + * Manages submission lifecycle with type-safe state transitions + */ + +import type { SubmissionStatus } from '@/types/statuses'; + +// Submission form data (generic) +export interface EntityFormData { + [key: string]: unknown; +} + +export interface ValidationError { + field: string; + message: string; +} + +// State definitions using discriminated unions +export type SubmissionState = + | { status: 'draft'; data: Partial } + | { status: 'validating'; data: EntityFormData } + | { status: 'validation_error'; data: EntityFormData; errors: ValidationError[] } + | { status: 'submitting'; data: EntityFormData; submissionId: string } + | { status: 'pending_moderation'; submissionId: string } + | { status: 'locked'; submissionId: string; lockedBy: string; lockedUntil: string } + | { status: 'reviewing'; submissionId: string; reviewerId: string } + | { status: 'approved'; submissionId: string; entityId: string } + | { status: 'rejected'; submissionId: string; reason: string } + | { status: 'escalated'; submissionId: string; escalatedBy: string; reason: string }; + +// Action definitions using discriminated unions +export type SubmissionAction = + | { type: 'UPDATE_DRAFT'; payload: Partial } + | { type: 'VALIDATE'; payload: EntityFormData } + | { type: 'VALIDATION_ERROR'; payload: ValidationError[] } + | { type: 'SUBMIT'; payload: { submissionId: string } } + | { type: 'SUBMISSION_COMPLETE' } + | { type: 'LOCK'; payload: { lockedBy: string; lockedUntil: string } } + | { type: 'START_REVIEW'; payload: { reviewerId: string } } + | { type: 'APPROVE'; payload: { entityId: string } } + | { type: 'REJECT'; payload: { reason: string } } + | { type: 'ESCALATE'; payload: { escalatedBy: string; reason: string } } + | { type: 'RESET' }; + +/** + * Submission reducer with exhaustive state transition validation + */ +export function submissionReducer( + state: SubmissionState, + action: SubmissionAction +): SubmissionState { + switch (action.type) { + case 'UPDATE_DRAFT': + if (state.status !== 'draft') { + console.warn(`Cannot update draft in state: ${state.status}`); + return state; + } + return { + status: 'draft', + data: { ...state.data, ...action.payload } + }; + + case 'VALIDATE': + if (state.status !== 'draft' && state.status !== 'validation_error') { + throw new Error(`Illegal transition: ${state.status} → validating`); + } + return { + status: 'validating', + data: action.payload + }; + + case 'VALIDATION_ERROR': + if (state.status !== 'validating') { + throw new Error(`Illegal transition: ${state.status} → validation_error`); + } + return { + status: 'validation_error', + data: (state as Extract).data, + errors: action.payload + }; + + case 'SUBMIT': + if (state.status !== 'validating') { + throw new Error(`Illegal transition: ${state.status} → submitting`); + } + return { + status: 'submitting', + data: (state as Extract).data, + submissionId: action.payload.submissionId + }; + + case 'SUBMISSION_COMPLETE': + if (state.status !== 'submitting') { + throw new Error(`Illegal transition: ${state.status} → pending_moderation`); + } + return { + status: 'pending_moderation', + submissionId: (state as Extract).submissionId + }; + + case 'LOCK': + if (state.status !== 'pending_moderation') { + throw new Error(`Illegal transition: ${state.status} → locked`); + } + return { + status: 'locked', + submissionId: (state as Extract).submissionId, + lockedBy: action.payload.lockedBy, + lockedUntil: action.payload.lockedUntil + }; + + case 'START_REVIEW': + if (state.status !== 'locked') { + throw new Error(`Illegal transition: ${state.status} → reviewing`); + } + return { + status: 'reviewing', + submissionId: (state as Extract).submissionId, + reviewerId: action.payload.reviewerId + }; + + case 'APPROVE': + if (state.status !== 'reviewing') { + throw new Error(`Illegal transition: ${state.status} → approved`); + } + return { + status: 'approved', + submissionId: (state as Extract).submissionId, + entityId: action.payload.entityId + }; + + case 'REJECT': + if (state.status !== 'reviewing') { + throw new Error(`Illegal transition: ${state.status} → rejected`); + } + return { + status: 'rejected', + submissionId: (state as Extract).submissionId, + reason: action.payload.reason + }; + + case 'ESCALATE': + if (state.status !== 'reviewing' && state.status !== 'locked') { + throw new Error(`Illegal transition: ${state.status} → escalated`); + } + const submissionId = state.status === 'reviewing' + ? (state as Extract).submissionId + : (state as Extract).submissionId; + + return { + status: 'escalated', + submissionId, + escalatedBy: action.payload.escalatedBy, + reason: action.payload.reason + }; + + case 'RESET': + return { status: 'draft', data: {} }; + + default: + // Exhaustive check + const _exhaustive: never = action; + return state; + } +} + +// State transition guards +export function canUpdateDraft(state: SubmissionState): boolean { + return state.status === 'draft'; +} + +export function canValidate(state: SubmissionState): boolean { + return state.status === 'draft' || state.status === 'validation_error'; +} + +export function canSubmit(state: SubmissionState): boolean { + return state.status === 'validating'; +} + +export function canLock(state: SubmissionState): boolean { + return state.status === 'pending_moderation'; +} + +export function canStartReview(state: SubmissionState): boolean { + return state.status === 'locked'; +} + +export function canApprove(state: SubmissionState): boolean { + return state.status === 'reviewing'; +} + +export function canReject(state: SubmissionState): boolean { + return state.status === 'reviewing'; +} + +export function canEscalate(state: SubmissionState): boolean { + return state.status === 'reviewing' || state.status === 'locked'; +} + +// Helper to convert database status to state machine state +export function stateFromDatabaseStatus( + dbStatus: SubmissionStatus, + submission: { + id: string; + assigned_to?: string | null; + locked_until?: string | null; + rejection_reason?: string | null; + content?: { entity_id?: string }; + } +): SubmissionState { + switch (dbStatus) { + case 'pending': + if (submission.locked_until && new Date(submission.locked_until) > new Date()) { + return { + status: 'locked', + submissionId: submission.id, + lockedBy: submission.assigned_to || 'unknown', + lockedUntil: submission.locked_until + }; + } + return { status: 'pending_moderation', submissionId: submission.id }; + + case 'reviewing': + return { + status: 'reviewing', + submissionId: submission.id, + reviewerId: submission.assigned_to || 'unknown' + }; + + case 'approved': + return { + status: 'approved', + submissionId: submission.id, + entityId: submission.content?.entity_id || 'unknown' + }; + + case 'rejected': + return { + status: 'rejected', + submissionId: submission.id, + reason: submission.rejection_reason || 'No reason provided' + }; + + case 'escalated': + return { + status: 'escalated', + submissionId: submission.id, + escalatedBy: submission.assigned_to || 'unknown', + reason: 'Escalated for review' + }; + + default: + return { status: 'pending_moderation', submissionId: submission.id }; + } +} diff --git a/src/types/statuses.ts b/src/types/statuses.ts new file mode 100644 index 00000000..b8df1ebc --- /dev/null +++ b/src/types/statuses.ts @@ -0,0 +1,164 @@ +/** + * Type-Safe Status Enums + * Provides exhaustive type checking for all entity statuses + */ + +import { z } from 'zod'; + +// Submission statuses +export type SubmissionStatus = + | 'draft' + | 'pending' + | 'locked' + | 'reviewing' + | 'partially_approved' + | 'approved' + | 'rejected' + | 'escalated'; + +export const SubmissionStatusSchema = z.enum([ + 'draft', + 'pending', + 'locked', + 'reviewing', + 'partially_approved', + 'approved', + 'rejected', + 'escalated' +]); + +// Review statuses for individual items +export type ReviewStatus = + | 'pending' + | 'approved' + | 'rejected' + | 'flagged' + | 'skipped'; + +export const ReviewStatusSchema = z.enum([ + 'pending', + 'approved', + 'rejected', + 'flagged', + 'skipped' +]); + +// Park operational statuses +export type ParkStatus = + | 'operating' + | 'closed_permanently' + | 'closed_temporarily' + | 'under_construction' + | 'planned' + | 'abandoned'; + +export const ParkStatusSchema = z.enum([ + 'operating', + 'closed_permanently', + 'closed_temporarily', + 'under_construction', + 'planned', + 'abandoned' +]); + +// Ride operational statuses +export type RideStatus = + | 'operating' + | 'closed_permanently' + | 'closed_temporarily' + | 'under_construction' + | 'relocated' + | 'stored' + | 'demolished'; + +export const RideStatusSchema = z.enum([ + 'operating', + 'closed_permanently', + 'closed_temporarily', + 'under_construction', + 'relocated', + 'stored', + 'demolished' +]); + +// Account statuses +export type AccountStatus = + | 'active' + | 'suspended' + | 'pending_deletion' + | 'deleted'; + +export const AccountStatusSchema = z.enum([ + 'active', + 'suspended', + 'pending_deletion', + 'deleted' +]); + +// Lock statuses +export type LockStatus = + | 'unlocked' + | 'locked' + | 'expired'; + +export const LockStatusSchema = z.enum([ + 'unlocked', + 'locked', + 'expired' +]); + +// Helper type guards +export function isSubmissionStatus(value: unknown): value is SubmissionStatus { + return SubmissionStatusSchema.safeParse(value).success; +} + +export function isReviewStatus(value: unknown): value is ReviewStatus { + return ReviewStatusSchema.safeParse(value).success; +} + +export function isParkStatus(value: unknown): value is ParkStatus { + return ParkStatusSchema.safeParse(value).success; +} + +export function isRideStatus(value: unknown): value is RideStatus { + return RideStatusSchema.safeParse(value).success; +} + +// Status display helpers +export const SUBMISSION_STATUS_LABELS: Record = { + draft: 'Draft', + pending: 'Pending Review', + locked: 'Locked', + reviewing: 'Under Review', + partially_approved: 'Partially Approved', + approved: 'Approved', + rejected: 'Rejected', + escalated: 'Escalated' +}; + +export const REVIEW_STATUS_LABELS: Record = { + pending: 'Pending', + approved: 'Approved', + rejected: 'Rejected', + flagged: 'Flagged', + skipped: 'Skipped' +}; + +export const PARK_STATUS_LABELS: Record = { + operating: 'Operating', + closed_permanently: 'Closed Permanently', + closed_temporarily: 'Closed Temporarily', + under_construction: 'Under Construction', + planned: 'Planned', + abandoned: 'Abandoned' +}; + +export const RIDE_STATUS_LABELS: Record = { + operating: 'Operating', + closed_permanently: 'Closed Permanently', + closed_temporarily: 'Closed Temporarily', + under_construction: 'Under Construction', + relocated: 'Relocated', + stored: 'Stored', + demolished: 'Demolished' +}; diff --git a/supabase/functions/_shared/logger.ts b/supabase/functions/_shared/logger.ts index c4ac03f5..2f6cd072 100644 --- a/supabase/functions/_shared/logger.ts +++ b/supabase/functions/_shared/logger.ts @@ -6,11 +6,33 @@ type LogLevel = 'info' | 'warn' | 'error' | 'debug'; interface LogContext { + requestId?: string; // Correlation ID for tracing userId?: string; action?: string; + duration?: number; // Request duration in ms + traceId?: string; // Distributed tracing ID [key: string]: unknown; } +// Request tracking utilities +export interface RequestTracking { + requestId: string; + start: number; + traceId?: string; +} + +export function startRequest(traceId?: string): RequestTracking { + return { + requestId: crypto.randomUUID(), + start: Date.now(), + traceId: traceId || crypto.randomUUID(), + }; +} + +export function endRequest(tracking: RequestTracking): number { + return Date.now() - tracking.start; +} + // Fields that should never be logged const SENSITIVE_FIELDS = [ 'password',