rename to core

This commit is contained in:
2025-12-15 13:46:07 +01:00
parent aedf58643d
commit 5c22f8820c
559 changed files with 415 additions and 767 deletions

View File

@@ -0,0 +1,392 @@
/**
* Domain Entity: Achievement
*
* Represents an achievement that can be earned by users.
* Achievements are categorized by role (driver, steward, admin) and type.
*/
import type { IEntity } from '@gridpilot/shared/domain';
export type AchievementCategory = 'driver' | 'steward' | 'admin' | 'community';
export type AchievementRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
export interface AchievementProps {
id: string;
name: string;
description: string;
category: AchievementCategory;
rarity: AchievementRarity;
iconUrl?: string;
points: number;
requirements: AchievementRequirement[];
isSecret: boolean;
createdAt: Date;
}
export interface AchievementRequirement {
type: 'races_completed' | 'wins' | 'podiums' | 'clean_races' | 'protests_handled' |
'leagues_managed' | 'seasons_completed' | 'consecutive_clean' | 'rating_threshold' |
'trust_threshold' | 'events_stewarded' | 'members_managed' | 'championships_won';
value: number;
operator: '>=' | '>' | '=' | '<' | '<=';
}
export class Achievement implements IEntity<string> {
readonly id: string;
readonly name: string;
readonly description: string;
readonly category: AchievementCategory;
readonly rarity: AchievementRarity;
readonly iconUrl: string;
readonly points: number;
readonly requirements: AchievementRequirement[];
readonly isSecret: boolean;
readonly createdAt: Date;
private constructor(props: AchievementProps) {
this.id = props.id;
this.name = props.name;
this.description = props.description;
this.category = props.category;
this.rarity = props.rarity;
this.iconUrl = props.iconUrl ?? '';
this.points = props.points;
this.requirements = props.requirements;
this.isSecret = props.isSecret;
this.createdAt = props.createdAt;
}
static create(props: Omit<AchievementProps, 'createdAt'> & { createdAt?: Date }): Achievement {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Achievement ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new Error('Achievement name is required');
}
if (props.requirements.length === 0) {
throw new Error('Achievement must have at least one requirement');
}
return new Achievement({
...props,
createdAt: props.createdAt ?? new Date(),
});
}
/**
* Check if user stats meet all requirements
*/
checkRequirements(stats: Record<string, number>): boolean {
return this.requirements.every(req => {
const value = stats[req.type] ?? 0;
switch (req.operator) {
case '>=': return value >= req.value;
case '>': return value > req.value;
case '=': return value === req.value;
case '<': return value < req.value;
case '<=': return value <= req.value;
default: return false;
}
});
}
/**
* Get rarity color for display
*/
getRarityColor(): string {
const colors: Record<AchievementRarity, string> = {
common: '#9CA3AF',
uncommon: '#22C55E',
rare: '#3B82F6',
epic: '#A855F7',
legendary: '#F59E0B',
};
return colors[this.rarity];
}
/**
* Get display text for hidden achievements
*/
getDisplayName(): string {
if (this.isSecret) {
return '???';
}
return this.name;
}
getDisplayDescription(): string {
if (this.isSecret) {
return 'This achievement is secret. Keep playing to unlock it!';
}
return this.description;
}
}
// Predefined achievements for drivers
export const DRIVER_ACHIEVEMENTS: Omit<AchievementProps, 'createdAt'>[] = [
{
id: 'first-race',
name: 'First Steps',
description: 'Complete your first race',
category: 'driver',
rarity: 'common',
points: 10,
requirements: [{ type: 'races_completed', value: 1, operator: '>=' }],
isSecret: false,
},
{
id: 'ten-races',
name: 'Getting Started',
description: 'Complete 10 races',
category: 'driver',
rarity: 'common',
points: 25,
requirements: [{ type: 'races_completed', value: 10, operator: '>=' }],
isSecret: false,
},
{
id: 'fifty-races',
name: 'Regular Racer',
description: 'Complete 50 races',
category: 'driver',
rarity: 'uncommon',
points: 50,
requirements: [{ type: 'races_completed', value: 50, operator: '>=' }],
isSecret: false,
},
{
id: 'hundred-races',
name: 'Veteran',
description: 'Complete 100 races',
category: 'driver',
rarity: 'rare',
points: 100,
requirements: [{ type: 'races_completed', value: 100, operator: '>=' }],
isSecret: false,
},
{
id: 'first-win',
name: 'Victory Lane',
description: 'Win your first race',
category: 'driver',
rarity: 'uncommon',
points: 50,
requirements: [{ type: 'wins', value: 1, operator: '>=' }],
isSecret: false,
},
{
id: 'ten-wins',
name: 'Serial Winner',
description: 'Win 10 races',
category: 'driver',
rarity: 'rare',
points: 100,
requirements: [{ type: 'wins', value: 10, operator: '>=' }],
isSecret: false,
},
{
id: 'first-podium',
name: 'Podium Finisher',
description: 'Finish on the podium',
category: 'driver',
rarity: 'common',
points: 25,
requirements: [{ type: 'podiums', value: 1, operator: '>=' }],
isSecret: false,
},
{
id: 'clean-streak-5',
name: 'Clean Racer',
description: 'Complete 5 consecutive races without incidents',
category: 'driver',
rarity: 'uncommon',
points: 50,
requirements: [{ type: 'consecutive_clean', value: 5, operator: '>=' }],
isSecret: false,
},
{
id: 'clean-streak-10',
name: 'Safety First',
description: 'Complete 10 consecutive races without incidents',
category: 'driver',
rarity: 'rare',
points: 100,
requirements: [{ type: 'consecutive_clean', value: 10, operator: '>=' }],
isSecret: false,
},
{
id: 'championship-win',
name: 'Champion',
description: 'Win a championship',
category: 'driver',
rarity: 'epic',
points: 200,
requirements: [{ type: 'championships_won', value: 1, operator: '>=' }],
isSecret: false,
},
{
id: 'triple-crown',
name: 'Triple Crown',
description: 'Win 3 championships',
category: 'driver',
rarity: 'legendary',
points: 500,
requirements: [{ type: 'championships_won', value: 3, operator: '>=' }],
isSecret: false,
},
{
id: 'elite-driver',
name: 'Elite Driver',
description: 'Reach Elite driver rating',
category: 'driver',
rarity: 'epic',
points: 250,
requirements: [{ type: 'rating_threshold', value: 90, operator: '>=' }],
isSecret: false,
},
];
// Predefined achievements for stewards
export const STEWARD_ACHIEVEMENTS: Omit<AchievementProps, 'createdAt'>[] = [
{
id: 'first-protest',
name: 'Justice Served',
description: 'Handle your first protest',
category: 'steward',
rarity: 'common',
points: 15,
requirements: [{ type: 'protests_handled', value: 1, operator: '>=' }],
isSecret: false,
},
{
id: 'ten-protests',
name: 'Fair Judge',
description: 'Handle 10 protests',
category: 'steward',
rarity: 'uncommon',
points: 50,
requirements: [{ type: 'protests_handled', value: 10, operator: '>=' }],
isSecret: false,
},
{
id: 'fifty-protests',
name: 'Senior Steward',
description: 'Handle 50 protests',
category: 'steward',
rarity: 'rare',
points: 100,
requirements: [{ type: 'protests_handled', value: 50, operator: '>=' }],
isSecret: false,
},
{
id: 'hundred-protests',
name: 'Chief Steward',
description: 'Handle 100 protests',
category: 'steward',
rarity: 'epic',
points: 200,
requirements: [{ type: 'protests_handled', value: 100, operator: '>=' }],
isSecret: false,
},
{
id: 'event-steward-10',
name: 'Event Official',
description: 'Steward 10 race events',
category: 'steward',
rarity: 'uncommon',
points: 50,
requirements: [{ type: 'events_stewarded', value: 10, operator: '>=' }],
isSecret: false,
},
{
id: 'trusted-steward',
name: 'Trusted Steward',
description: 'Achieve highly-trusted status',
category: 'steward',
rarity: 'rare',
points: 150,
requirements: [{ type: 'trust_threshold', value: 75, operator: '>=' }],
isSecret: false,
},
];
// Predefined achievements for admins
export const ADMIN_ACHIEVEMENTS: Omit<AchievementProps, 'createdAt'>[] = [
{
id: 'first-league',
name: 'League Founder',
description: 'Create your first league',
category: 'admin',
rarity: 'common',
points: 25,
requirements: [{ type: 'leagues_managed', value: 1, operator: '>=' }],
isSecret: false,
},
{
id: 'first-season',
name: 'Season Organizer',
description: 'Complete your first full season',
category: 'admin',
rarity: 'uncommon',
points: 50,
requirements: [{ type: 'seasons_completed', value: 1, operator: '>=' }],
isSecret: false,
},
{
id: 'five-seasons',
name: 'Experienced Organizer',
description: 'Complete 5 seasons',
category: 'admin',
rarity: 'rare',
points: 100,
requirements: [{ type: 'seasons_completed', value: 5, operator: '>=' }],
isSecret: false,
},
{
id: 'ten-seasons',
name: 'Veteran Organizer',
description: 'Complete 10 seasons',
category: 'admin',
rarity: 'epic',
points: 200,
requirements: [{ type: 'seasons_completed', value: 10, operator: '>=' }],
isSecret: false,
},
{
id: 'large-league',
name: 'Community Builder',
description: 'Manage a league with 50+ members',
category: 'admin',
rarity: 'rare',
points: 150,
requirements: [{ type: 'members_managed', value: 50, operator: '>=' }],
isSecret: false,
},
{
id: 'huge-league',
name: 'Empire Builder',
description: 'Manage a league with 100+ members',
category: 'admin',
rarity: 'epic',
points: 300,
requirements: [{ type: 'members_managed', value: 100, operator: '>=' }],
isSecret: false,
},
];
// Community achievements (for all roles)
export const COMMUNITY_ACHIEVEMENTS: Omit<AchievementProps, 'createdAt'>[] = [
{
id: 'community-leader',
name: 'Community Leader',
description: 'Achieve community leader trust level',
category: 'community',
rarity: 'legendary',
points: 500,
requirements: [{ type: 'trust_threshold', value: 90, operator: '>=' }],
isSecret: false,
},
];

