rating
This commit is contained in:
295
core/identity/domain/entities/AdminVoteSession.ts
Normal file
295
core/identity/domain/entities/AdminVoteSession.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user