rename to core
This commit is contained in:
8
core/identity/application/dto/AuthCallbackCommandDTO.ts
Normal file
8
core/identity/application/dto/AuthCallbackCommandDTO.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { AuthProviderDTO } from './AuthProviderDTO';
|
||||
|
||||
export interface AuthCallbackCommandDTO {
|
||||
provider: AuthProviderDTO;
|
||||
code: string;
|
||||
state: string;
|
||||
returnTo?: string;
|
||||
}
|
||||
1
core/identity/application/dto/AuthProviderDTO.ts
Normal file
1
core/identity/application/dto/AuthProviderDTO.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type AuthProviderDTO = 'IRACING_DEMO';
|
||||
8
core/identity/application/dto/AuthSessionDTO.ts
Normal file
8
core/identity/application/dto/AuthSessionDTO.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { AuthenticatedUserDTO } from './AuthenticatedUserDTO';
|
||||
|
||||
export interface AuthSessionDTO {
|
||||
user: AuthenticatedUserDTO;
|
||||
issuedAt: number;
|
||||
expiresAt: number;
|
||||
token: string;
|
||||
}
|
||||
8
core/identity/application/dto/AuthenticatedUserDTO.ts
Normal file
8
core/identity/application/dto/AuthenticatedUserDTO.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface AuthenticatedUserDTO {
|
||||
id: string;
|
||||
displayName: string;
|
||||
email?: string;
|
||||
iracingCustomerId?: string;
|
||||
primaryDriverId?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
4
core/identity/application/dto/IracingAuthStateDTO.ts
Normal file
4
core/identity/application/dto/IracingAuthStateDTO.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface IracingAuthStateDTO {
|
||||
state: string;
|
||||
returnTo?: string;
|
||||
}
|
||||
6
core/identity/application/dto/StartAuthCommandDTO.ts
Normal file
6
core/identity/application/dto/StartAuthCommandDTO.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { AuthProviderDTO } from './AuthProviderDTO';
|
||||
|
||||
export interface StartAuthCommandDTO {
|
||||
provider: AuthProviderDTO;
|
||||
returnTo?: string;
|
||||
}
|
||||
8
core/identity/application/ports/IdentityProviderPort.ts
Normal file
8
core/identity/application/ports/IdentityProviderPort.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO';
|
||||
import type { AuthCallbackCommandDTO } from '../dto/AuthCallbackCommandDTO';
|
||||
import type { StartAuthCommandDTO } from '../dto/StartAuthCommandDTO';
|
||||
|
||||
export interface IdentityProviderPort {
|
||||
startAuth(command: StartAuthCommandDTO): Promise<{ redirectUrl: string; state: string }>;
|
||||
completeAuth(command: AuthCallbackCommandDTO): Promise<AuthenticatedUserDTO>;
|
||||
}
|
||||
8
core/identity/application/ports/IdentitySessionPort.ts
Normal file
8
core/identity/application/ports/IdentitySessionPort.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO';
|
||||
import type { AuthSessionDTO } from '../dto/AuthSessionDTO';
|
||||
|
||||
export interface IdentitySessionPort {
|
||||
getCurrentSession(): Promise<AuthSessionDTO | null>;
|
||||
createSession(user: AuthenticatedUserDTO): Promise<AuthSessionDTO>;
|
||||
clearSession(): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { AuthSessionDTO } from '../dto/AuthSessionDTO';
|
||||
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
|
||||
|
||||
export class GetCurrentUserSessionUseCase {
|
||||
private readonly sessionPort: IdentitySessionPort;
|
||||
|
||||
constructor(sessionPort: IdentitySessionPort) {
|
||||
this.sessionPort = sessionPort;
|
||||
}
|
||||
|
||||
async execute(): Promise<AuthSessionDTO | null> {
|
||||
return this.sessionPort.getCurrentSession();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { AuthCallbackCommandDTO } from '../dto/AuthCallbackCommandDTO';
|
||||
import type { AuthSessionDTO } from '../dto/AuthSessionDTO';
|
||||
import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO';
|
||||
import type { IdentityProviderPort } from '../ports/IdentityProviderPort';
|
||||
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
|
||||
|
||||
export class HandleAuthCallbackUseCase {
|
||||
private readonly provider: IdentityProviderPort;
|
||||
private readonly sessionPort: IdentitySessionPort;
|
||||
|
||||
constructor(provider: IdentityProviderPort, sessionPort: IdentitySessionPort) {
|
||||
this.provider = provider;
|
||||
this.sessionPort = sessionPort;
|
||||
}
|
||||
|
||||
async execute(command: AuthCallbackCommandDTO): Promise<AuthSessionDTO> {
|
||||
const user: AuthenticatedUserDTO = await this.provider.completeAuth(command);
|
||||
const session = await this.sessionPort.createSession(user);
|
||||
return session;
|
||||
}
|
||||
}
|
||||
75
core/identity/application/use-cases/LoginWithEmailUseCase.ts
Normal file
75
core/identity/application/use-cases/LoginWithEmailUseCase.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Login with Email Use Case
|
||||
*
|
||||
* Authenticates a user with email and password.
|
||||
*/
|
||||
|
||||
import type { IUserRepository } from '../../domain/repositories/IUserRepository';
|
||||
import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO';
|
||||
import type { AuthSessionDTO } from '../dto/AuthSessionDTO';
|
||||
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
|
||||
|
||||
export interface LoginCommandDTO {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class LoginWithEmailUseCase {
|
||||
constructor(
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly sessionPort: IdentitySessionPort,
|
||||
) {}
|
||||
|
||||
async execute(command: LoginCommandDTO): Promise<AuthSessionDTO> {
|
||||
// Validate inputs
|
||||
if (!command.email || !command.password) {
|
||||
throw new Error('Email and password are required');
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
const user = await this.userRepository.findByEmail(command.email.toLowerCase().trim());
|
||||
if (!user) {
|
||||
throw new Error('Invalid email or password');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const passwordHash = await this.hashPassword(command.password, user.salt);
|
||||
if (passwordHash !== user.passwordHash) {
|
||||
throw new Error('Invalid email or password');
|
||||
}
|
||||
|
||||
// Create session
|
||||
const authenticatedUserBase: AuthenticatedUserDTO = {
|
||||
id: user.id,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
};
|
||||
|
||||
const authenticatedUser: AuthenticatedUserDTO =
|
||||
user.primaryDriverId !== undefined
|
||||
? { ...authenticatedUserBase, primaryDriverId: user.primaryDriverId }
|
||||
: authenticatedUserBase;
|
||||
|
||||
return this.sessionPort.createSession(authenticatedUser);
|
||||
}
|
||||
|
||||
private async hashPassword(password: string, salt: string): Promise<string> {
|
||||
// Simple hash for demo - in production, use bcrypt or argon2
|
||||
const data = password + salt;
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
const encoder = new TextEncoder();
|
||||
const dataBuffer = encoder.encode(data);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
// Fallback for environments without crypto.subtle
|
||||
let hash = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const char = data.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash).toString(16).padStart(16, '0');
|
||||
}
|
||||
}
|
||||
13
core/identity/application/use-cases/LogoutUseCase.ts
Normal file
13
core/identity/application/use-cases/LogoutUseCase.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
|
||||
|
||||
export class LogoutUseCase {
|
||||
private readonly sessionPort: IdentitySessionPort;
|
||||
|
||||
constructor(sessionPort: IdentitySessionPort) {
|
||||
this.sessionPort = sessionPort;
|
||||
}
|
||||
|
||||
async execute(): Promise<void> {
|
||||
await this.sessionPort.clearSession();
|
||||
}
|
||||
}
|
||||
122
core/identity/application/use-cases/SignupWithEmailUseCase.ts
Normal file
122
core/identity/application/use-cases/SignupWithEmailUseCase.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Signup with Email Use Case
|
||||
*
|
||||
* Creates a new user account with email and password.
|
||||
*/
|
||||
|
||||
import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository';
|
||||
import type { AuthenticatedUserDTO } from '../dto/AuthenticatedUserDTO';
|
||||
import type { AuthSessionDTO } from '../dto/AuthSessionDTO';
|
||||
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
|
||||
|
||||
export interface SignupCommandDTO {
|
||||
email: string;
|
||||
password: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface SignupResultDTO {
|
||||
session: AuthSessionDTO;
|
||||
isNewUser: boolean;
|
||||
}
|
||||
|
||||
export class SignupWithEmailUseCase {
|
||||
constructor(
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly sessionPort: IdentitySessionPort,
|
||||
) {}
|
||||
|
||||
async execute(command: SignupCommandDTO): Promise<SignupResultDTO> {
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(command.email)) {
|
||||
throw new Error('Invalid email format');
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
if (command.password.length < 8) {
|
||||
throw new Error('Password must be at least 8 characters');
|
||||
}
|
||||
|
||||
// Validate display name
|
||||
if (!command.displayName || command.displayName.trim().length < 2) {
|
||||
throw new Error('Display name must be at least 2 characters');
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
const existingUser = await this.userRepository.findByEmail(command.email);
|
||||
if (existingUser) {
|
||||
throw new Error('An account with this email already exists');
|
||||
}
|
||||
|
||||
// Hash password (simple hash for demo - in production use bcrypt)
|
||||
const salt = this.generateSalt();
|
||||
const passwordHash = await this.hashPassword(command.password, salt);
|
||||
|
||||
// Create user
|
||||
const userId = this.generateUserId();
|
||||
const newUser: StoredUser = {
|
||||
id: userId,
|
||||
email: command.email.toLowerCase().trim(),
|
||||
displayName: command.displayName.trim(),
|
||||
passwordHash,
|
||||
salt,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
await this.userRepository.create(newUser);
|
||||
|
||||
// Create session
|
||||
const authenticatedUser: AuthenticatedUserDTO = {
|
||||
id: newUser.id,
|
||||
displayName: newUser.displayName,
|
||||
email: newUser.email,
|
||||
};
|
||||
|
||||
const session = await this.sessionPort.createSession(authenticatedUser);
|
||||
|
||||
return {
|
||||
session,
|
||||
isNewUser: true,
|
||||
};
|
||||
}
|
||||
|
||||
private generateSalt(): string {
|
||||
const array = new Uint8Array(16);
|
||||
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
||||
crypto.getRandomValues(array);
|
||||
} else {
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
array[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
private async hashPassword(password: string, salt: string): Promise<string> {
|
||||
// Simple hash for demo - in production, use bcrypt or argon2
|
||||
const data = password + salt;
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
const encoder = new TextEncoder();
|
||||
const dataBuffer = encoder.encode(data);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBuffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
// Fallback for environments without crypto.subtle
|
||||
let hash = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const char = data.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash).toString(16).padStart(16, '0');
|
||||
}
|
||||
|
||||
private generateUserId(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return 'user-' + Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
}
|
||||
14
core/identity/application/use-cases/StartAuthUseCase.ts
Normal file
14
core/identity/application/use-cases/StartAuthUseCase.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { StartAuthCommandDTO } from '../dto/StartAuthCommandDTO';
|
||||
import type { IdentityProviderPort } from '../ports/IdentityProviderPort';
|
||||
|
||||
export class StartAuthUseCase {
|
||||
private readonly provider: IdentityProviderPort;
|
||||
|
||||
constructor(provider: IdentityProviderPort) {
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
async execute(command: StartAuthCommandDTO): Promise<{ redirectUrl: string; state: string }> {
|
||||
return this.provider.startAuth(command);
|
||||
}
|
||||
}
|
||||
392
core/identity/domain/entities/Achievement.ts
Normal file
392
core/identity/domain/entities/Achievement.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
151
core/identity/domain/entities/SponsorAccount.ts
Normal file
151
core/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 '../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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
73
core/identity/domain/entities/User.ts
Normal file
73
core/identity/domain/entities/User.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
93
core/identity/domain/entities/UserAchievement.ts
Normal file
93
core/identity/domain/entities/UserAchievement.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
28
core/identity/domain/repositories/IAchievementRepository.ts
Normal file
28
core/identity/domain/repositories/IAchievementRepository.ts
Normal 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> }>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
49
core/identity/domain/repositories/IUserRatingRepository.ts
Normal file
49
core/identity/domain/repositories/IUserRatingRepository.ts
Normal 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>;
|
||||
}
|
||||
50
core/identity/domain/repositories/IUserRepository.ts
Normal file
50
core/identity/domain/repositories/IUserRepository.ts
Normal 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>;
|
||||
}
|
||||
142
core/identity/domain/services/RatingUpdateService.ts
Normal file
142
core/identity/domain/services/RatingUpdateService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
65
core/identity/domain/types/EmailAddress.ts
Normal file
65
core/identity/domain/types/EmailAddress.ts
Normal 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;
|
||||
}
|
||||
48
core/identity/domain/value-objects/EmailAddress.ts
Normal file
48
core/identity/domain/value-objects/EmailAddress.ts
Normal 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';
|
||||
32
core/identity/domain/value-objects/UserId.ts
Normal file
32
core/identity/domain/value-objects/UserId.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
281
core/identity/domain/value-objects/UserRating.ts
Normal file
281
core/identity/domain/value-objects/UserRating.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
27
core/identity/index.ts
Normal file
27
core/identity/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export * from './domain/value-objects/EmailAddress';
|
||||
export * from './domain/value-objects/UserId';
|
||||
export * from './domain/value-objects/UserRating';
|
||||
export * from './domain/entities/User';
|
||||
export * from './domain/entities/SponsorAccount';
|
||||
export * from './domain/entities/Achievement';
|
||||
export * from './domain/entities/UserAchievement';
|
||||
|
||||
export * from './domain/repositories/IUserRepository';
|
||||
export * from './domain/repositories/ISponsorAccountRepository';
|
||||
export * from './domain/repositories/IUserRatingRepository';
|
||||
export * from './domain/repositories/IAchievementRepository';
|
||||
|
||||
export * from './infrastructure/repositories/InMemoryUserRatingRepository';
|
||||
export * from './infrastructure/repositories/InMemoryAchievementRepository';
|
||||
|
||||
export * from './application/dto/AuthenticatedUserDTO';
|
||||
export * from './application/dto/AuthSessionDTO';
|
||||
export * from './application/dto/AuthCallbackCommandDTO';
|
||||
export * from './application/dto/StartAuthCommandDTO';
|
||||
export * from './application/dto/AuthProviderDTO';
|
||||
export * from './application/dto/IracingAuthStateDTO';
|
||||
|
||||
export * from './application/use-cases/StartAuthUseCase';
|
||||
export * from './application/use-cases/HandleAuthCallbackUseCase';
|
||||
export * from './application/use-cases/GetCurrentUserSessionUseCase';
|
||||
export * from './application/use-cases/LogoutUseCase';
|
||||
@@ -0,0 +1,50 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { createStaticRacingSeed } from '@gridpilot/testing-support';
|
||||
import type { IdentityProviderPort } from '../../application/ports/IdentityProviderPort';
|
||||
import type { StartAuthCommandDTO } from '../../application/dto/StartAuthCommandDTO';
|
||||
import type { AuthCallbackCommandDTO } from '../../application/dto/AuthCallbackCommandDTO';
|
||||
import type { AuthenticatedUserDTO } from '../../application/dto/AuthenticatedUserDTO';
|
||||
|
||||
export class IracingDemoIdentityProviderAdapter implements IdentityProviderPort {
|
||||
private readonly seedDriverId: string;
|
||||
|
||||
constructor() {
|
||||
const seed = createStaticRacingSeed(42);
|
||||
this.seedDriverId = seed.drivers[0]?.id ?? 'driver-1';
|
||||
}
|
||||
|
||||
async startAuth(command: StartAuthCommandDTO): Promise<{ redirectUrl: string; state: string }> {
|
||||
const state = randomUUID();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('code', 'dummy-code');
|
||||
params.set('state', state);
|
||||
if (command.returnTo) {
|
||||
params.set('returnTo', command.returnTo);
|
||||
}
|
||||
|
||||
return {
|
||||
redirectUrl: `/auth/iracing/callback?${params.toString()}`,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
async completeAuth(command: AuthCallbackCommandDTO): Promise<AuthenticatedUserDTO> {
|
||||
if (!command.code) {
|
||||
throw new Error('Missing auth code');
|
||||
}
|
||||
if (!command.state) {
|
||||
throw new Error('Missing auth state');
|
||||
}
|
||||
|
||||
const user: AuthenticatedUserDTO = {
|
||||
id: 'demo-user',
|
||||
displayName: 'GridPilot Demo Driver',
|
||||
iracingCustomerId: '000000',
|
||||
primaryDriverId: this.seedDriverId,
|
||||
avatarUrl: `/api/avatar/${this.seedDriverId}`,
|
||||
};
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryAchievementRepository
|
||||
*
|
||||
* In-memory implementation of IAchievementRepository
|
||||
*/
|
||||
|
||||
import {
|
||||
Achievement,
|
||||
AchievementCategory,
|
||||
DRIVER_ACHIEVEMENTS,
|
||||
STEWARD_ACHIEVEMENTS,
|
||||
ADMIN_ACHIEVEMENTS,
|
||||
COMMUNITY_ACHIEVEMENTS,
|
||||
} from '../../domain/entities/Achievement';
|
||||
import { UserAchievement } from '../../domain/entities/UserAchievement';
|
||||
import type { IAchievementRepository } from '../../domain/repositories/IAchievementRepository';
|
||||
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
|
||||
export class InMemoryAchievementRepository implements IAchievementRepository {
|
||||
private achievements: Map<string, Achievement> = new Map();
|
||||
private userAchievements: Map<string, UserAchievement> = new Map();
|
||||
private readonly logger: ILogger;
|
||||
|
||||
constructor(logger: ILogger) {
|
||||
this.logger = logger;
|
||||
this.logger.info('InMemoryAchievementRepository initialized.');
|
||||
// Seed with predefined achievements
|
||||
this.seedAchievements();
|
||||
}
|
||||
|
||||
private seedAchievements(): void {
|
||||
this.logger.debug('Seeding predefined achievements.');
|
||||
const allAchievements = [
|
||||
...DRIVER_ACHIEVEMENTS,
|
||||
...STEWARD_ACHIEVEMENTS,
|
||||
...ADMIN_ACHIEVEMENTS,
|
||||
...COMMUNITY_ACHIEVEMENTS,
|
||||
];
|
||||
|
||||
for (const props of allAchievements) {
|
||||
const achievement = Achievement.create(props);
|
||||
this.achievements.set(achievement.id, achievement);
|
||||
this.logger.debug(`Seeded achievement: ${achievement.id} (${achievement.name}).`);
|
||||
}
|
||||
this.logger.info(`Seeded ${allAchievements.length} predefined achievements.`);
|
||||
}
|
||||
|
||||
// Achievement operations
|
||||
async findAchievementById(id: string): Promise<Achievement | null> {
|
||||
this.logger.debug(`Finding achievement by id: ${id}`);
|
||||
try {
|
||||
const achievement = this.achievements.get(id) ?? null;
|
||||
if (achievement) {
|
||||
this.logger.info(`Found achievement: ${id}.`);
|
||||
} else {
|
||||
this.logger.warn(`Achievement with id ${id} not found.`);
|
||||
}
|
||||
return achievement;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding achievement by id ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findAllAchievements(): Promise<Achievement[]> {
|
||||
this.logger.debug('Finding all achievements.');
|
||||
try {
|
||||
const achievements = Array.from(this.achievements.values());
|
||||
this.logger.info(`Found ${achievements.length} achievements.`);
|
||||
return achievements;
|
||||
} catch (error) {
|
||||
this.logger.error('Error finding all achievements:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findAchievementsByCategory(category: AchievementCategory): Promise<Achievement[]> {
|
||||
this.logger.debug(`Finding achievements by category: ${category}`);
|
||||
try {
|
||||
const achievements = Array.from(this.achievements.values())
|
||||
.filter(a => a.category === category);
|
||||
this.logger.info(`Found ${achievements.length} achievements for category: ${category}.`);
|
||||
return achievements;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding achievements by category ${category}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createAchievement(achievement: Achievement): Promise<Achievement> {
|
||||
this.logger.debug(`Creating achievement: ${achievement.id}`);
|
||||
try {
|
||||
if (this.achievements.has(achievement.id)) {
|
||||
this.logger.warn(`Achievement with ID ${achievement.id} already exists.`);
|
||||
throw new Error('Achievement with this ID already exists');
|
||||
}
|
||||
this.achievements.set(achievement.id, achievement);
|
||||
this.logger.info(`Achievement ${achievement.id} created successfully.`);
|
||||
return achievement;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error creating achievement ${achievement.id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// UserAchievement operations
|
||||
async findUserAchievementById(id: string): Promise<UserAchievement | null> {
|
||||
this.logger.debug(`Finding user achievement by id: ${id}`);
|
||||
try {
|
||||
const userAchievement = this.userAchievements.get(id) ?? null;
|
||||
if (userAchievement) {
|
||||
this.logger.info(`Found user achievement: ${id}.`);
|
||||
} else {
|
||||
this.logger.warn(`User achievement with id ${id} not found.`);
|
||||
}
|
||||
return userAchievement;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding user achievement by id ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findUserAchievementsByUserId(userId: string): Promise<UserAchievement[]> {
|
||||
this.logger.debug(`Finding user achievements by user id: ${userId}`);
|
||||
try {
|
||||
const userAchievements = Array.from(this.userAchievements.values())
|
||||
.filter(ua => ua.userId === userId);
|
||||
this.logger.info(`Found ${userAchievements.length} user achievements for user id: ${userId}.`);
|
||||
return userAchievements;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding user achievements by user id ${userId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findUserAchievementByUserAndAchievement(
|
||||
userId: string,
|
||||
achievementId: string
|
||||
): Promise<UserAchievement | null> {
|
||||
this.logger.debug(`Finding user achievement for user: ${userId}, achievement: ${achievementId}`);
|
||||
try {
|
||||
for (const ua of this.userAchievements.values()) {
|
||||
if (ua.userId === userId && ua.achievementId === achievementId) {
|
||||
this.logger.info(`Found user achievement for user: ${userId}, achievement: ${achievementId}.`);
|
||||
return ua;
|
||||
}
|
||||
}
|
||||
this.logger.warn(`User achievement for user ${userId}, achievement ${achievementId} not found.`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding user achievement for user ${userId}, achievement ${achievementId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async hasUserEarnedAchievement(userId: string, achievementId: string): Promise<boolean> {
|
||||
this.logger.debug(`Checking if user ${userId} earned achievement ${achievementId}`);
|
||||
try {
|
||||
const ua = await this.findUserAchievementByUserAndAchievement(userId, achievementId);
|
||||
const hasEarned = ua !== null && ua.isComplete();
|
||||
this.logger.debug(`User ${userId} earned achievement ${achievementId}: ${hasEarned}.`);
|
||||
return hasEarned;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error checking if user ${userId} earned achievement ${achievementId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement> {
|
||||
this.logger.debug(`Creating user achievement: ${userAchievement.id}`);
|
||||
try {
|
||||
if (this.userAchievements.has(userAchievement.id)) {
|
||||
this.logger.warn(`UserAchievement with ID ${userAchievement.id} already exists.`);
|
||||
throw new Error('UserAchievement with this ID already exists');
|
||||
}
|
||||
this.userAchievements.set(userAchievement.id, userAchievement);
|
||||
this.logger.info(`UserAchievement ${userAchievement.id} created successfully.`);
|
||||
return userAchievement;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error creating user achievement ${userAchievement.id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement> {
|
||||
this.logger.debug(`Updating user achievement: ${userAchievement.id}`);
|
||||
try {
|
||||
if (!this.userAchievements.has(userAchievement.id)) {
|
||||
this.logger.warn(`UserAchievement with ID ${userAchievement.id} not found for update.`);
|
||||
throw new Error('UserAchievement not found');
|
||||
}
|
||||
this.userAchievements.set(userAchievement.id, userAchievement);
|
||||
this.logger.info(`UserAchievement ${userAchievement.id} updated successfully.`);
|
||||
return userAchievement;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error updating user achievement ${userAchievement.id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Stats
|
||||
async getAchievementLeaderboard(limit: number): Promise<{ userId: string; points: number; count: number }[]> {
|
||||
this.logger.debug(`Getting achievement leaderboard with limit: ${limit}`);
|
||||
try {
|
||||
const userStats = new Map<string, { points: number; count: number }>();
|
||||
|
||||
for (const ua of this.userAchievements.values()) {
|
||||
if (!ua.isComplete()) continue;
|
||||
|
||||
const achievement = this.achievements.get(ua.achievementId);
|
||||
if (!achievement) {
|
||||
this.logger.warn(`Achievement ${ua.achievementId} not found while building leaderboard.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = userStats.get(ua.userId) ?? { points: 0, count: 0 };
|
||||
userStats.set(ua.userId, {
|
||||
points: existing.points + achievement.points,
|
||||
count: existing.count + 1,
|
||||
});
|
||||
}
|
||||
|
||||
const leaderboard = Array.from(userStats.entries())
|
||||
.map(([userId, stats]) => ({ userId, ...stats }))
|
||||
.sort((a, b) => b.points - a.points)
|
||||
.slice(0, limit);
|
||||
this.logger.info(`Generated achievement leaderboard with ${leaderboard.length} entries.`);
|
||||
return leaderboard;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error getting achievement leaderboard:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserAchievementStats(userId: string): Promise<{
|
||||
total: number;
|
||||
points: number;
|
||||
byCategory: Record<AchievementCategory, number>
|
||||
}> {
|
||||
this.logger.debug(`Getting achievement stats for user: ${userId}`);
|
||||
try {
|
||||
const userAchievements = await this.findUserAchievementsByUserId(userId);
|
||||
const completedAchievements = userAchievements.filter(ua => ua.isComplete());
|
||||
this.logger.debug(`Found ${completedAchievements.length} completed achievements for user ${userId}.`);
|
||||
|
||||
const byCategory: Record<AchievementCategory, number> = {
|
||||
driver: 0,
|
||||
steward: 0,
|
||||
admin: 0,
|
||||
community: 0,
|
||||
};
|
||||
|
||||
let points = 0;
|
||||
|
||||
for (const ua of completedAchievements) {
|
||||
const achievement = this.achievements.get(ua.achievementId);
|
||||
if (achievement) {
|
||||
points += achievement.points;
|
||||
byCategory[achievement.category]++;
|
||||
} else {
|
||||
this.logger.warn(`Achievement ${ua.achievementId} not found while calculating user stats for user ${userId}.`);
|
||||
}
|
||||
}
|
||||
|
||||
const stats = {
|
||||
total: completedAchievements.length,
|
||||
points,
|
||||
byCategory,
|
||||
};
|
||||
this.logger.info(`Generated achievement stats for user ${userId}:`, stats);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error getting user achievement stats for user ${userId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Test helpers
|
||||
clearUserAchievements(): void {
|
||||
this.logger.debug('Clearing all user achievements.');
|
||||
this.userAchievements.clear();
|
||||
this.logger.info('All user achievements cleared.');
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.logger.debug('Clearing all achievement data.');
|
||||
this.achievements.clear();
|
||||
this.userAchievements.clear();
|
||||
this.logger.info('All achievement data cleared.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Infrastructure: InMemorySponsorAccountRepository
|
||||
*
|
||||
* In-memory implementation of ISponsorAccountRepository for development/testing.
|
||||
*/
|
||||
|
||||
import type { ISponsorAccountRepository } from '../../domain/repositories/ISponsorAccountRepository';
|
||||
import type { SponsorAccount } from '../../domain/entities/SponsorAccount';
|
||||
import type { UserId } from '../../domain/value-objects/UserId';
|
||||
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
|
||||
export class InMemorySponsorAccountRepository implements ISponsorAccountRepository {
|
||||
private accounts: Map<string, SponsorAccount> = new Map();
|
||||
private readonly logger: ILogger;
|
||||
|
||||
constructor(logger: ILogger, seedData?: SponsorAccount[]) {
|
||||
this.logger = logger;
|
||||
this.logger.info('InMemorySponsorAccountRepository initialized.');
|
||||
if (seedData) {
|
||||
this.seed(seedData);
|
||||
}
|
||||
}
|
||||
|
||||
async save(account: SponsorAccount): Promise<void> {
|
||||
this.logger.debug(`Saving sponsor account: ${account.getId().value}`);
|
||||
try {
|
||||
this.accounts.set(account.getId().value, account);
|
||||
this.logger.info(`Sponsor account ${account.getId().value} saved successfully.`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error saving sponsor account ${account.getId().value}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: UserId): Promise<SponsorAccount | null> {
|
||||
this.logger.debug(`Finding sponsor account by id: ${id.value}`);
|
||||
try {
|
||||
const account = this.accounts.get(id.value) ?? null;
|
||||
if (account) {
|
||||
this.logger.info(`Found sponsor account: ${id.value}.`);
|
||||
} else {
|
||||
this.logger.warn(`Sponsor account with id ${id.value} not found.`);
|
||||
}
|
||||
return account;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding sponsor account by id ${id.value}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findBySponsorId(sponsorId: string): Promise<SponsorAccount | null> {
|
||||
this.logger.debug(`Finding sponsor account by sponsor id: ${sponsorId}`);
|
||||
try {
|
||||
const account = Array.from(this.accounts.values()).find(
|
||||
a => a.getSponsorId() === sponsorId
|
||||
) ?? null;
|
||||
if (account) {
|
||||
this.logger.info(`Found sponsor account for sponsor id: ${sponsorId}.`);
|
||||
} else {
|
||||
this.logger.warn(`Sponsor account for sponsor id ${sponsorId} not found.`);
|
||||
}
|
||||
return account;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding sponsor account by sponsor id ${sponsorId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<SponsorAccount | null> {
|
||||
this.logger.debug(`Finding sponsor account by email: ${email}`);
|
||||
try {
|
||||
const normalizedEmail = email.toLowerCase().trim();
|
||||
const account = Array.from(this.accounts.values()).find(
|
||||
a => a.getEmail().toLowerCase() === normalizedEmail
|
||||
) ?? null;
|
||||
if (account) {
|
||||
this.logger.info(`Found sponsor account by email: ${email}.`);
|
||||
} else {
|
||||
this.logger.warn(`Sponsor account with email ${email} not found.`);
|
||||
}
|
||||
return account;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding sponsor account by email ${email}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: UserId): Promise<void> {
|
||||
this.logger.debug(`Deleting sponsor account: ${id.value}`);
|
||||
try {
|
||||
if (this.accounts.delete(id.value)) {
|
||||
this.logger.info(`Sponsor account ${id.value} deleted successfully.`);
|
||||
} else {
|
||||
this.logger.warn(`Sponsor account with id ${id.value} not found for deletion.`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error deleting sponsor account ${id.value}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for testing
|
||||
clear(): void {
|
||||
this.logger.debug('Clearing all sponsor accounts.');
|
||||
this.accounts.clear();
|
||||
this.logger.info('All sponsor accounts cleared.');
|
||||
}
|
||||
|
||||
// Helper for seeding demo data
|
||||
seed(accounts: SponsorAccount[]): void {
|
||||
this.logger.debug(`Seeding ${accounts.length} sponsor accounts.`);
|
||||
try {
|
||||
accounts.forEach(a => {
|
||||
this.accounts.set(a.getId().value, a);
|
||||
this.logger.debug(`Seeded sponsor account: ${a.getId().value}.`);
|
||||
});
|
||||
this.logger.info(`Successfully seeded ${accounts.length} sponsor accounts.`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error seeding sponsor accounts:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryUserRatingRepository
|
||||
*
|
||||
* In-memory implementation of IUserRatingRepository
|
||||
*/
|
||||
|
||||
import { UserRating } from '../../domain/value-objects/UserRating';
|
||||
import type { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
|
||||
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
|
||||
export class InMemoryUserRatingRepository implements IUserRatingRepository {
|
||||
private ratings: Map<string, UserRating> = new Map();
|
||||
private readonly logger: ILogger;
|
||||
|
||||
constructor(logger: ILogger, seedData?: UserRating[]) {
|
||||
this.logger = logger;
|
||||
this.logger.info('InMemoryUserRatingRepository initialized.');
|
||||
if (seedData) {
|
||||
seedData.forEach(rating => this.ratings.set(rating.userId, rating));
|
||||
this.logger.debug(`Seeded ${seedData.length} user ratings.`);
|
||||
}
|
||||
}
|
||||
|
||||
async findByUserId(userId: string): Promise<UserRating | null> {
|
||||
this.logger.debug(`Finding user rating for user id: ${userId}`);
|
||||
try {
|
||||
const rating = this.ratings.get(userId) ?? null;
|
||||
if (rating) {
|
||||
this.logger.info(`Found user rating for user id: ${userId}.`);
|
||||
} else {
|
||||
this.logger.warn(`User rating for user id ${userId} not found.`);
|
||||
}
|
||||
return rating;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding user rating for user id ${userId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findByUserIds(userIds: string[]): Promise<UserRating[]> {
|
||||
this.logger.debug(`Finding user ratings for user ids: ${userIds.join(', ')}`);
|
||||
try {
|
||||
const results: UserRating[] = [];
|
||||
for (const userId of userIds) {
|
||||
const rating = this.ratings.get(userId);
|
||||
if (rating) {
|
||||
results.push(rating);
|
||||
} else {
|
||||
this.logger.warn(`User rating for user id ${userId} not found.`);
|
||||
}
|
||||
}
|
||||
this.logger.info(`Found ${results.length} user ratings for ${userIds.length} requested users.`);
|
||||
return results;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding user ratings for user ids ${userIds.join(', ')}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async save(rating: UserRating): Promise<UserRating> {
|
||||
this.logger.debug(`Saving user rating for user id: ${rating.userId}`);
|
||||
try {
|
||||
if (this.ratings.has(rating.userId)) {
|
||||
this.logger.debug(`Updating existing user rating for user id: ${rating.userId}.`);
|
||||
} else {
|
||||
this.logger.debug(`Creating new user rating for user id: ${rating.userId}.`);
|
||||
}
|
||||
this.ratings.set(rating.userId, rating);
|
||||
this.logger.info(`User rating for user id ${rating.userId} saved successfully.`);
|
||||
return rating;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error saving user rating for user id ${rating.userId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getTopDrivers(limit: number): Promise<UserRating[]> {
|
||||
this.logger.debug(`Getting top ${limit} drivers.`);
|
||||
try {
|
||||
const topDrivers = Array.from(this.ratings.values())
|
||||
.filter(r => r.driver.sampleSize > 0)
|
||||
.sort((a, b) => b.driver.value - a.driver.value)
|
||||
.slice(0, limit);
|
||||
this.logger.info(`Retrieved ${topDrivers.length} top drivers.`);
|
||||
return topDrivers;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error getting top drivers:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getTopTrusted(limit: number): Promise<UserRating[]> {
|
||||
this.logger.debug(`Getting top ${limit} trusted users.`);
|
||||
try {
|
||||
const topTrusted = Array.from(this.ratings.values())
|
||||
.filter(r => r.trust.sampleSize > 0)
|
||||
.sort((a, b) => b.trust.value - a.trust.value)
|
||||
.slice(0, limit);
|
||||
this.logger.info(`Retrieved ${topTrusted.length} top trusted users.`);
|
||||
return topTrusted;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error getting top trusted users:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getEligibleStewards(): Promise<UserRating[]> {
|
||||
this.logger.debug('Getting eligible stewards.');
|
||||
try {
|
||||
const eligibleStewards = Array.from(this.ratings.values())
|
||||
.filter(r => r.canBeSteward());
|
||||
this.logger.info(`Found ${eligibleStewards.length} eligible stewards.`);
|
||||
return eligibleStewards;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error getting eligible stewards:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findByDriverTier(tier: 'rookie' | 'amateur' | 'semi-pro' | 'pro' | 'elite'): Promise<UserRating[]> {
|
||||
this.logger.debug(`Finding user ratings by driver tier: ${tier}`);
|
||||
try {
|
||||
const ratingsByTier = Array.from(this.ratings.values())
|
||||
.filter(r => r.getDriverTier() === tier);
|
||||
this.logger.info(`Found ${ratingsByTier.length} user ratings for driver tier: ${tier}.`);
|
||||
return ratingsByTier;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding user ratings by driver tier ${tier}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(userId: string): Promise<void> {
|
||||
this.logger.debug(`Deleting user rating for user id: ${userId}`);
|
||||
try {
|
||||
if (this.ratings.delete(userId)) {
|
||||
this.logger.info(`User rating for user id ${userId} deleted successfully.`);
|
||||
} else {
|
||||
this.logger.warn(`User rating for user id ${userId} not found for deletion.`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error deleting user rating for user id ${userId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Test helper
|
||||
clear(): void {
|
||||
this.logger.debug('Clearing all user ratings.');
|
||||
this.ratings.clear();
|
||||
this.logger.info('All user ratings cleared.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* In-Memory User Repository
|
||||
*
|
||||
* Stores users in memory for demo/development purposes.
|
||||
*/
|
||||
|
||||
import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository';
|
||||
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
|
||||
export class InMemoryUserRepository implements IUserRepository {
|
||||
private users: Map<string, StoredUser> = new Map();
|
||||
private emailIndex: Map<string, string> = new Map(); // email -> userId
|
||||
private readonly logger: ILogger;
|
||||
|
||||
constructor(logger: ILogger, initialUsers: StoredUser[] = []) {
|
||||
this.logger = logger;
|
||||
this.logger.info('InMemoryUserRepository initialized.');
|
||||
for (const user of initialUsers) {
|
||||
this.users.set(user.id, user);
|
||||
this.emailIndex.set(user.email.toLowerCase(), user.id);
|
||||
this.logger.debug(`Seeded user: ${user.id} (${user.email}).`);
|
||||
}
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<StoredUser | null> {
|
||||
this.logger.debug(`Finding user by email: ${email}`);
|
||||
try {
|
||||
const userId = this.emailIndex.get(email.toLowerCase());
|
||||
if (!userId) {
|
||||
this.logger.warn(`User with email ${email} not found.`);
|
||||
return null;
|
||||
}
|
||||
const user = this.users.get(userId) ?? null;
|
||||
if (user) {
|
||||
this.logger.info(`Found user by email: ${email}.`);
|
||||
} else {
|
||||
this.logger.warn(`User with ID ${userId} (from email index) not found.`);
|
||||
}
|
||||
return user;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding user by email ${email}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<StoredUser | null> {
|
||||
this.logger.debug(`Finding user by id: ${id}`);
|
||||
try {
|
||||
const user = this.users.get(id) ?? null;
|
||||
if (user) {
|
||||
this.logger.info(`Found user: ${id}.`);
|
||||
} else {
|
||||
this.logger.warn(`User with id ${id} not found.`);
|
||||
}
|
||||
return user;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding user by id ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async create(user: StoredUser): Promise<StoredUser> {
|
||||
this.logger.debug(`Creating user: ${user.id} with email: ${user.email}`);
|
||||
try {
|
||||
if (this.emailIndex.has(user.email.toLowerCase())) {
|
||||
this.logger.warn(`Email ${user.email} already exists.`);
|
||||
throw new Error('Email already exists');
|
||||
}
|
||||
this.users.set(user.id, user);
|
||||
this.emailIndex.set(user.email.toLowerCase(), user.id);
|
||||
this.logger.info(`User ${user.id} (${user.email}) created successfully.`);
|
||||
return user;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error creating user ${user.id} (${user.email}):`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(user: StoredUser): Promise<StoredUser> {
|
||||
this.logger.debug(`Updating user: ${user.id} with email: ${user.email}`);
|
||||
try {
|
||||
const existing = this.users.get(user.id);
|
||||
if (!existing) {
|
||||
this.logger.warn(`User with ID ${user.id} not found for update.`);
|
||||
throw new Error('User not found');
|
||||
}
|
||||
// If email changed, update index
|
||||
if (existing.email.toLowerCase() !== user.email.toLowerCase()) {
|
||||
if (this.emailIndex.has(user.email.toLowerCase()) && this.emailIndex.get(user.email.toLowerCase()) !== user.id) {
|
||||
this.logger.warn(`Cannot update user ${user.id} to email ${user.email} as it's already taken.`);
|
||||
throw new Error('Email already exists for another user');
|
||||
}
|
||||
this.logger.debug(`Updating email index from ${existing.email} to ${user.email}.`);
|
||||
this.emailIndex.delete(existing.email.toLowerCase());
|
||||
this.emailIndex.set(user.email.toLowerCase(), user.id);
|
||||
}
|
||||
this.users.set(user.id, user);
|
||||
this.logger.info(`User ${user.id} (${user.email}) updated successfully.`);
|
||||
return user;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error updating user ${user.id} (${user.email}):`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async emailExists(email: string): Promise<boolean> {
|
||||
this.logger.debug(`Checking existence of email: ${email}`);
|
||||
try {
|
||||
const exists = this.emailIndex.has(email.toLowerCase());
|
||||
this.logger.debug(`Email ${email} exists: ${exists}.`);
|
||||
return exists;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error checking existence of email ${email}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { AuthenticatedUserDTO } from '../../application/dto/AuthenticatedUserDTO';
|
||||
import type { AuthSessionDTO } from '../../application/dto/AuthSessionDTO';
|
||||
import type { IdentitySessionPort } from '../../application/ports/IdentitySessionPort';
|
||||
|
||||
const SESSION_COOKIE = 'gp_demo_session';
|
||||
|
||||
function parseCookieValue(raw: string | undefined): AuthSessionDTO | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as AuthSessionDTO;
|
||||
if (!parsed.expiresAt || Date.now() > parsed.expiresAt) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function serializeSession(session: AuthSessionDTO): string {
|
||||
return JSON.stringify(session);
|
||||
}
|
||||
|
||||
export class CookieIdentitySessionAdapter implements IdentitySessionPort {
|
||||
async getCurrentSession(): Promise<AuthSessionDTO | null> {
|
||||
const store = await cookies();
|
||||
const raw = store.get(SESSION_COOKIE)?.value;
|
||||
return parseCookieValue(raw);
|
||||
}
|
||||
|
||||
async createSession(user: AuthenticatedUserDTO): Promise<AuthSessionDTO> {
|
||||
const now = Date.now();
|
||||
const expiresAt = now + 24 * 60 * 60 * 1000;
|
||||
|
||||
const session: AuthSessionDTO = {
|
||||
user,
|
||||
issuedAt: now,
|
||||
expiresAt,
|
||||
token: randomUUID(),
|
||||
};
|
||||
|
||||
const store = await cookies();
|
||||
store.set(SESSION_COOKIE, serializeSession(session), {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async clearSession(): Promise<void> {
|
||||
const store = await cookies();
|
||||
store.delete(SESSION_COOKIE);
|
||||
}
|
||||
}
|
||||
15
core/identity/package.json
Normal file
15
core/identity/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@gridpilot/identity",
|
||||
"version": "0.1.0",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./domain/*": "./domain/*",
|
||||
"./application/*": "./application/*",
|
||||
"./infrastructure/*": "./infrastructure/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
9
core/identity/tsconfig.json
Normal file
9
core/identity/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"declarationMap": false
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user