View File

@@ -0,0 +1,151 @@
/**
* Domain Entity: SponsorAccount
*
* Represents a sponsor's login account in the identity bounded context.
* Separate from the racing domain's Sponsor entity which holds business data.
*/
import { UserId } from '../value-objects/UserId';
import type { EmailValidationResult } from '../types/EmailAddress';
import { validateEmail } from '../types/EmailAddress';
export interface SponsorAccountProps {
id: UserId;
sponsorId: string; // Reference to racing domain's Sponsor entity
email: string;
passwordHash: string;
companyName: string;
isActive: boolean;
createdAt: Date;
lastLoginAt?: Date;
}
export class SponsorAccount {
private readonly id: UserId;
private readonly sponsorId: string;
private email: string;
private passwordHash: string;
private companyName: string;
private isActive: boolean;
private readonly createdAt: Date;
private lastLoginAt: Date | undefined;
private constructor(props: SponsorAccountProps) {
this.id = props.id;
this.sponsorId = props.sponsorId;
this.email = props.email;
this.passwordHash = props.passwordHash;
this.companyName = props.companyName;
this.isActive = props.isActive;
this.createdAt = props.createdAt ?? new Date();
this.lastLoginAt = props.lastLoginAt;
}
public static create(props: Omit<SponsorAccountProps, 'createdAt' | 'isActive'> & {
createdAt?: Date;
isActive?: boolean;
}): SponsorAccount {
if (!props.sponsorId || !props.sponsorId.trim()) {
throw new Error('SponsorAccount sponsorId is required');
}
if (!props.companyName || !props.companyName.trim()) {
throw new Error('SponsorAccount companyName is required');
}
if (!props.passwordHash || !props.passwordHash.trim()) {
throw new Error('SponsorAccount passwordHash is required');
}
const emailResult: EmailValidationResult = validateEmail(props.email);
if (!emailResult.success) {
throw new Error(emailResult.error);
}
return new SponsorAccount({
...props,
email: emailResult.email,
createdAt: props.createdAt ?? new Date(),
isActive: props.isActive ?? true,
});
}
public getId(): UserId {
return this.id;
}
public getSponsorId(): string {
return this.sponsorId;
}
public getEmail(): string {
return this.email;
}
public getPasswordHash(): string {
return this.passwordHash;
}
public getCompanyName(): string {
return this.companyName;
}
public getIsActive(): boolean {
return this.isActive;
}
public getCreatedAt(): Date {
return this.createdAt;
}
public getLastLoginAt(): Date | undefined {
return this.lastLoginAt;
}
public canLogin(): boolean {
return this.isActive;
}
public recordLogin(): SponsorAccount {
return new SponsorAccount({
id: this.id,
sponsorId: this.sponsorId,
email: this.email,
passwordHash: this.passwordHash,
companyName: this.companyName,
isActive: this.isActive,
createdAt: this.createdAt,
lastLoginAt: new Date(),
});
}
public deactivate(): SponsorAccount {
return new SponsorAccount({
id: this.id,
sponsorId: this.sponsorId,
email: this.email,
passwordHash: this.passwordHash,
companyName: this.companyName,
isActive: false,
createdAt: this.createdAt,
...(this.lastLoginAt ? { lastLoginAt: this.lastLoginAt } : {}),
});
}
public updatePassword(newPasswordHash: string): SponsorAccount {
if (!newPasswordHash || !newPasswordHash.trim()) {
throw new Error('Password hash cannot be empty');
}
return new SponsorAccount({
id: this.id,
sponsorId: this.sponsorId,
email: this.email,
passwordHash: newPasswordHash,
companyName: this.companyName,
isActive: this.isActive,
createdAt: this.createdAt,
...(this.lastLoginAt ? { lastLoginAt: this.lastLoginAt } : {}),
});
}
}

