wip
This commit is contained in:
390
packages/identity/domain/entities/Achievement.ts
Normal file
390
packages/identity/domain/entities/Achievement.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* Domain Entity: Achievement
|
||||
*
|
||||
* Represents an achievement that can be earned by users.
|
||||
* Achievements are categorized by role (driver, steward, admin) and type.
|
||||
*/
|
||||
|
||||
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 {
|
||||
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,
|
||||
},
|
||||
];
|
||||
151
packages/identity/domain/entities/SponsorAccount.ts
Normal file
151
packages/identity/domain/entities/SponsorAccount.ts
Normal 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 '../value-objects/EmailAddress';
|
||||
import { validateEmail } from '../value-objects/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;
|
||||
|
||||
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;
|
||||
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,
|
||||
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,
|
||||
lastLoginAt: this.lastLoginAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
83
packages/identity/domain/entities/UserAchievement.ts
Normal file
83
packages/identity/domain/entities/UserAchievement.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Domain Entity: UserAchievement
|
||||
*
|
||||
* Represents an achievement earned by a specific user.
|
||||
*/
|
||||
|
||||
export interface UserAchievementProps {
|
||||
id: string;
|
||||
userId: string;
|
||||
achievementId: string;
|
||||
earnedAt: Date;
|
||||
notifiedAt?: Date;
|
||||
progress?: number; // For partial progress tracking (0-100)
|
||||
}
|
||||
|
||||
export class UserAchievement {
|
||||
readonly id: string;
|
||||
readonly userId: string;
|
||||
readonly achievementId: string;
|
||||
readonly earnedAt: Date;
|
||||
readonly notifiedAt?: Date;
|
||||
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({
|
||||
...this,
|
||||
notifiedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update progress towards achievement
|
||||
*/
|
||||
updateProgress(progress: number): UserAchievement {
|
||||
const clampedProgress = Math.max(0, Math.min(100, progress));
|
||||
return new UserAchievement({
|
||||
...this,
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user