mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 12:31:26 -05:00
Refactor: Update Supabase client
This commit is contained in:
201
src/lib/moderationStateMachine.ts
Normal file
201
src/lib/moderationStateMachine.ts
Normal 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
66
src/lib/requestContext.ts
Normal 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
141
src/lib/requestTracking.ts
Normal 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();
|
||||||
|
}
|
||||||
255
src/lib/submissionStateMachine.ts
Normal file
255
src/lib/submissionStateMachine.ts
Normal 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
164
src/types/statuses.ts
Normal 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'
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user