View File

@@ -0,0 +1,73 @@
import type { EmailValidationResult } from '../types/EmailAddress';
import { validateEmail } from '../types/EmailAddress';
import { UserId } from '../value-objects/UserId';
export interface UserProps {
id: UserId;
displayName: string;
email?: string;
iracingCustomerId?: string;
primaryDriverId?: string;
avatarUrl?: string;
}
export class User {
private readonly id: UserId;
private displayName: string;
private email: string | undefined;
private iracingCustomerId: string | undefined;
private primaryDriverId: string | undefined;
private avatarUrl: string | undefined;
private constructor(props: UserProps) {
if (!props.displayName || !props.displayName.trim()) {
throw new Error('User displayName cannot be empty');
}
this.id = props.id;
this.displayName = props.displayName.trim();
this.email = props.email;
this.iracingCustomerId = props.iracingCustomerId;
this.primaryDriverId = props.primaryDriverId;
this.avatarUrl = props.avatarUrl;
}
public static create(props: UserProps): User {
if (props.email) {
const result: EmailValidationResult = validateEmail(props.email);
if (!result.success) {
throw new Error(result.error);
}
return new User({
...props,
email: result.email,
});
}
return new User(props);
}
public getId(): UserId {
return this.id;
}
public getDisplayName(): string {
return this.displayName;
}
public getEmail(): string | undefined {
return this.email;
}
public getIracingCustomerId(): string | undefined {
return this.iracingCustomerId;
}
public getPrimaryDriverId(): string | undefined {
return this.primaryDriverId;
}
public getAvatarUrl(): string | undefined {
return this.avatarUrl;
}
}

