/** * Moderation State Machine * Manages moderation workflow with type-safe state transitions and lock coordination */ import type { SubmissionItemWithDeps } from './submissionItemsService'; import { logger } from './logger'; // 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: SubmissionItemWithDeps[] } | { 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: SubmissionItemWithDeps[] } } | { 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`); } // Validate lock expiry date const lockDate = new Date(action.payload.lockExpires); if (isNaN(lockDate.getTime())) { throw new Error('Invalid lock expiry date'); } return { status: 'locked', itemId: state.itemId, lockExpires: action.payload.lockExpires }; case 'LOCK_EXPIRED': if (state.status !== 'locked' && state.status !== 'reviewing' && state.status !== 'loading_data') { logger.warn(`Lock expired notification in unexpected state: ${state.status}`); return state; } return { status: 'lock_expired', itemId: state.itemId }; case 'LOAD_DATA': if (state.status !== 'locked') { throw new Error(`Illegal transition: ${state.status} → loading_data`); } return { status: 'loading_data', itemId: state.itemId, lockExpires: state.lockExpires }; case 'DATA_LOADED': if (state.status !== 'loading_data') { throw new Error(`Illegal transition: ${state.status} → reviewing`); } return { status: 'reviewing', itemId: state.itemId, lockExpires: state.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.itemId }; case 'START_REJECTION': if (state.status !== 'reviewing') { throw new Error(`Illegal transition: ${state.status} → rejecting`); } return { status: 'rejecting', itemId: state.itemId }; case 'COMPLETE': if (state.status !== 'approving' && state.status !== 'rejecting') { throw new Error(`Illegal transition: ${state.status} → complete`); } return { status: 'complete', itemId: state.itemId, result: action.payload.result }; case 'ERROR': // Error can happen from most states if (state.status === 'idle' || state.status === 'complete') { logger.warn('Error action in terminal state'); return state; } return { status: 'error', itemId: state.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') { logger.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.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.lockExpires); const now = new Date(); const timeUntilExpiry = lockExpires.getTime() - now.getTime(); // Renew if less than 2 minutes remaining return timeUntilExpiry < 120000; }