team rating

This commit is contained in:
2025-12-30 12:25:45 +01:00
parent ccaa39c39c
commit 83371ea839
93 changed files with 10324 additions and 490 deletions

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View 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();
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();
});
});
});

View File

@@ -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;
}
}

View File

@@ -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');
});
});
});

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}