View File

@@ -0,0 +1,93 @@
/**
* Domain Entity: UserAchievement
*
* Represents an achievement earned by a specific user.
*/
import type { IEntity } from '@gridpilot/shared/domain';
export interface UserAchievementProps {
id: string;
userId: string;
achievementId: string;
earnedAt: Date;
notifiedAt?: Date;
progress?: number; // For partial progress tracking (0-100)
}
export class UserAchievement implements IEntity<string> {
readonly id: string;
readonly userId: string;
readonly achievementId: string;
readonly earnedAt: Date;
readonly notifiedAt: Date | undefined;
readonly progress: number;
private constructor(props: UserAchievementProps) {
this.id = props.id;
this.userId = props.userId;
this.achievementId = props.achievementId;
this.earnedAt = props.earnedAt;
this.notifiedAt = props.notifiedAt;
this.progress = props.progress ?? 100;
}
static create(props: UserAchievementProps): UserAchievement {
if (!props.id || props.id.trim().length === 0) {
throw new Error('UserAchievement ID is required');
}
if (!props.userId || props.userId.trim().length === 0) {
throw new Error('UserAchievement userId is required');
}
if (!props.achievementId || props.achievementId.trim().length === 0) {
throw new Error('UserAchievement achievementId is required');
}
return new UserAchievement(props);
}
/**
* Mark achievement as notified to user
*/
markNotified(): UserAchievement {
return new UserAchievement({
id: this.id,
userId: this.userId,
achievementId: this.achievementId,
earnedAt: this.earnedAt,
notifiedAt: new Date(),
progress: this.progress,
});
}
/**
* Update progress towards achievement
*/
updateProgress(progress: number): UserAchievement {
const clampedProgress = Math.max(0, Math.min(100, progress));
return new UserAchievement({
id: this.id,
userId: this.userId,
achievementId: this.achievementId,
earnedAt: this.earnedAt,
...(this.notifiedAt ? { notifiedAt: this.notifiedAt } : {}),
progress: clampedProgress,
});
}
/**
* Check if achievement is fully earned
*/
isComplete(): boolean {
return this.progress >= 100;
}
/**
* Check if user has been notified
*/
isNotified(): boolean {
return this.notifiedAt !== undefined;
}
}

View File

