All files / src/components/organizer/SpeakerStatus speakerTransitions.ts

100% Statements 32/32
87.3% Branches 55/63
100% Functions 4/4
100% Lines 30/30

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196                                                                        3x                                     76x                                                                                     38x 25x     18x 2x   16x 2x   14x 7x             7x 2x       5x 2x   3x 1x       2x                       8x               2x     6x             1x         5x 1x     4x 1x     3x 1x     2x                       8x 8x            
/**
 * Speaker workflow transition allow-list (Story 11.D.4 — AC1).
 *
 * The 8-state allow-list mirrored from ADR-009 §0.2 and
 * `docs/architecture/06a-workflow-state-machines.md` lines 296-306. This map is the
 * SINGLE frontend source of truth — the kanban drag-drop dispatcher (AC2-AC6), the
 * detail-drawer secondary-actions list and override-state popover (AC7), and the
 * Playwright E2E suite all consult it.
 *
 * DECLINED is reachable from every non-terminal state. DECLINED has no outgoing
 * transitions (terminal). The provisioning gate at CONTACTED → READY, the
 * slot-capacity gate at READY → INVITED, and the reason requirement on any →
 * DECLINED are NOT encoded here — they are runtime preconditions enforced by
 * `SpeakerWorkflowService.transition()` on the backend (Story 11.B.2). The frontend
 * honours them by routing the drop through the appropriate rich modal (AC4-AC6).
 *
 * Pure module — no React, no hooks, no i18n calls.
 */
import type { TFunction } from 'i18next';
import type { SpeakerWorkflowState } from '@/types/speakerPool.types';
 
/**
 * The 8 ADR-009 states the kanban renders. Narrows the broader `SpeakerWorkflowState`
 * union (which historically also contained `SLOT_ASSIGNED`, `WITHDREW`, `OVERFLOW`) so
 * legacy union members cannot slip through this module.
 */
export type KanbanState =
  | 'IDENTIFIED'
  | 'CONTACTED'
  | 'READY'
  | 'INVITED'
  | 'ACCEPTED'
  | 'CONTENT_SUBMITTED'
  | 'QUALITY_REVIEWED'
  | 'DECLINED';
 
export const ALLOWED_TRANSITIONS: Readonly<Record<KanbanState, ReadonlySet<KanbanState>>> = {
  IDENTIFIED: new Set<KanbanState>(['CONTACTED', 'DECLINED']),
  CONTACTED: new Set<KanbanState>(['READY', 'DECLINED']),
  // READY → ACCEPTED added 2026-05-20: organizer accepts on-behalf for speakers who never
  // use the portal. Slot-capacity gate applies; reason required (audit trail). No emails.
  READY: new Set<KanbanState>(['INVITED', 'ACCEPTED', 'DECLINED']),
  INVITED: new Set<KanbanState>(['ACCEPTED', 'DECLINED']),
  ACCEPTED: new Set<KanbanState>(['CONTENT_SUBMITTED', 'DECLINED']),
  CONTENT_SUBMITTED: new Set<KanbanState>(['QUALITY_REVIEWED', 'DECLINED']),
  QUALITY_REVIEWED: new Set<KanbanState>(['DECLINED']),
  DECLINED: new Set<KanbanState>(), // terminal
} as const;
 
/**
 * Returns true when `from → to` is a legal transition per ADR-009 §0.2. Tolerates the
 * broader `SpeakerWorkflowState` union at the input boundary — non-kanban members
 * (legacy `SLOT_ASSIGNED`/`WITHDREW`/`OVERFLOW`) yield `false`.
 */
export function isLegalTransition(from: SpeakerWorkflowState, to: SpeakerWorkflowState): boolean {
  return ALLOWED_TRANSITIONS[from as KanbanState]?.has(to as KanbanState) ?? false;
}
 
/**
 * Drop intent classification — used by the kanban drag-end dispatcher (AC4) and the
 * detail-drawer override-state popover (AC7.2).
 *
 * - `legal-direct`       → fire the underlying status mutation immediately (no input
 *                          needed). Only `INVITED → ACCEPTED` lands here today (organizer
 *                          marking on behalf; the speaker portal flow uses a different
 *                          endpoint with its own audit principal).
 * - `legal-input`        → open the corresponding rich modal pre-filled:
 *                          - `IDENTIFIED → CONTACTED`  → MarkContactedModal
 *                          - `CONTACTED → READY`        → PromoteSpeakerDialog (11.D.1)
 *                          - `READY → INVITED`          → invitation flow (useSendInvitation)
 *                          - `ACCEPTED → CONTENT_SUBMITTED` → drawer's Content sub-tab (on-behalf form, AC8)
 *                          - `CONTENT_SUBMITTED → QUALITY_REVIEWED` → drawer's Quality Review sub-view
 * - `legal-decline`      → open `StatusChangeDialog` with required-reason field (AC5)
 * - `legal-blocked-slot` → `READY → INVITED` while slotCapacity.reached; reject with
 *                          snackbar toast using `organizer:speakerCard.slotCapacityTooltip` (AC6)
 * - `illegal`            → reject with snackbar toast using the state-machine explanation (AC3)
 */
