296 lines
8.7 KiB
TypeScript
296 lines
8.7 KiB
TypeScript
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<string> {
|
|
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<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: IEntity<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(),
|
|
};
|
|
}
|
|
}
|