@@ -0,0 +1,28 @@
/**
* Repository Interface: IAchievementRepository
*
* Defines operations for Achievement and UserAchievement entities
*/
import type { Achievement, AchievementCategory } from '../entities/Achievement';
import type { UserAchievement } from '../entities/UserAchievement';
export interface IAchievementRepository {
// Achievement operations
findAchievementById(id: string): Promise<Achievement | null>;
findAllAchievements(): Promise<Achievement[]>;
findAchievementsByCategory(category: AchievementCategory): Promise<Achievement[]>;
createAchievement(achievement: Achievement): Promise<Achievement>;
// UserAchievement operations
findUserAchievementById(id: string): Promise<UserAchievement | null>;
findUserAchievementsByUserId(userId: string): Promise<UserAchievement[]>;
findUserAchievementByUserAndAchievement(userId: string, achievementId: string): Promise<UserAchievement | null>;
hasUserEarnedAchievement(userId: string, achievementId: string): Promise<boolean>;
createUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement>;
updateUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement>;
// Stats
getAchievementLeaderboard(limit: number): Promise<{ userId: string; points: number; count: number }[]>;
getUserAchievementStats(userId: string): Promise<{ total: number; points: number; byCategory: Record<AchievementCategory, number> }>;
}

View File

@@ -0,0 +1,16 @@
/**
* Repository Interface: ISponsorAccountRepository
*
* Defines persistence operations for SponsorAccount entities.
*/
import type { SponsorAccount } from '../entities/SponsorAccount';
import type { UserId } from '../value-objects/UserId';
export interface ISponsorAccountRepository {
save(account: SponsorAccount): Promise<void>;
findById(id: UserId): Promise<SponsorAccount | null>;
findBySponsorId(sponsorId: string): Promise<SponsorAccount | null>;
findByEmail(email: string): Promise<SponsorAccount | null>;
delete(id: UserId): Promise<void>;
}

View File

@@ -0,0 +1,49 @@
/**
* Repository Interface: IUserRatingRepository
*
* Defines operations for UserRating value objects
*/
import type { UserRating } from '../value-objects/UserRating';
export interface IUserRatingRepository {
/**
* Find rating by user ID
*/
findByUserId(userId: string): Promise<UserRating | null>;
/**
* Find ratings by multiple user IDs
*/
findByUserIds(userIds: string[]): Promise<UserRating[]>;
/**
* Save or update a user rating
*/
save(rating: UserRating): Promise<UserRating>;
/**
* Get top rated drivers
*/
getTopDrivers(limit: number): Promise<UserRating[]>;
/**
* Get top trusted users
*/
getTopTrusted(limit: number): Promise<UserRating[]>;
/**
* Get eligible stewards (based on trust and fairness thresholds)
*/
getEligibleStewards(): Promise<UserRating[]>;
/**
* Get ratings by driver tier
*/
findByDriverTier(tier: 'rookie' | 'amateur' | 'semi-pro' | 'pro' | 'elite'): Promise<UserRating[]>;
/**
* Delete rating by user ID
*/
delete(userId: string): Promise<void>;
}

View File

@@ -0,0 +1,50 @@
/**
* Domain Repository: IUserRepository
*
* Repository interface for User entity operations.
*/
import type { AuthenticatedUserDTO } from '../../application/dto/AuthenticatedUserDTO';
export interface UserCredentials {
email: string;
passwordHash: string;
salt: string;
}
export interface StoredUser {
id: string;
email: string;
displayName: string;
passwordHash: string;
salt: string;
primaryDriverId?: string;
createdAt: Date;
}
export interface IUserRepository {
/**
* Find user by email
*/
findByEmail(email: string): Promise<StoredUser | null>;
/**
* Find user by ID
*/
findById(id: string): Promise<StoredUser | null>;
/**
* Create a new user
*/
create(user: StoredUser): Promise<StoredUser>;
/**
* Update user
*/
update(user: StoredUser): Promise<StoredUser>;
/**
* Check if email exists
*/
emailExists(email: string): Promise<boolean>;
}

View File

