Files
gridpilot.gg/core/identity/domain/entities/AdminVoteSession.ts
2026-01-16 16:46:57 +01:00

295 lines
8.7 KiB
TypeScript

import { Entity } from '@core/shared/domain/Entity';
import { IdentityDomainInvariantError, IdentityDomainValidationError } 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 extends Entity<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) {
super(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<string>();
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: Entity<string>): boolean {
return this.id === other.id;
}
toJSON(): Record<string, unknown> {
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(),
};
}
}