import type { IEntity } from '@core/shared/domain'; import { IdentityDomainValidationError, IdentityDomainInvariantError } from '../errors/IdentityDomainError'; export interface AdminVote { voterId: string; positive: boolean; votedAt: Date; } export interface AdminVoteOutcome { percentPositive: number; count: { positive: number; negative: number; total: number; }; eligibleVoterCount: number; participationRate: number; outcome: 'positive' | 'negative' | 'tie'; } export interface AdminVoteSessionProps { voteSessionId: string; leagueId: string; adminId: string; startDate: Date; endDate: Date; eligibleVoters: string[]; // User IDs votes?: AdminVote[]; closed?: boolean; outcome?: AdminVoteOutcome; createdAt?: Date; updatedAt?: Date; } /** * AdminVoteSession Entity * * Aggregate root for admin vote sessions scoped to a league. * Controls who can vote, deduplication, time windows, and closure. * Emits outcome events that convert to rating ledger events. * * Based on ratings-architecture-concept.md sections 5.2.1 and 7.1.1 */ export class AdminVoteSession implements IEntity { readonly id: string; readonly leagueId: string; readonly adminId: string; readonly startDate: Date; readonly endDate: Date; readonly eligibleVoters: string[]; private _votes: AdminVote[]; private _closed: boolean; private _outcome: AdminVoteOutcome | undefined; readonly createdAt: Date; private _updatedAt: Date; private constructor(props: AdminVoteSessionProps) { this.id = props.voteSessionId; this.leagueId = props.leagueId; this.adminId = props.adminId; this.startDate = props.startDate; this.endDate = props.endDate; this.eligibleVoters = props.eligibleVoters; this._votes = props.votes || []; this._closed = props.closed || false; this._outcome = props.outcome; this.createdAt = props.createdAt || new Date(); this._updatedAt = props.updatedAt || new Date(); } static create(props: AdminVoteSessionProps): AdminVoteSession { // Validate required fields if (!props.voteSessionId || props.voteSessionId.trim().length === 0) { throw new IdentityDomainValidationError('voteSessionId is required'); } if (!props.leagueId || props.leagueId.trim().length === 0) { throw new IdentityDomainValidationError('leagueId is required'); } if (!props.adminId || props.adminId.trim().length === 0) { throw new IdentityDomainValidationError('adminId is required'); } if (!props.startDate || !props.endDate) { throw new IdentityDomainValidationError('startDate and endDate are required'); } if (props.startDate >= props.endDate) { throw new IdentityDomainInvariantError('startDate must be before endDate'); } if (!props.eligibleVoters || props.eligibleVoters.length === 0) { throw new IdentityDomainValidationError('At least one eligible voter is required'); } // Validate no duplicate eligible voters const uniqueVoters = new Set(props.eligibleVoters); if (uniqueVoters.size !== props.eligibleVoters.length) { throw new IdentityDomainInvariantError('Duplicate eligible voters are not allowed'); } // Validate votes if provided if (props.votes) { const voterIds = new Set(); for (const vote of props.votes) { if (!vote.voterId || vote.voterId.trim().length === 0) { throw new IdentityDomainValidationError('Vote voterId is required'); } if (!props.eligibleVoters.includes(vote.voterId)) { throw new IdentityDomainInvariantError(`Voter ${vote.voterId} is not eligible`); } if (voterIds.has(vote.voterId)) { throw new IdentityDomainInvariantError(`Duplicate vote from voter ${vote.voterId}`); } if (!vote.votedAt) { throw new IdentityDomainValidationError('Vote timestamp is required'); } voterIds.add(vote.voterId); } } // Validate outcome if provided if (props.outcome) { if (props.outcome.percentPositive < 0 || props.outcome.percentPositive > 100) { throw new IdentityDomainValidationError('percentPositive must be between 0 and 100'); } if (props.outcome.eligibleVoterCount !== props.eligibleVoters.length) { throw new IdentityDomainInvariantError('eligibleVoterCount must match eligibleVoters length'); } } return new AdminVoteSession(props); } static rehydrate(props: AdminVoteSessionProps): AdminVoteSession { // Rehydration assumes data is already validated (from persistence) return new AdminVoteSession(props); } // Getters get votes(): AdminVote[] { return [...this._votes]; } get closed(): boolean { return this._closed; } get outcome(): AdminVoteOutcome | undefined { return this._outcome; } get updatedAt(): Date { return this._updatedAt; } /** * Cast a vote in this session * @param voterId - The user ID of the voter * @param positive - Whether the vote is positive (true) or negative (false) * @param votedAt - When the vote was cast (optional, defaults to now) * @throws Error if session is closed, voter is not eligible, or already voted */ castVote(voterId: string, positive: boolean, votedAt: Date = new Date()): void { if (this._closed) { throw new IdentityDomainInvariantError('Cannot cast vote: session is closed'); } if (!this.eligibleVoters.includes(voterId)) { throw new IdentityDomainInvariantError(`Voter ${voterId} is not eligible for this session`); } if (this._votes.some(v => v.voterId === voterId)) { throw new IdentityDomainInvariantError(`Voter ${voterId} has already voted`); } if (votedAt < this.startDate || votedAt > this.endDate) { throw new IdentityDomainInvariantError('Vote timestamp is outside the voting window'); } this._votes.push({ voterId, positive, votedAt, }); this._updatedAt = new Date(); } /** * Close the vote session and calculate outcome * @throws Error if session is already closed * @returns The calculated outcome */ close(): AdminVoteOutcome { if (this._closed) { throw new IdentityDomainInvariantError('Session is already closed'); } const now = new Date(); if (now < this.startDate || now > this.endDate) { throw new IdentityDomainInvariantError('Cannot close session outside the voting window'); } const positiveVotes = this._votes.filter(v => v.positive).length; const negativeVotes = this._votes.filter(v => !v.positive).length; const totalVotes = this._votes.length; const eligibleVoterCount = this.eligibleVoters.length; const percentPositive = totalVotes > 0 ? (positiveVotes / totalVotes) * 100 : 0; const participationRate = (totalVotes / eligibleVoterCount) * 100; let outcome: 'positive' | 'negative' | 'tie'; if (totalVotes === 0) { outcome = 'tie'; } else if (positiveVotes > negativeVotes) { outcome = 'positive'; } else if (negativeVotes > positiveVotes) { outcome = 'negative'; } else { outcome = 'tie'; } this._outcome = { percentPositive: Math.round(percentPositive * 100) / 100, // Round to 2 decimal places count: { positive: positiveVotes, negative: negativeVotes, total: totalVotes, }, eligibleVoterCount, participationRate: Math.round(participationRate * 100) / 100, outcome, }; this._closed = true; this._updatedAt = now; return this._outcome; } /** * Check if a voter has already voted */ hasVoted(voterId: string): boolean { return this._votes.some(v => v.voterId === voterId); } /** * Get vote by voter ID */ getVote(voterId: string): AdminVote | undefined { return this._votes.find(v => v.voterId === voterId); } /** * Get count of votes cast */ getVoteCount(): number { return this._votes.length; } /** * Check if session is within voting window */ isVotingWindowOpen(now: Date = new Date()): boolean { return now >= this.startDate && now <= this.endDate && !this._closed; } equals(other: IEntity): boolean { return this.id === other.id; } toJSON(): Record { return { voteSessionId: this.id, leagueId: this.leagueId, adminId: this.adminId, startDate: this.startDate.toISOString(), endDate: this.endDate.toISOString(), eligibleVoters: this.eligibleVoters, votes: this._votes.map(v => ({ voterId: v.voterId, positive: v.positive, votedAt: v.votedAt.toISOString(), })), closed: this._closed, outcome: this._outcome, createdAt: this.createdAt.toISOString(), updatedAt: this._updatedAt.toISOString(), }; } }