@@ -0,0 +1,142 @@
import type { IDomainService } from '@gridpilot/shared/domain';
import type { IUserRatingRepository } from '../repositories/IUserRatingRepository';
import { UserRating } from '../value-objects/UserRating';
/**
* Domain Service: RatingUpdateService
*
* Handles updating user ratings based on various events and performance metrics.
* Centralizes rating calculation logic and ensures consistency across the system.
*/
export class RatingUpdateService implements IDomainService {
constructor(
private readonly userRatingRepository: IUserRatingRepository
) {}
/**
* Update driver ratings after race completion
*/
async updateDriverRatingsAfterRace(
driverResults: Array<{
driverId: string;
position: number;
totalDrivers: number;
incidents: number;
startPosition: number;
}>
): Promise<void> {
for (const result of driverResults) {
await this.updateDriverRating(result);
}
}
/**
* Update individual driver rating based on race result
*/
private async updateDriverRating(result: {
driverId: string;
position: number;
totalDrivers: number;
incidents: number;
startPosition: number;
}): Promise<void> {
const { driverId, position, totalDrivers, incidents, startPosition } = result;
// Get or create user rating
let userRating = await this.userRatingRepository.findByUserId(driverId);
if (!userRating) {
userRating = UserRating.create(driverId);
}
// Calculate performance score (0-100)
const performanceScore = this.calculatePerformanceScore(position, totalDrivers, startPosition);
// Calculate fairness score based on incidents (lower incidents = higher fairness)
const fairnessScore = this.calculateFairnessScore(incidents, totalDrivers);
// Update ratings
const updatedRating = userRating
.updateDriverRating(performanceScore)
.updateFairnessScore(fairnessScore);
// Save updated rating
await this.userRatingRepository.save(updatedRating);
}
/**
* Calculate performance score based on finishing position and field strength
*/
private calculatePerformanceScore(
position: number,
totalDrivers: number,
startPosition: number
): number {
// Base score from finishing position (reverse percentile)
const positionScore = ((totalDrivers - position + 1) / totalDrivers) * 100;
// Bonus for positions gained
const positionsGained = startPosition - position;
const gainBonus = Math.max(0, positionsGained * 2); // 2 points per position gained
// Field strength adjustment (harder fields give higher scores for same position)
const fieldStrengthMultiplier = 0.8 + (totalDrivers / 50); // Max 1.0 for 30+ drivers
const rawScore = (positionScore + gainBonus) * fieldStrengthMultiplier;
// Clamp to 0-100 range
return Math.max(0, Math.min(100, rawScore));
}
/**
* Calculate fairness score based on incident involvement
*/
private calculateFairnessScore(incidents: number, totalDrivers: number): number {
// Base fairness score (100 = perfect, 0 = terrible)
let fairnessScore = 100;
// Deduct points for incidents
fairnessScore -= incidents * 15; // 15 points per incident
// Additional deduction for high incident rate relative to field
const incidentRate = incidents / totalDrivers;
if (incidentRate > 0.5) {
fairnessScore -= 20; // Heavy penalty for being involved in many incidents
}
// Clamp to 0-100 range
return Math.max(0, Math.min(100, fairnessScore));
}
/**
* Update trust score based on sportsmanship actions
*/
async updateTrustScore(driverId: string, trustChange: number): Promise<void> {
let userRating = await this.userRatingRepository.findByUserId(driverId);
if (!userRating) {
userRating = UserRating.create(driverId);
}
// Convert trust change (-50 to +50) to 0-100 scale
const currentTrust = userRating.trust.value;
const newTrustValue = Math.max(0, Math.min(100, currentTrust + trustChange));
const updatedRating = userRating.updateTrustScore(newTrustValue);
await this.userRatingRepository.save(updatedRating);
}
/**
* Update steward rating based on protest handling quality
*/
async updateStewardRating(stewardId: string, ratingChange: number): Promise<void> {
let userRating = await this.userRatingRepository.findByUserId(stewardId);
if (!userRating) {
userRating = UserRating.create(stewardId);
}
const currentRating = userRating.steward.value;
const newRatingValue = Math.max(0, Math.min(100, currentRating + ratingChange));
const updatedRating = userRating.updateStewardRating(newRatingValue);
await this.userRatingRepository.save(updatedRating);
}
}

View File

@@ -0,0 +1,65 @@
import { z } from 'zod';
/**
* Core email validation schema and helper types.
* Kept in domain/types so domain/value-objects can host the EmailAddress VO class.
*/
export const emailSchema = z
.string()
.trim()
.toLowerCase()
.min(6, 'Email too short')
.max(254, 'Email too long')
.email('Invalid email format');
export type EmailValidationSuccess = {
success: true;
email: string;
error?: undefined;
};
export type EmailValidationFailure = {
success: false;
email?: undefined;
error: string;
};
export type EmailValidationResult = EmailValidationSuccess | EmailValidationFailure;
/**
* Validate and normalize an email address.
* Mirrors the previous apps/website/lib/email-validation.ts behavior.
*/
export function validateEmail(email: string): EmailValidationResult {
const result = emailSchema.safeParse(email);
if (result.success) {
return {
success: true,
email: result.data,
};
}
return {
success: false,
error: result.error.errors[0]?.message || 'Invalid email',
};
}
/**
* Basic disposable email detection.
* This list matches the previous website-local implementation and
* can be extended in the future without changing the public API.
*/
export const DISPOSABLE_DOMAINS = new Set<string>([
'tempmail.com',
'throwaway.email',
'guerrillamail.com',
'mailinator.com',
'10minutemail.com',
]);
export function isDisposableEmail(email: string): boolean {
const domain = email.split('@')[1]?.toLowerCase();
return domain ? DISPOSABLE_DOMAINS.has(domain) : false;
}