export type DropIntent =
  | { kind: 'legal-direct' }
  | {
      kind: 'legal-input';
      modal: 'mark-contacted' | 'promote' | 'invitation' | 'content-form' | 'quality-review';
    }
  | { kind: 'legal-decline' }
  | { kind: 'legal-accept-on-behalf' }
  | { kind: 'legal-blocked-slot' }
  | { kind: 'illegal' };
 
/**
 * Classifies a proposed drop. The caller already verified `from !== to`. `slotCapacityReached`
 * is the in-page mirror of the backend `SpeakerWorkflowService.transition(INVITED)` gate
 * (Story 11.B.2); see AC6 defensive note.
 */
export function classifyDrop(
  from: SpeakerWorkflowState,
  to: SpeakerWorkflowState,
  slotCapacityReached: boolean
): DropIntent {
  if (!isLegalTransition(from, to)) return { kind: 'illegal' };
  if (to === 'DECLINED') return { kind: 'legal-decline' };
 
  // The five "legal-input" transitions per the JSDoc table above.
  if (from === 'IDENTIFIED' && to === 'CONTACTED') {
    return { kind: 'legal-input', modal: 'mark-contacted' };
  }
  if (from === 'CONTACTED' && to === 'READY') {
    return { kind: 'legal-input', modal: 'promote' };
  }
  if (from === 'READY' && to === 'INVITED') {
    return slotCapacityReached
      ? { kind: 'legal-blocked-slot' }
      : { kind: 'legal-input', modal: 'invitation' };
  }
  // READY → ACCEPTED (on-behalf, 2026-05-20). Same slot-capacity gate as READY → INVITED
  // because both add a new occupant. Reason is REQUIRED (status_history audit trail) →
  // routes through the same StatusChangeDialog as decline, with a different label.
  if (from === 'READY' && to === 'ACCEPTED') {
    return slotCapacityReached
      ? { kind: 'legal-blocked-slot' }
      : { kind: 'legal-accept-on-behalf' };
  }
  if (from === 'ACCEPTED' && to === 'CONTENT_SUBMITTED') {
    return { kind: 'legal-input', modal: 'content-form' };
  }
  if (from === 'CONTENT_SUBMITTED' && to === 'QUALITY_REVIEWED') {
    return { kind: 'legal-input', modal: 'quality-review' };
  }
 
  // INVITED → ACCEPTED is the only `legal-direct` today.
  return { kind: 'legal-direct' };
}
 
/**
 * Rejection-explanation key lookup used by the invalid-drop toast (AC3). Maps an
 * illegal `(from, to)` pair to a small i18n key under `organizer:kanbanDrag.rejection.*`.
 * Falls back to `cannotMoveBackwards` for any combination not explicitly enumerated —
 * the table is exhaustive for forward skip-ahead attempts; everything else is a
 * backwards move from the organizer's perspective.
 */
function explanationKey(from: KanbanState, to: KanbanState): string {
  // Skip-ahead from IDENTIFIED — must Contact first to log outreach.
  if (
    from === 'IDENTIFIED' &&
    (to === 'READY' ||
      to === 'INVITED' ||
      to === 'ACCEPTED' ||
      to === 'CONTENT_SUBMITTED' ||
      to === 'QUALITY_REVIEWED')
  ) {
    return 'mustPromoteFirst';
  }
  // Skip-ahead from CONTACTED — must Promote (READY) first to provision the speaker.
  if (
    from === 'CONTACTED' &&
    (to === 'INVITED' ||
      to === 'ACCEPTED' ||
      to === 'CONTENT_SUBMITTED' ||
      to === 'QUALITY_REVIEWED')
  ) {
    return 'mustPromoteFirst';
  }
  // Skip-ahead from READY — must Invite first (READY → ACCEPTED is now a legal
  // on-behalf transition, not a skip-ahead; this branch only fires for the still-illegal
  // skip-ahead targets CONTENT_SUBMITTED and QUALITY_REVIEWED).
  if (from === 'READY' && (to === 'CONTENT_SUBMITTED' || to === 'QUALITY_REVIEWED')) {
    return 'mustInviteFirst';
  }
  // Skip-ahead from INVITED — speaker must accept first.
  if (from === 'INVITED' && (to === 'CONTENT_SUBMITTED' || to === 'QUALITY_REVIEWED')) {
    return 'mustAcceptFirst';
  }
  // Skip-ahead from ACCEPTED — content must be submitted first.
  if (from === 'ACCEPTED' && to === 'QUALITY_REVIEWED') {
    return 'mustSubmitContentFirst';
  }
  // All remaining illegal combinations are backwards moves.
  return 'cannotMoveBackwards';
}
 
/**
 * Returns the localized invalid-drop toast text per AC3. Composition happens in TS so
 * locale files store atomic strings.
 */
export function getRejectionExplanation(
  from: SpeakerWorkflowState,
  to: SpeakerWorkflowState,
  t: TFunction
): string {
  const key = explanationKey(from as KanbanState, to as KanbanState);
  return t('organizer:kanbanDrag.rejection.template', {
    from: t(`organizer:speakerStatus.${from}`),
    to: t(`organizer:speakerStatus.${to}`),
    explanation: t(`organizer:kanbanDrag.rejection.${key}`),
  });
}