mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 07:51:13 -05:00
208 lines
6.5 KiB
TypeScript
208 lines
6.5 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|