View File

@@ -0,0 +1,48 @@
import type { IValueObject } from '@gridpilot/shared/domain';
import type { EmailValidationResult } from '../types/EmailAddress';
import { validateEmail, isDisposableEmail } from '../types/EmailAddress';
export interface EmailAddressProps {
value: string;
}
/**
* Value Object: EmailAddress
*
* Wraps a validated, normalized email string and provides equality semantics.
* Validation and helper utilities live in domain/types/EmailAddress.
*/
export class EmailAddress implements IValueObject<EmailAddressProps> {
public readonly props: EmailAddressProps;
private constructor(value: string) {
this.props = { value };
}
static create(raw: string): EmailAddress {
const result: EmailValidationResult = validateEmail(raw);
if (!result.success) {
throw new Error(result.error);
}
return new EmailAddress(result.email);
}
static fromValidated(value: string): EmailAddress {
return new EmailAddress(value);
}
get value(): string {
return this.props.value;
}
equals(other: IValueObject<EmailAddressProps>): boolean {
return this.props.value === other.props.value;
}
isDisposable(): boolean {
return isDisposableEmail(this.props.value);
}
}
export type { EmailValidationResult } from '../types/EmailAddress';
export { validateEmail, isDisposableEmail } from '../types/EmailAddress';

View File

@@ -0,0 +1,32 @@
import type { IValueObject } from '@gridpilot/shared/domain';
export interface UserIdProps {
value: string;
}
export class UserId implements IValueObject<UserIdProps> {
public readonly props: UserIdProps;
private constructor(value: string) {
if (!value || !value.trim()) {
throw new Error('UserId cannot be empty');
}
this.props = { value };
}
public static fromString(value: string): UserId {
return new UserId(value);
}
get value(): string {
return this.props.value;
}
public toString(): string {
return this.props.value;
}
public equals(other: IValueObject<UserIdProps>): boolean {
return this.props.value === other.props.value;
}
}

View File

