Refactor: Update Supabase client

This commit is contained in:
gpt-engineer-app[bot]
2025-10-21 12:05:04 +00:00
parent d886343398
commit 0c0d79754b
6 changed files with 849 additions and 0 deletions

View File

@@ -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<ModerationState, { status: 'claiming' }>).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<ModerationState, { status: 'locked' | 'reviewing' | 'loading_data' }>).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<ModerationState, { status: 'locked' }>).itemId,
lockExpires: (state as Extract<ModerationState, { status: 'locked' }>).lockExpires
};
case 'DATA_LOADED':
if (state.status !== 'loading_data') {
throw new Error(`Illegal transition: ${state.status} → reviewing`);
}
return {
status: 'reviewing',
itemId: (state as Extract<ModerationState, { status: 'loading_data' }>).itemId,
lockExpires: (state as Extract<ModerationState, { status: 'loading_data' }>).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<ModerationState, { status: 'reviewing' }>).itemId
};
case 'START_REJECTION':
if (state.status !== 'reviewing') {
throw new Error(`Illegal transition: ${state.status} → rejecting`);
}
return {
status: 'rejecting',
itemId: (state as Extract<ModerationState, { status: 'reviewing' }>).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<ModerationState, { status: 'approving' | 'rejecting' }>).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<ModerationState, { itemId: string }>).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<ModerationState, { lockExpires: string }>).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<ModerationState, { lockExpires: string }>).lockExpires);
const now = new Date();
const timeUntilExpiry = lockExpires.getTime() - now.getTime();
// Renew if less than 2 minutes remaining
return timeUntilExpiry < 120000;
}

66
src/lib/requestContext.ts Normal file
View File

@@ -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<string, RequestContext>();
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();

141
src/lib/requestTracking.ts Normal file
View File

@@ -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<T>(
options: RequestTrackingOptions,
fn: (context: RequestContext) => Promise<T>
): 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<void> {
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();
}

View File

@@ -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<EntityFormData> }
| { 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<EntityFormData> }
| { 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<SubmissionState, { status: 'validating' }>).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<SubmissionState, { status: 'validating' }>).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<SubmissionState, { status: 'submitting' }>).submissionId
};
case 'LOCK':
if (state.status !== 'pending_moderation') {
throw new Error(`Illegal transition: ${state.status} → locked`);
}
return {
status: 'locked',
submissionId: (state as Extract<SubmissionState, { status: 'pending_moderation' }>).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<SubmissionState, { status: 'locked' }>).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<SubmissionState, { status: 'reviewing' }>).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<SubmissionState, { status: 'reviewing' }>).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<SubmissionState, { status: 'reviewing' }>).submissionId
: (state as Extract<SubmissionState, { status: 'locked' }>).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 };
}
}

164
src/types/statuses.ts Normal file
View File

@@ -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<SubmissionStatus, string> = {
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<ReviewStatus, string> = {
pending: 'Pending',
approved: 'Approved',
rejected: 'Rejected',
flagged: 'Flagged',
skipped: 'Skipped'
};
export const PARK_STATUS_LABELS: Record<ParkStatus, string> = {
operating: 'Operating',
closed_permanently: 'Closed Permanently',
closed_temporarily: 'Closed Temporarily',
under_construction: 'Under Construction',
planned: 'Planned',
abandoned: 'Abandoned'
};
export const RIDE_STATUS_LABELS: Record<RideStatus, string> = {
operating: 'Operating',
closed_permanently: 'Closed Permanently',
closed_temporarily: 'Closed Temporarily',
under_construction: 'Under Construction',
relocated: 'Relocated',
stored: 'Stored',
demolished: 'Demolished'
};

View File

@@ -6,11 +6,33 @@
type LogLevel = 'info' | 'warn' | 'error' | 'debug'; type LogLevel = 'info' | 'warn' | 'error' | 'debug';
interface LogContext { interface LogContext {
requestId?: string; // Correlation ID for tracing
userId?: string; userId?: string;
action?: string; action?: string;
duration?: number; // Request duration in ms
traceId?: string; // Distributed tracing ID
[key: string]: unknown; [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 // Fields that should never be logged
const SENSITIVE_FIELDS = [ const SENSITIVE_FIELDS = [
'password', 'password',