team rating
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryDriverStatsRepository
|
||||
*
|
||||
* In-memory implementation of IDriverStatsRepository.
|
||||
* Stores computed driver statistics for caching and frontend queries.
|
||||
*/
|
||||
|
||||
import type { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository';
|
||||
import type { DriverStats } from '@core/racing/application/use-cases/IDriverStatsUseCase';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
export class InMemoryDriverStatsRepository implements IDriverStatsRepository {
|
||||
private stats = new Map<string, DriverStats>();
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger.info('[InMemoryDriverStatsRepository] Initialized.');
|
||||
}
|
||||
|
||||
async getDriverStats(driverId: string): Promise<DriverStats | null> {
|
||||
this.logger.debug(`[InMemoryDriverStatsRepository] Getting stats for driver: ${driverId}`);
|
||||
return this.stats.get(driverId) ?? null;
|
||||
}
|
||||
|
||||
getDriverStatsSync(driverId: string): DriverStats | null {
|
||||
this.logger.debug(`[InMemoryDriverStatsRepository] Getting stats (sync) for driver: ${driverId}`);
|
||||
return this.stats.get(driverId) ?? null;
|
||||
}
|
||||
|
||||
async saveDriverStats(driverId: string, stats: DriverStats): Promise<void> {
|
||||
this.logger.debug(`[InMemoryDriverStatsRepository] Saving stats for driver: ${driverId}`);
|
||||
this.stats.set(driverId, stats);
|
||||
}
|
||||
|
||||
async getAllStats(): Promise<Map<string, DriverStats>> {
|
||||
this.logger.debug('[InMemoryDriverStatsRepository] Getting all stats');
|
||||
return new Map(this.stats);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.logger.info('[InMemoryDriverStatsRepository] Clearing all stats');
|
||||
this.stats.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryTeamStatsRepository
|
||||
*
|
||||
* In-memory implementation of ITeamStatsRepository.
|
||||
* Stores computed team statistics for caching and frontend queries.
|
||||
*/
|
||||
|
||||
import type { ITeamStatsRepository, TeamStats } from '@core/racing/domain/repositories/ITeamStatsRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
export class InMemoryTeamStatsRepository implements ITeamStatsRepository {
|
||||
private stats = new Map<string, TeamStats>();
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger.info('[InMemoryTeamStatsRepository] Initialized.');
|
||||
}
|
||||
|
||||
async getTeamStats(teamId: string): Promise<TeamStats | null> {
|
||||
this.logger.debug(`[InMemoryTeamStatsRepository] Getting stats for team: ${teamId}`);
|
||||
return this.stats.get(teamId) ?? null;
|
||||
}
|
||||
|
||||
getTeamStatsSync(teamId: string): TeamStats | null {
|
||||
this.logger.debug(`[InMemoryTeamStatsRepository] Getting stats (sync) for team: ${teamId}`);
|
||||
return this.stats.get(teamId) ?? null;
|
||||
}
|
||||
|
||||
async saveTeamStats(teamId: string, stats: TeamStats): Promise<void> {
|
||||
this.logger.debug(`[InMemoryTeamStatsRepository] Saving stats for team: ${teamId}`);
|
||||
this.stats.set(teamId, stats);
|
||||
}
|
||||
|
||||
async getAllStats(): Promise<Map<string, TeamStats>> {
|
||||
this.logger.debug('[InMemoryTeamStatsRepository] Getting all stats');
|
||||
return new Map(this.stats);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.logger.info('[InMemoryTeamStatsRepository] Clearing all stats');
|
||||
this.stats.clear();
|
||||
}
|
||||
}
|
||||
70
adapters/racing/persistence/media/InMemoryMediaRepository.ts
Normal file
70
adapters/racing/persistence/media/InMemoryMediaRepository.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryMediaRepository
|
||||
*
|
||||
* In-memory implementation of IMediaRepository.
|
||||
* Stores URLs for static media assets like logos and images.
|
||||
*/
|
||||
|
||||
import type { IMediaRepository } from '@core/racing/domain/repositories/IMediaRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
export class InMemoryMediaRepository implements IMediaRepository {
|
||||
private driverAvatars = new Map<string, string>();
|
||||
private teamLogos = new Map<string, string>();
|
||||
private trackImages = new Map<string, string>();
|
||||
private categoryIcons = new Map<string, string>();
|
||||
private sponsorLogos = new Map<string, string>();
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger.info('[InMemoryMediaRepository] Initialized.');
|
||||
}
|
||||
|
||||
async getDriverAvatar(driverId: string): Promise<string | null> {
|
||||
return this.driverAvatars.get(driverId) ?? null;
|
||||
}
|
||||
|
||||
async getTeamLogo(teamId: string): Promise<string | null> {
|
||||
return this.teamLogos.get(teamId) ?? null;
|
||||
}
|
||||
|
||||
async getTrackImage(trackId: string): Promise<string | null> {
|
||||
return this.trackImages.get(trackId) ?? null;
|
||||
}
|
||||
|
||||
async getCategoryIcon(categoryId: string): Promise<string | null> {
|
||||
return this.categoryIcons.get(categoryId) ?? null;
|
||||
}
|
||||
|
||||
async getSponsorLogo(sponsorId: string): Promise<string | null> {
|
||||
return this.sponsorLogos.get(sponsorId) ?? null;
|
||||
}
|
||||
|
||||
// Helper methods for seeding
|
||||
setDriverAvatar(driverId: string, url: string): void {
|
||||
this.driverAvatars.set(driverId, url);
|
||||
}
|
||||
|
||||
setTeamLogo(teamId: string, url: string): void {
|
||||
this.teamLogos.set(teamId, url);
|
||||
}
|
||||
|
||||
setTrackImage(trackId: string, url: string): void {
|
||||
this.trackImages.set(trackId, url);
|
||||
}
|
||||
|
||||
setCategoryIcon(categoryId: string, url: string): void {
|
||||
this.categoryIcons.set(categoryId, url);
|
||||
}
|
||||
|
||||
setSponsorLogo(sponsorId: string, url: string): void {
|
||||
this.sponsorLogos.set(sponsorId, url);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.driverAvatars.clear();
|
||||
this.teamLogos.clear();
|
||||
this.trackImages.clear();
|
||||
this.categoryIcons.clear();
|
||||
this.sponsorLogos.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* ORM Entity: TeamRatingEvent
|
||||
*
|
||||
* Stores team rating events in the ledger with indexes for efficient querying
|
||||
* by teamId and ordering by occurredAt for snapshot computation.
|
||||
*/
|
||||
@Entity({ name: 'team_rating_events' })
|
||||
@Index(['teamId', 'occurredAt', 'createdAt', 'id'], { unique: true })
|
||||
export class TeamRatingEventOrmEntity {
|
||||
@PrimaryColumn({ type: 'text' })
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'text' })
|
||||
teamId!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'text' })
|
||||
dimension!: string;
|
||||
|
||||
@Column({ type: 'double precision' })
|
||||
delta!: number;
|
||||
|
||||
@Column({ type: 'double precision', nullable: true })
|
||||
weight?: number;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'timestamptz' })
|
||||
occurredAt!: Date;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
source!: {
|
||||
type: 'race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment';
|
||||
id?: string;
|
||||
};
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
reason!: {
|
||||
code: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
visibility!: {
|
||||
public: boolean;
|
||||
};
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
version!: number;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* ORM Entity: TeamRating
|
||||
*
|
||||
* Stores the current rating snapshot per team.
|
||||
* Uses JSONB for dimension data to keep schema flexible.
|
||||
*/
|
||||
@Entity({ name: 'team_ratings' })
|
||||
@Index(['teamId'], { unique: true })
|
||||
export class TeamRatingOrmEntity {
|
||||
@PrimaryColumn({ type: 'text' })
|
||||
teamId!: string;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
driving!: {
|
||||
value: number;
|
||||
confidence: number;
|
||||
sampleSize: number;
|
||||
trend: 'rising' | 'stable' | 'falling';
|
||||
lastUpdated: Date;
|
||||
};
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
adminTrust!: {
|
||||
value: number;
|
||||
confidence: number;
|
||||
sampleSize: number;
|
||||
trend: 'rising' | 'stable' | 'falling';
|
||||
lastUpdated: Date;
|
||||
};
|
||||
|
||||
@Column({ type: 'double precision' })
|
||||
overall!: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
calculatorVersion?: string;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { TeamRatingEventOrmMapper } from './TeamRatingEventOrmMapper';
|
||||
import { TeamRatingEventOrmEntity } from '../entities/TeamRatingEventOrmEntity';
|
||||
import { TeamRatingEvent } from '@core/racing/domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '@core/racing/domain/value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '@core/racing/domain/value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '@core/racing/domain/value-objects/TeamRatingDelta';
|
||||
|
||||
describe('TeamRatingEventOrmMapper', () => {
|
||||
const validEntityProps = {
|
||||
id: '123e4567-e89b-12d3-a456-426614174000',
|
||||
teamId: 'team-123',
|
||||
dimension: 'driving',
|
||||
delta: 10,
|
||||
weight: 1,
|
||||
occurredAt: new Date('2024-01-01T00:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
source: { type: 'race' as const, id: 'race-456' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st in race' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const validDomainProps = {
|
||||
id: TeamRatingEventId.create('123e4567-e89b-12d3-a456-426614174000'),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
weight: 1,
|
||||
occurredAt: new Date('2024-01-01T00:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
source: { type: 'race' as const, id: 'race-456' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st in race' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
};
|
||||
|
||||
describe('toDomain', () => {
|
||||
it('should convert ORM entity to domain entity', () => {
|
||||
const entity = Object.assign(new TeamRatingEventOrmEntity(), validEntityProps);
|
||||
const domain = TeamRatingEventOrmMapper.toDomain(entity);
|
||||
|
||||
expect(domain.id.value).toBe(validEntityProps.id);
|
||||
expect(domain.teamId).toBe(validEntityProps.teamId);
|
||||
expect(domain.dimension.value).toBe(validEntityProps.dimension);
|
||||
expect(domain.delta.value).toBe(validEntityProps.delta);
|
||||
expect(domain.weight).toBe(validEntityProps.weight);
|
||||
expect(domain.occurredAt).toEqual(validEntityProps.occurredAt);
|
||||
expect(domain.createdAt).toEqual(validEntityProps.createdAt);
|
||||
expect(domain.source).toEqual(validEntityProps.source);
|
||||
expect(domain.reason).toEqual(validEntityProps.reason);
|
||||
expect(domain.visibility).toEqual(validEntityProps.visibility);
|
||||
expect(domain.version).toBe(validEntityProps.version);
|
||||
});
|
||||
|
||||
it('should handle optional weight', () => {
|
||||
const entity = Object.assign(new TeamRatingEventOrmEntity(), {
|
||||
...validEntityProps,
|
||||
weight: undefined,
|
||||
});
|
||||
const domain = TeamRatingEventOrmMapper.toDomain(entity);
|
||||
|
||||
expect(domain.weight).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle null weight', () => {
|
||||
const entity = Object.assign(new TeamRatingEventOrmEntity(), {
|
||||
...validEntityProps,
|
||||
weight: null,
|
||||
});
|
||||
const domain = TeamRatingEventOrmMapper.toDomain(entity);
|
||||
|
||||
expect(domain.weight).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toOrmEntity', () => {
|
||||
it('should convert domain entity to ORM entity', () => {
|
||||
const domain = TeamRatingEvent.create(validDomainProps);
|
||||
const entity = TeamRatingEventOrmMapper.toOrmEntity(domain);
|
||||
|
||||
expect(entity.id).toBe(validDomainProps.id.value);
|
||||
expect(entity.teamId).toBe(validDomainProps.teamId);
|
||||
expect(entity.dimension).toBe(validDomainProps.dimension.value);
|
||||
expect(entity.delta).toBe(validDomainProps.delta.value);
|
||||
expect(entity.weight).toBe(validDomainProps.weight);
|
||||
expect(entity.occurredAt).toEqual(validDomainProps.occurredAt);
|
||||
expect(entity.createdAt).toEqual(validDomainProps.createdAt);
|
||||
expect(entity.source).toEqual(validDomainProps.source);
|
||||
expect(entity.reason).toEqual(validDomainProps.reason);
|
||||
expect(entity.visibility).toEqual(validDomainProps.visibility);
|
||||
expect(entity.version).toBe(validDomainProps.version);
|
||||
});
|
||||
|
||||
it('should handle domain entity without weight', () => {
|
||||
const props = { ...validDomainProps };
|
||||
delete (props as any).weight;
|
||||
const domain = TeamRatingEvent.create(props);
|
||||
const entity = TeamRatingEventOrmMapper.toOrmEntity(domain);
|
||||
|
||||
expect(entity.weight).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { TeamRatingEvent } from '@core/racing/domain/entities/TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '@core/racing/domain/value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '@core/racing/domain/value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '@core/racing/domain/value-objects/TeamRatingDelta';
|
||||
import { TeamRatingEventOrmEntity } from '../entities/TeamRatingEventOrmEntity';
|
||||
|
||||
/**
|
||||
* Mapper: TeamRatingEventOrmMapper
|
||||
*
|
||||
* Converts between TeamRatingEvent domain entity and TeamRatingEventOrmEntity.
|
||||
*/
|
||||
export class TeamRatingEventOrmMapper {
|
||||
/**
|
||||
* Convert ORM entity to domain entity
|
||||
*/
|
||||
static toDomain(entity: TeamRatingEventOrmEntity): TeamRatingEvent {
|
||||
const props: any = {
|
||||
id: TeamRatingEventId.create(entity.id),
|
||||
teamId: entity.teamId,
|
||||
dimension: TeamRatingDimensionKey.create(entity.dimension),
|
||||
delta: TeamRatingDelta.create(entity.delta),
|
||||
occurredAt: entity.occurredAt,
|
||||
createdAt: entity.createdAt,
|
||||
source: entity.source,
|
||||
reason: entity.reason,
|
||||
visibility: entity.visibility,
|
||||
version: entity.version,
|
||||
};
|
||||
|
||||
if (entity.weight !== undefined && entity.weight !== null) {
|
||||
props.weight = entity.weight;
|
||||
}
|
||||
|
||||
return TeamRatingEvent.rehydrate(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert domain entity to ORM entity
|
||||
*/
|
||||
static toOrmEntity(domain: TeamRatingEvent): TeamRatingEventOrmEntity {
|
||||
const entity = new TeamRatingEventOrmEntity();
|
||||
entity.id = domain.id.value;
|
||||
entity.teamId = domain.teamId;
|
||||
entity.dimension = domain.dimension.value;
|
||||
entity.delta = domain.delta.value;
|
||||
if (domain.weight !== undefined) {
|
||||
entity.weight = domain.weight;
|
||||
}
|
||||
entity.occurredAt = domain.occurredAt;
|
||||
entity.createdAt = domain.createdAt;
|
||||
entity.source = domain.source;
|
||||
entity.reason = domain.reason;
|
||||
entity.visibility = domain.visibility;
|
||||
entity.version = domain.version;
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { TeamRatingOrmMapper } from './TeamRatingOrmMapper';
|
||||
import { TeamRatingOrmEntity } from '../entities/TeamRatingOrmEntity';
|
||||
import { TeamRatingValue } from '@core/racing/domain/value-objects/TeamRatingValue';
|
||||
import { TeamRatingSnapshot } from '@core/racing/domain/services/TeamRatingSnapshotCalculator';
|
||||
|
||||
describe('TeamRatingOrmMapper', () => {
|
||||
const validEntityProps = {
|
||||
teamId: 'team-123',
|
||||
driving: {
|
||||
value: 65,
|
||||
confidence: 0.8,
|
||||
sampleSize: 10,
|
||||
trend: 'rising' as const,
|
||||
lastUpdated: new Date('2024-01-01T00:00:00Z'),
|
||||
},
|
||||
adminTrust: {
|
||||
value: 55,
|
||||
confidence: 0.8,
|
||||
sampleSize: 10,
|
||||
trend: 'stable' as const,
|
||||
lastUpdated: new Date('2024-01-01T00:00:00Z'),
|
||||
},
|
||||
overall: 62,
|
||||
calculatorVersion: '1.0',
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2024-01-01T00:00:00Z'),
|
||||
};
|
||||
|
||||
const validSnapshotProps: TeamRatingSnapshot = {
|
||||
teamId: 'team-123',
|
||||
driving: TeamRatingValue.create(65),
|
||||
adminTrust: TeamRatingValue.create(55),
|
||||
overall: 62,
|
||||
lastUpdated: new Date('2024-01-01T00:00:00Z'),
|
||||
eventCount: 10,
|
||||
};
|
||||
|
||||
describe('toDomain', () => {
|
||||
it('should convert ORM entity to domain snapshot', () => {
|
||||
const entity = Object.assign(new TeamRatingOrmEntity(), validEntityProps);
|
||||
const snapshot = TeamRatingOrmMapper.toDomain(entity);
|
||||
|
||||
expect(snapshot.teamId).toBe(validEntityProps.teamId);
|
||||
expect(snapshot.driving.value).toBe(validEntityProps.driving.value);
|
||||
expect(snapshot.adminTrust.value).toBe(validEntityProps.adminTrust.value);
|
||||
expect(snapshot.overall).toBe(validEntityProps.overall);
|
||||
expect(snapshot.lastUpdated).toEqual(validEntityProps.updatedAt);
|
||||
expect(snapshot.eventCount).toBe(validEntityProps.driving.sampleSize + validEntityProps.adminTrust.sampleSize);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toOrmEntity', () => {
|
||||
it('should convert domain snapshot to ORM entity', () => {
|
||||
const entity = TeamRatingOrmMapper.toOrmEntity(validSnapshotProps);
|
||||
|
||||
expect(entity.teamId).toBe(validSnapshotProps.teamId);
|
||||
expect(entity.driving.value).toBe(validSnapshotProps.driving.value);
|
||||
expect(entity.adminTrust.value).toBe(validSnapshotProps.adminTrust.value);
|
||||
expect(entity.overall).toBe(validSnapshotProps.overall);
|
||||
expect(entity.calculatorVersion).toBe('1.0');
|
||||
expect(entity.createdAt).toEqual(validSnapshotProps.lastUpdated);
|
||||
expect(entity.updatedAt).toEqual(validSnapshotProps.lastUpdated);
|
||||
|
||||
// Check calculated confidence
|
||||
expect(entity.driving.confidence).toBeGreaterThan(0);
|
||||
expect(entity.driving.confidence).toBeLessThan(1);
|
||||
expect(entity.adminTrust.confidence).toBeGreaterThan(0);
|
||||
expect(entity.adminTrust.confidence).toBeLessThan(1);
|
||||
|
||||
// Check sample size
|
||||
expect(entity.driving.sampleSize).toBe(validSnapshotProps.eventCount);
|
||||
expect(entity.adminTrust.sampleSize).toBe(validSnapshotProps.eventCount);
|
||||
});
|
||||
|
||||
it('should calculate correct trend for rising driving rating', () => {
|
||||
const snapshot: TeamRatingSnapshot = {
|
||||
teamId: 'team-123',
|
||||
driving: TeamRatingValue.create(65),
|
||||
adminTrust: TeamRatingValue.create(50),
|
||||
overall: 60,
|
||||
lastUpdated: new Date('2024-01-01T00:00:00Z'),
|
||||
eventCount: 5,
|
||||
};
|
||||
|
||||
const entity = TeamRatingOrmMapper.toOrmEntity(snapshot);
|
||||
expect(entity.driving.trend).toBe('rising');
|
||||
});
|
||||
|
||||
it('should calculate correct trend for falling driving rating', () => {
|
||||
const snapshot: TeamRatingSnapshot = {
|
||||
teamId: 'team-123',
|
||||
driving: TeamRatingValue.create(35),
|
||||
adminTrust: TeamRatingValue.create(50),
|
||||
overall: 40,
|
||||
lastUpdated: new Date('2024-01-01T00:00:00Z'),
|
||||
eventCount: 5,
|
||||
};
|
||||
|
||||
const entity = TeamRatingOrmMapper.toOrmEntity(snapshot);
|
||||
expect(entity.driving.trend).toBe('falling');
|
||||
});
|
||||
|
||||
it('should calculate correct trend for stable driving rating', () => {
|
||||
const snapshot: TeamRatingSnapshot = {
|
||||
teamId: 'team-123',
|
||||
driving: TeamRatingValue.create(50),
|
||||
adminTrust: TeamRatingValue.create(50),
|
||||
overall: 50,
|
||||
lastUpdated: new Date('2024-01-01T00:00:00Z'),
|
||||
eventCount: 5,
|
||||
};
|
||||
|
||||
const entity = TeamRatingOrmMapper.toOrmEntity(snapshot);
|
||||
expect(entity.driving.trend).toBe('stable');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { TeamRatingValue } from '@core/racing/domain/value-objects/TeamRatingValue';
|
||||
import { TeamRatingSnapshot } from '@core/racing/domain/services/TeamRatingSnapshotCalculator';
|
||||
import { TeamRatingOrmEntity } from '../entities/TeamRatingOrmEntity';
|
||||
|
||||
/**
|
||||
* Mapper: TeamRatingOrmMapper
|
||||
*
|
||||
* Converts between TeamRatingSnapshot domain value and TeamRatingOrmEntity.
|
||||
*/
|
||||
export class TeamRatingOrmMapper {
|
||||
/**
|
||||
* Convert ORM entity to domain snapshot
|
||||
*/
|
||||
static toDomain(entity: TeamRatingOrmEntity): TeamRatingSnapshot {
|
||||
return {
|
||||
teamId: entity.teamId,
|
||||
driving: TeamRatingValue.create(entity.driving.value),
|
||||
adminTrust: TeamRatingValue.create(entity.adminTrust.value),
|
||||
overall: entity.overall,
|
||||
lastUpdated: entity.updatedAt,
|
||||
eventCount: entity.driving.sampleSize + entity.adminTrust.sampleSize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert domain snapshot to ORM entity
|
||||
*/
|
||||
static toOrmEntity(snapshot: TeamRatingSnapshot): TeamRatingOrmEntity {
|
||||
const entity = new TeamRatingOrmEntity();
|
||||
entity.teamId = snapshot.teamId;
|
||||
|
||||
// Calculate confidence based on event count
|
||||
const confidence = 1 - Math.exp(-snapshot.eventCount / 20);
|
||||
|
||||
entity.driving = {
|
||||
value: snapshot.driving.value,
|
||||
confidence: confidence,
|
||||
sampleSize: snapshot.eventCount,
|
||||
trend: snapshot.driving.value > 50 ? 'rising' : snapshot.driving.value < 50 ? 'falling' : 'stable',
|
||||
lastUpdated: snapshot.lastUpdated,
|
||||
};
|
||||
|
||||
entity.adminTrust = {
|
||||
value: snapshot.adminTrust.value,
|
||||
confidence: confidence,
|
||||
sampleSize: snapshot.eventCount,
|
||||
trend: snapshot.adminTrust.value > 50 ? 'rising' : snapshot.adminTrust.value < 50 ? 'falling' : 'stable',
|
||||
lastUpdated: snapshot.lastUpdated,
|
||||
};
|
||||
|
||||
entity.overall = snapshot.overall;
|
||||
entity.calculatorVersion = '1.0';
|
||||
entity.createdAt = snapshot.lastUpdated;
|
||||
entity.updatedAt = snapshot.lastUpdated;
|
||||
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import type { ITeamRatingEventRepository, FindByTeamIdOptions, PaginatedQueryOptions, PaginatedResult } from '@core/racing/domain/repositories/ITeamRatingEventRepository';
|
||||
import type { TeamRatingEvent } from '@core/racing/domain/entities/TeamRatingEvent';
|
||||
import type { TeamRatingEventId } from '@core/racing/domain/value-objects/TeamRatingEventId';
|
||||
|
||||
import { TeamRatingEventOrmEntity } from '../entities/TeamRatingEventOrmEntity';
|
||||
import { TeamRatingEventOrmMapper } from '../mappers/TeamRatingEventOrmMapper';
|
||||
|
||||
/**
|
||||
* TypeORM Implementation: ITeamRatingEventRepository
|
||||
*
|
||||
* Persists team rating events in the ledger with efficient querying by teamId
|
||||
* and ordering for snapshot computation.
|
||||
*/
|
||||
export class TypeOrmTeamRatingEventRepository implements ITeamRatingEventRepository {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async save(event: TeamRatingEvent): Promise<TeamRatingEvent> {
|
||||
const repo = this.dataSource.getRepository(TeamRatingEventOrmEntity);
|
||||
const entity = TeamRatingEventOrmMapper.toOrmEntity(event);
|
||||
await repo.save(entity);
|
||||
return event;
|
||||
}
|
||||
|
||||
async findByTeamId(teamId: string, options?: FindByTeamIdOptions): Promise<TeamRatingEvent[]> {
|
||||
const repo = this.dataSource.getRepository(TeamRatingEventOrmEntity);
|
||||
|
||||
const query = repo
|
||||
.createQueryBuilder('event')
|
||||
.where('event.teamId = :teamId', { teamId })
|
||||
.orderBy('event.occurredAt', 'ASC')
|
||||
.addOrderBy('event.createdAt', 'ASC')
|
||||
.addOrderBy('event.id', 'ASC');
|
||||
|
||||
if (options?.afterId) {
|
||||
query.andWhere('event.id > :afterId', { afterId: options.afterId.value });
|
||||
}
|
||||
|
||||
if (options?.limit) {
|
||||
query.limit(options.limit);
|
||||
}
|
||||
|
||||
const entities = await query.getMany();
|
||||
return entities.map(entity => TeamRatingEventOrmMapper.toDomain(entity));
|
||||
}
|
||||
|
||||
async findByIds(ids: TeamRatingEventId[]): Promise<TeamRatingEvent[]> {
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const repo = this.dataSource.getRepository(TeamRatingEventOrmEntity);
|
||||
const idValues = ids.map(id => id.value);
|
||||
|
||||
const entities = await repo
|
||||
.createQueryBuilder('event')
|
||||
.where('event.id IN (:...ids)', { ids: idValues })
|
||||
.orderBy('event.occurredAt', 'ASC')
|
||||
.addOrderBy('event.createdAt', 'ASC')
|
||||
.addOrderBy('event.id', 'ASC')
|
||||
.getMany();
|
||||
|
||||
return entities.map(entity => TeamRatingEventOrmMapper.toDomain(entity));
|
||||
}
|
||||
|
||||
async getAllByTeamId(teamId: string): Promise<TeamRatingEvent[]> {
|
||||
const repo = this.dataSource.getRepository(TeamRatingEventOrmEntity);
|
||||
|
||||
const entities = await repo
|
||||
.createQueryBuilder('event')
|
||||
.where('event.teamId = :teamId', { teamId })
|
||||
.orderBy('event.occurredAt', 'ASC')
|
||||
.addOrderBy('event.createdAt', 'ASC')
|
||||
.addOrderBy('event.id', 'ASC')
|
||||
.getMany();
|
||||
|
||||
return entities.map(entity => TeamRatingEventOrmMapper.toDomain(entity));
|
||||
}
|
||||
|
||||
async findEventsPaginated(teamId: string, options?: PaginatedQueryOptions): Promise<PaginatedResult<TeamRatingEvent>> {
|
||||
const repo = this.dataSource.getRepository(TeamRatingEventOrmEntity);
|
||||
|
||||
const query = repo
|
||||
.createQueryBuilder('event')
|
||||
.where('event.teamId = :teamId', { teamId });
|
||||
|
||||
// Apply filters
|
||||
if (options?.filter) {
|
||||
const filter = options.filter;
|
||||
|
||||
if (filter.dimensions) {
|
||||
query.andWhere('event.dimension IN (:...dimensions)', { dimensions: filter.dimensions });
|
||||
}
|
||||
|
||||
if (filter.sourceTypes) {
|
||||
query.andWhere('event.source.type IN (:...sourceTypes)', { sourceTypes: filter.sourceTypes });
|
||||
}
|
||||
|
||||
if (filter.from) {
|
||||
query.andWhere('event.occurredAt >= :from', { from: filter.from });
|
||||
}
|
||||
|
||||
if (filter.to) {
|
||||
query.andWhere('event.occurredAt <= :to', { to: filter.to });
|
||||
}
|
||||
|
||||
if (filter.reasonCodes) {
|
||||
query.andWhere('event.reason.code IN (:...reasonCodes)', { reasonCodes: filter.reasonCodes });
|
||||
}
|
||||
|
||||
if (filter.visibility) {
|
||||
query.andWhere('event.visibility.public = :visibility', { visibility: filter.visibility === 'public' });
|
||||
}
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const total = await query.getCount();
|
||||
|
||||
// Apply pagination
|
||||
const limit = options?.limit ?? 10;
|
||||
const offset = options?.offset ?? 0;
|
||||
|
||||
query
|
||||
.orderBy('event.occurredAt', 'ASC')
|
||||
.addOrderBy('event.createdAt', 'ASC')
|
||||
.addOrderBy('event.id', 'ASC')
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
const entities = await query.getMany();
|
||||
const items = entities.map(entity => TeamRatingEventOrmMapper.toDomain(entity));
|
||||
|
||||
const hasMore = offset + limit < total;
|
||||
const nextOffset = hasMore ? offset + limit : undefined;
|
||||
|
||||
const result: PaginatedResult<TeamRatingEvent> = {
|
||||
items,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
hasMore
|
||||
};
|
||||
|
||||
if (nextOffset !== undefined) {
|
||||
result.nextOffset = nextOffset;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import type { ITeamRatingRepository } from '@core/racing/domain/repositories/ITeamRatingRepository';
|
||||
import type { TeamRatingSnapshot } from '@core/racing/domain/services/TeamRatingSnapshotCalculator';
|
||||
|
||||
import { TeamRatingOrmEntity } from '../entities/TeamRatingOrmEntity';
|
||||
import { TeamRatingOrmMapper } from '../mappers/TeamRatingOrmMapper';
|
||||
|
||||
/**
|
||||
* TypeORM Implementation: ITeamRatingRepository
|
||||
*
|
||||
* Persists and retrieves TeamRating snapshots for fast reads.
|
||||
*/
|
||||
export class TypeOrmTeamRatingRepository implements ITeamRatingRepository {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async findByTeamId(teamId: string): Promise<TeamRatingSnapshot | null> {
|
||||
const repo = this.dataSource.getRepository(TeamRatingOrmEntity);
|
||||
const entity = await repo.findOne({ where: { teamId } });
|
||||
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return TeamRatingOrmMapper.toDomain(entity);
|
||||
}
|
||||
|
||||
async save(teamRating: TeamRatingSnapshot): Promise<TeamRatingSnapshot> {
|
||||
const repo = this.dataSource.getRepository(TeamRatingOrmEntity);
|
||||
const entity = TeamRatingOrmMapper.toOrmEntity(teamRating);
|
||||
await repo.save(entity);
|
||||
return teamRating;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user