@@ -0,0 +1,281 @@
import type { IValueObject } from '@gridpilot/shared/domain';
/**
* Value Object: UserRating
*
* Multi-dimensional rating system for users covering:
* - Driver skill: racing ability, lap times, consistency
* - Admin competence: league management, event organization
* - Steward fairness: protest handling, penalty consistency
* - Trust score: reliability, sportsmanship, rule compliance
* - Fairness score: clean racing, incident involvement
*/
export interface RatingDimension {
value: number; // Current rating value (0-100 scale)
confidence: number; // Confidence level based on sample size (0-1)
sampleSize: number; // Number of events contributing to this rating
trend: 'rising' | 'stable' | 'falling';
lastUpdated: Date;
}
export interface UserRatingProps {
userId: string;
driver: RatingDimension;
admin: RatingDimension;
steward: RatingDimension;
trust: RatingDimension;
fairness: RatingDimension;
overallReputation: number;
createdAt: Date;
updatedAt: Date;
}
const DEFAULT_DIMENSION: RatingDimension = {
value: 50,
confidence: 0,
sampleSize: 0,
trend: 'stable',
lastUpdated: new Date(),
};
export class UserRating implements IValueObject<UserRatingProps> {
readonly props: UserRatingProps;
private constructor(props: UserRatingProps) {
this.props = props;
}
get userId(): string {
return this.props.userId;
}
get driver(): RatingDimension {
return this.props.driver;
}
get admin(): RatingDimension {
return this.props.admin;
}
get steward(): RatingDimension {
return this.props.steward;
}
get trust(): RatingDimension {
return this.props.trust;
}
get fairness(): RatingDimension {
return this.props.fairness;
}
get overallReputation(): number {
return this.props.overallReputation;
}
get createdAt(): Date {
return this.props.createdAt;
}
get updatedAt(): Date {
return this.props.updatedAt;
}
static create(userId: string): UserRating {
if (!userId || userId.trim().length === 0) {
throw new Error('UserRating userId is required');
}
const now = new Date();
return new UserRating({
userId,
driver: { ...DEFAULT_DIMENSION, lastUpdated: now },
admin: { ...DEFAULT_DIMENSION, lastUpdated: now },
steward: { ...DEFAULT_DIMENSION, lastUpdated: now },
trust: { ...DEFAULT_DIMENSION, lastUpdated: now },
fairness: { ...DEFAULT_DIMENSION, lastUpdated: now },
overallReputation: 50,
createdAt: now,
updatedAt: now,
});
}
static restore(props: UserRatingProps): UserRating {
return new UserRating(props);
}
equals(other: IValueObject<UserRatingProps>): boolean {
return this.props.userId === other.props.userId;
}
/**
* Update driver rating based on race performance
*/
updateDriverRating(
newValue: number,
weight: number = 1
): UserRating {
const updated = this.updateDimension(this.driver, newValue, weight);
return this.withUpdates({ driver: updated });
}
/**
* Update admin rating based on league management feedback
*/
updateAdminRating(
newValue: number,
weight: number = 1
): UserRating {
const updated = this.updateDimension(this.admin, newValue, weight);
return this.withUpdates({ admin: updated });
}
/**
* Update steward rating based on protest handling feedback
*/
updateStewardRating(
newValue: number,
weight: number = 1
): UserRating {
const updated = this.updateDimension(this.steward, newValue, weight);
return this.withUpdates({ steward: updated });
}
/**
* Update trust score based on reliability and sportsmanship
*/
updateTrustScore(
newValue: number,
weight: number = 1
): UserRating {
const updated = this.updateDimension(this.trust, newValue, weight);
return this.withUpdates({ trust: updated });
}
/**
* Update fairness score based on clean racing incidents
*/
updateFairnessScore(
newValue: number,
weight: number = 1
): UserRating {
const updated = this.updateDimension(this.fairness, newValue, weight);
return this.withUpdates({ fairness: updated });
}
/**
* Calculate weighted overall reputation
*/
calculateOverallReputation(): number {
// Weight dimensions by confidence and importance
const weights = {
driver: 0.25 * this.driver.confidence,
admin: 0.15 * this.admin.confidence,
steward: 0.15 * this.steward.confidence,
trust: 0.25 * this.trust.confidence,
fairness: 0.20 * this.fairness.confidence,
};
const totalWeight = Object.values(weights).reduce((sum, w) => sum + w, 0);
if (totalWeight === 0) {
return 50; // Default when no ratings yet
}
const weightedSum =
this.driver.value * weights.driver +
this.admin.value * weights.admin +
this.steward.value * weights.steward +
this.trust.value * weights.trust +
this.fairness.value * weights.fairness;
return Math.round(weightedSum / totalWeight);
}
/**
* Get rating tier for display
*/
getDriverTier(): 'rookie' | 'amateur' | 'semi-pro' | 'pro' | 'elite' {
if (this.driver.value >= 90) return 'elite';
if (this.driver.value >= 75) return 'pro';
if (this.driver.value >= 60) return 'semi-pro';
if (this.driver.value >= 40) return 'amateur';
return 'rookie';
}
/**
* Get trust level for matchmaking
*/
getTrustLevel(): 'unverified' | 'trusted' | 'highly-trusted' | 'community-leader' {
if (this.trust.value >= 90 && this.trust.sampleSize >= 50) return 'community-leader';
if (this.trust.value >= 75 && this.trust.sampleSize >= 20) return 'highly-trusted';
if (this.trust.value >= 60 && this.trust.sampleSize >= 5) return 'trusted';
return 'unverified';
}
/**
* Check if user is eligible to be a steward
*/
canBeSteward(): boolean {
return (
this.trust.value >= 70 &&
this.fairness.value >= 70 &&
this.trust.sampleSize >= 10
);
}
/**
* Check if user is eligible to be an admin
*/
canBeAdmin(): boolean {
return (
this.trust.value >= 60 &&
this.trust.sampleSize >= 5
);
}
private updateDimension(
dimension: RatingDimension,
newValue: number,
weight: number
): RatingDimension {
const clampedValue = Math.max(0, Math.min(100, newValue));
const newSampleSize = dimension.sampleSize + weight;
// Exponential moving average with decay based on sample size
const alpha = Math.min(0.3, 1 / (dimension.sampleSize + 1));
const updatedValue = dimension.value * (1 - alpha) + clampedValue * alpha;
// Calculate confidence (asymptotic to 1)
const confidence = 1 - Math.exp(-newSampleSize / 20);
// Determine trend
const valueDiff = updatedValue - dimension.value;
let trend: 'rising' | 'stable' | 'falling' = 'stable';
if (valueDiff > 2) trend = 'rising';
if (valueDiff < -2) trend = 'falling';
return {
value: Math.round(updatedValue * 10) / 10,
confidence: Math.round(confidence * 100) / 100,
sampleSize: newSampleSize,
trend,
lastUpdated: new Date(),
};
}
private withUpdates(updates: Partial<UserRatingProps>): UserRating {
const newRating = new UserRating({
...this.props,
...updates,
updatedAt: new Date(),
});
// Recalculate overall reputation
return new UserRating({
...newRating.props,
overallReputation: newRating.calculateOverallReputation(),
});
}
}