auth
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { InMemoryMagicLinkRepository } from './InMemoryMagicLinkRepository';
|
||||
|
||||
const mockLogger = {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
};
|
||||
|
||||
describe('InMemoryMagicLinkRepository', () => {
|
||||
let repository: InMemoryMagicLinkRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new InMemoryMagicLinkRepository(mockLogger as any);
|
||||
});
|
||||
|
||||
describe('createPasswordResetRequest', () => {
|
||||
it('should create a password reset request', async () => {
|
||||
const request = {
|
||||
email: 'test@example.com',
|
||||
token: 'abc123',
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
|
||||
userId: 'user-123',
|
||||
};
|
||||
|
||||
await repository.createPasswordResetRequest(request);
|
||||
|
||||
const found = await repository.findByToken('abc123');
|
||||
expect(found).toEqual(request);
|
||||
});
|
||||
|
||||
it('should enforce rate limiting', async () => {
|
||||
const request = {
|
||||
email: 'test@example.com',
|
||||
token: 'token1',
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
|
||||
userId: 'user-123',
|
||||
};
|
||||
|
||||
// Create 3 requests for same email
|
||||
await repository.createPasswordResetRequest({ ...request, token: 'token1' });
|
||||
await repository.createPasswordResetRequest({ ...request, token: 'token2' });
|
||||
await repository.createPasswordResetRequest({ ...request, token: 'token3' });
|
||||
|
||||
// 4th should fail
|
||||
const result = await repository.checkRateLimit('test@example.com');
|
||||
expect(result.isErr()).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow requests after time window expires', async () => {
|
||||
const now = Date.now();
|
||||
const request = {
|
||||
email: 'test@example.com',
|
||||
token: 'token1',
|
||||
expiresAt: new Date(now + 15 * 60 * 1000),
|
||||
userId: 'user-123',
|
||||
};
|
||||
|
||||
// Mock Date.now to return time after rate limit window
|
||||
const originalNow = Date.now;
|
||||
Date.now = () => now + (16 * 60 * 1000); // 16 minutes later
|
||||
|
||||
try {
|
||||
await repository.createPasswordResetRequest(request);
|
||||
const found = await repository.findByToken('token1');
|
||||
expect(found).toBeDefined();
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByToken', () => {
|
||||
it('should find existing token', async () => {
|
||||
const request = {
|
||||
email: 'test@example.com',
|
||||
token: 'abc123',
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
|
||||
userId: 'user-123',
|
||||
};
|
||||
|
||||
await repository.createPasswordResetRequest(request);
|
||||
const found = await repository.findByToken('abc123');
|
||||
|
||||
expect(found).toEqual(request);
|
||||
});
|
||||
|
||||
it('should return null for non-existent token', async () => {
|
||||
const found = await repository.findByToken('nonexistent');
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsUsed', () => {
|
||||
it('should mark token as used', async () => {
|
||||
const request = {
|
||||
email: 'test@example.com',
|
||||
token: 'abc123',
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
|
||||
userId: 'user-123',
|
||||
};
|
||||
|
||||
await repository.createPasswordResetRequest(request);
|
||||
await repository.markAsUsed('abc123');
|
||||
|
||||
const found = await repository.findByToken('abc123');
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle non-existent token gracefully', async () => {
|
||||
await expect(repository.markAsUsed('nonexistent')).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkRateLimit', () => {
|
||||
it('should allow requests under limit', async () => {
|
||||
const result = await repository.checkRateLimit('test@example.com');
|
||||
expect(result.isOk()).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject requests over limit', async () => {
|
||||
const email = 'test@example.com';
|
||||
const request = {
|
||||
email,
|
||||
token: 'token',
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
|
||||
userId: 'user-123',
|
||||
};
|
||||
|
||||
// Create 3 requests
|
||||
await repository.createPasswordResetRequest({ ...request, token: 'token1' });
|
||||
await repository.createPasswordResetRequest({ ...request, token: 'token2' });
|
||||
await repository.createPasswordResetRequest({ ...request, token: 'token3' });
|
||||
|
||||
const result = await repository.checkRateLimit(email);
|
||||
expect(result.isErr()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { IMagicLinkRepository, PasswordResetRequest } from '@core/identity/domain/repositories/IMagicLinkRepository';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import { Logger } from '@core/shared/application';
|
||||
|
||||
export class InMemoryMagicLinkRepository implements IMagicLinkRepository {
|
||||
private resetRequests: Map<string, PasswordResetRequest> = new Map();
|
||||
private rateLimitStore: Map<string, { count: number; lastRequest: Date }> = new Map();
|
||||
|
||||
// Rate limit: max 3 requests per 15 minutes
|
||||
private readonly RATE_LIMIT_MAX = 3;
|
||||
private readonly RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
constructor(private readonly logger: Logger) {}
|
||||
|
||||
async createPasswordResetRequest(request: PasswordResetRequest): Promise<void> {
|
||||
this.logger.debug('[InMemoryMagicLinkRepository] Creating password reset request', {
|
||||
email: request.email,
|
||||
token: request.token.substring(0, 10) + '...',
|
||||
expiresAt: request.expiresAt,
|
||||
});
|
||||
|
||||
this.resetRequests.set(request.token, request);
|
||||
|
||||
// Update rate limit tracking
|
||||
const now = new Date();
|
||||
const existing = this.rateLimitStore.get(request.email);
|
||||
|
||||
if (existing) {
|
||||
// Reset count if window has passed
|
||||
if (now.getTime() - existing.lastRequest.getTime() > this.RATE_LIMIT_WINDOW) {
|
||||
this.rateLimitStore.set(request.email, { count: 1, lastRequest: now });
|
||||
} else {
|
||||
this.rateLimitStore.set(request.email, {
|
||||
count: existing.count + 1,
|
||||
lastRequest: now
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.rateLimitStore.set(request.email, { count: 1, lastRequest: now });
|
||||
}
|
||||
}
|
||||
|
||||
async findByToken(token: string): Promise<PasswordResetRequest | null> {
|
||||
const request = this.resetRequests.get(token);
|
||||
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (request.expiresAt < new Date()) {
|
||||
this.resetRequests.delete(token);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if already used
|
||||
if (request.used) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
async markAsUsed(token: string): Promise<void> {
|
||||
const request = this.resetRequests.get(token);
|
||||
if (request) {
|
||||
request.used = true;
|
||||
this.logger.debug('[InMemoryMagicLinkRepository] Marked token as used', {
|
||||
token: token.substring(0, 10) + '...',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async checkRateLimit(email: string): Promise<Result<void, { message: string }>> {
|
||||
const now = new Date();
|
||||
const tracking = this.rateLimitStore.get(email);
|
||||
|
||||
if (!tracking) {
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
// Check if window has passed
|
||||
if (now.getTime() - tracking.lastRequest.getTime() > this.RATE_LIMIT_WINDOW) {
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
// Check if exceeded limit
|
||||
if (tracking.count >= this.RATE_LIMIT_MAX) {
|
||||
const timeRemaining = Math.ceil(
|
||||
(this.RATE_LIMIT_WINDOW - (now.getTime() - tracking.lastRequest.getTime())) / 60000
|
||||
);
|
||||
return Result.err({
|
||||
message: `Too many reset attempts. Please try again in ${timeRemaining} minutes.`,
|
||||
});
|
||||
}
|
||||
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
async cleanupExpired(): Promise<void> {
|
||||
const now = new Date();
|
||||
const toDelete: string[] = [];
|
||||
|
||||
for (const [token, request] of this.resetRequests.entries()) {
|
||||
if (request.expiresAt < now || request.used) {
|
||||
toDelete.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
toDelete.forEach(token => this.resetRequests.delete(token));
|
||||
|
||||
if (toDelete.length > 0) {
|
||||
this.logger.debug('[InMemoryMagicLinkRepository] Cleaned up expired tokens', {
|
||||
count: toDelete.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('password_reset_requests')
|
||||
export class PasswordResetRequestOrmEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column()
|
||||
email!: string;
|
||||
|
||||
@Column({ unique: true })
|
||||
token!: string;
|
||||
|
||||
@Column()
|
||||
expiresAt!: Date;
|
||||
|
||||
@Column()
|
||||
userId!: string;
|
||||
|
||||
@Column({ default: false })
|
||||
used!: boolean;
|
||||
|
||||
@Column({ default: 0 })
|
||||
attemptCount!: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export class UserOrmMapper {
|
||||
assertNonEmptyString(entityName, 'email', entity.email);
|
||||
assertNonEmptyString(entityName, 'displayName', entity.displayName);
|
||||
assertNonEmptyString(entityName, 'passwordHash', entity.passwordHash);
|
||||
assertNonEmptyString(entityName, 'salt', entity.salt);
|
||||
assertOptionalStringOrNull(entityName, 'salt', entity.salt);
|
||||
assertOptionalStringOrNull(entityName, 'primaryDriverId', entity.primaryDriverId);
|
||||
assertDate(entityName, 'createdAt', entity.createdAt);
|
||||
} catch (error) {
|
||||
@@ -48,7 +48,7 @@ export class UserOrmMapper {
|
||||
entity.email = stored.email;
|
||||
entity.displayName = stored.displayName;
|
||||
entity.passwordHash = stored.passwordHash;
|
||||
entity.salt = stored.salt;
|
||||
entity.salt = stored.salt ?? '';
|
||||
entity.primaryDriverId = stored.primaryDriverId ?? null;
|
||||
entity.createdAt = stored.createdAt;
|
||||
return entity;
|
||||
@@ -60,7 +60,7 @@ export class UserOrmMapper {
|
||||
email: entity.email,
|
||||
displayName: entity.displayName,
|
||||
passwordHash: entity.passwordHash,
|
||||
salt: entity.salt,
|
||||
...(entity.salt ? { salt: entity.salt } : {}),
|
||||
primaryDriverId: entity.primaryDriverId ?? undefined,
|
||||
createdAt: entity.createdAt,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
import { IMagicLinkRepository, PasswordResetRequest } from '@core/identity/domain/repositories/IMagicLinkRepository';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import { Logger } from '@core/shared/application';
|
||||
import { PasswordResetRequestOrmEntity } from '../entities/PasswordResetRequestOrmEntity';
|
||||
|
||||
export class TypeOrmMagicLinkRepository implements IMagicLinkRepository {
|
||||
// Rate limit: max 3 requests per 15 minutes
|
||||
private readonly RATE_LIMIT_MAX = 3;
|
||||
private readonly RATE_LIMIT_WINDOW = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async createPasswordResetRequest(request: PasswordResetRequest): Promise<void> {
|
||||
this.logger.debug('[TypeOrmMagicLinkRepository] Creating password reset request', {
|
||||
email: request.email,
|
||||
token: request.token.substring(0, 10) + '...',
|
||||
expiresAt: request.expiresAt,
|
||||
});
|
||||
|
||||
const repo = this.dataSource.getRepository(PasswordResetRequestOrmEntity);
|
||||
|
||||
const entity = new PasswordResetRequestOrmEntity();
|
||||
entity.email = request.email;
|
||||
entity.token = request.token;
|
||||
entity.expiresAt = request.expiresAt;
|
||||
entity.userId = request.userId;
|
||||
entity.used = false;
|
||||
entity.attemptCount = 0;
|
||||
|
||||
await repo.save(entity);
|
||||
}
|
||||
|
||||
async findByToken(token: string): Promise<PasswordResetRequest | null> {
|
||||
const repo = this.dataSource.getRepository(PasswordResetRequestOrmEntity);
|
||||
|
||||
const entity = await repo.findOne({ where: { token } });
|
||||
|
||||
if (!entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (entity.expiresAt < new Date()) {
|
||||
await repo.delete(entity.id);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if already used
|
||||
if (entity.used) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
email: entity.email,
|
||||
token: entity.token,
|
||||
expiresAt: entity.expiresAt,
|
||||
userId: entity.userId,
|
||||
used: entity.used,
|
||||
};
|
||||
}
|
||||
|
||||
async markAsUsed(token: string): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(PasswordResetRequestOrmEntity);
|
||||
|
||||
await repo.update(
|
||||
{ token },
|
||||
{
|
||||
used: true,
|
||||
attemptCount: () => 'attemptCount + 1'
|
||||
}
|
||||
);
|
||||
|
||||
this.logger.debug('[TypeOrmMagicLinkRepository] Marked token as used', {
|
||||
token: token.substring(0, 10) + '...',
|
||||
});
|
||||
}
|
||||
|
||||
async checkRateLimit(email: string): Promise<Result<void, { message: string }>> {
|
||||
const repo = this.dataSource.getRepository(PasswordResetRequestOrmEntity);
|
||||
const now = new Date();
|
||||
const windowStart = new Date(now.getTime() - this.RATE_LIMIT_WINDOW);
|
||||
|
||||
// Count requests in the current window
|
||||
const recentRequests = await repo.count({
|
||||
where: {
|
||||
email,
|
||||
createdAt: windowStart,
|
||||
used: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (recentRequests >= this.RATE_LIMIT_MAX) {
|
||||
// Find the oldest request to calculate remaining time
|
||||
const oldestRequest = await repo.findOne({
|
||||
where: { email },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
if (oldestRequest) {
|
||||
const timeRemaining = Math.ceil(
|
||||
(this.RATE_LIMIT_WINDOW - (now.getTime() - oldestRequest.createdAt.getTime())) / 60000
|
||||
);
|
||||
return Result.err({
|
||||
message: `Too many reset attempts. Please try again in ${timeRemaining} minutes.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
async cleanupExpired(): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(PasswordResetRequestOrmEntity);
|
||||
const now = new Date();
|
||||
|
||||
const result = await repo.delete({
|
||||
expiresAt: now,
|
||||
});
|
||||
|
||||
if (result.affected && result.affected > 0) {
|
||||
this.logger.debug('[TypeOrmMagicLinkRepository] Cleaned up expired tokens', {
|
||||
count: result.affected,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { IMagicLinkNotificationPort, MagicLinkNotificationInput } from '@core/identity/domain/ports/IMagicLinkNotificationPort';
|
||||
import { Logger } from '@core/shared/application';
|
||||
|
||||
/**
|
||||
* Console adapter for magic link notifications
|
||||
* Logs to console for development/testing purposes
|
||||
*/
|
||||
export class ConsoleMagicLinkNotificationAdapter implements IMagicLinkNotificationPort {
|
||||
constructor(private readonly logger: Logger) {}
|
||||
|
||||
async sendMagicLink(input: MagicLinkNotificationInput): Promise<void> {
|
||||
this.logger.info('[ConsoleMagicLinkNotificationAdapter] Magic link generated', {
|
||||
email: input.email,
|
||||
userId: input.userId,
|
||||
magicLink: input.magicLink,
|
||||
expiresAt: input.expiresAt,
|
||||
});
|
||||
|
||||
// In development, log to console
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('\n🔒 PASSWORD RESET MAGIC LINK');
|
||||
console.log('='.repeat(50));
|
||||
console.log(`📧 Email: ${input.email}`);
|
||||
console.log(`👤 User ID: ${input.userId}`);
|
||||
console.log(`🔗 Link: ${input.magicLink}`);
|
||||
console.log(`⏰ Expires: ${input.expiresAt.toLocaleString()}`);
|
||||
console.log('='.repeat(50));
|
||||
console.log('⚠️ This would be sent via email in production\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity({ name: 'driver_stats' })
|
||||
export class DriverStatsOrmEntity {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
driverId!: string;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
rating!: number;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
safetyRating!: number;
|
||||
|
||||
@Column({ type: 'numeric', precision: 3, scale: 1 })
|
||||
sportsmanshipRating!: number;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
totalRaces!: number;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
wins!: number;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
podiums!: number;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
dnfs!: number;
|
||||
|
||||
@Column({ type: 'numeric', precision: 5, scale: 2 })
|
||||
avgFinish!: number;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
bestFinish!: number;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
worstFinish!: number;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
consistency!: number;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
experienceLevel!: string;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
overallRank!: number | null;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { DriverStats } from '@core/racing/application/use-cases/IDriverStatsUseCase';
|
||||
|
||||
import { DriverStatsOrmEntity } from '../entities/DriverStatsOrmEntity';
|
||||
import {
|
||||
assertNonEmptyString,
|
||||
assertInteger,
|
||||
assertNumber
|
||||
} from '../schema/TypeOrmSchemaGuards';
|
||||
|
||||
export class DriverStatsOrmMapper {
|
||||
toOrmEntity(driverId: string, domain: DriverStats): DriverStatsOrmEntity {
|
||||
const entity = new DriverStatsOrmEntity();
|
||||
entity.driverId = driverId;
|
||||
entity.rating = domain.rating;
|
||||
entity.safetyRating = domain.safetyRating;
|
||||
entity.sportsmanshipRating = domain.sportsmanshipRating;
|
||||
entity.totalRaces = domain.totalRaces;
|
||||
entity.wins = domain.wins;
|
||||
entity.podiums = domain.podiums;
|
||||
entity.dnfs = domain.dnfs;
|
||||
entity.avgFinish = domain.avgFinish;
|
||||
entity.bestFinish = domain.bestFinish;
|
||||
entity.worstFinish = domain.worstFinish;
|
||||
entity.consistency = domain.consistency;
|
||||
entity.experienceLevel = domain.experienceLevel;
|
||||
entity.overallRank = domain.overallRank ?? null;
|
||||
return entity;
|
||||
}
|
||||
|
||||
toDomain(entity: DriverStatsOrmEntity): DriverStats {
|
||||
const entityName = 'DriverStats';
|
||||
|
||||
assertNonEmptyString(entityName, 'driverId', entity.driverId);
|
||||
assertInteger(entityName, 'rating', entity.rating);
|
||||
assertInteger(entityName, 'safetyRating', entity.safetyRating);
|
||||
assertInteger(entityName, 'sportsmanshipRating', entity.sportsmanshipRating);
|
||||
assertInteger(entityName, 'totalRaces', entity.totalRaces);
|
||||
assertInteger(entityName, 'wins', entity.wins);
|
||||
assertInteger(entityName, 'podiums', entity.podiums);
|
||||
assertInteger(entityName, 'dnfs', entity.dnfs);
|
||||
assertNumber(entityName, 'avgFinish', entity.avgFinish);
|
||||
assertInteger(entityName, 'bestFinish', entity.bestFinish);
|
||||
assertInteger(entityName, 'worstFinish', entity.worstFinish);
|
||||
assertInteger(entityName, 'consistency', entity.consistency);
|
||||
assertNonEmptyString(entityName, 'experienceLevel', entity.experienceLevel);
|
||||
|
||||
const result: DriverStats = {
|
||||
rating: entity.rating,
|
||||
safetyRating: entity.safetyRating,
|
||||
sportsmanshipRating: entity.sportsmanshipRating,
|
||||
totalRaces: entity.totalRaces,
|
||||
wins: entity.wins,
|
||||
podiums: entity.podiums,
|
||||
dnfs: entity.dnfs,
|
||||
avgFinish: entity.avgFinish,
|
||||
bestFinish: entity.bestFinish,
|
||||
worstFinish: entity.worstFinish,
|
||||
consistency: entity.consistency,
|
||||
experienceLevel: entity.experienceLevel,
|
||||
overallRank: entity.overallRank ?? null,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository';
|
||||
import type { DriverStats } from '@core/racing/application/use-cases/IDriverStatsUseCase';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import { DriverStatsOrmEntity } from '../entities/DriverStatsOrmEntity';
|
||||
import { DriverStatsOrmMapper } from '../mappers/DriverStatsOrmMapper';
|
||||
|
||||
export class TypeOrmDriverStatsRepository implements IDriverStatsRepository {
|
||||
constructor(
|
||||
private readonly repo: Repository<DriverStatsOrmEntity>,
|
||||
private readonly mapper: DriverStatsOrmMapper,
|
||||
) {}
|
||||
|
||||
async getDriverStats(driverId: string): Promise<DriverStats | null> {
|
||||
const entity = await this.repo.findOne({ where: { driverId } });
|
||||
return entity ? this.mapper.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
getDriverStatsSync(_driverId: string): DriverStats | null {
|
||||
// TypeORM repositories don't support synchronous operations
|
||||
// This method is provided for interface compatibility but should not be used
|
||||
// with TypeORM implementations. Return null to indicate it's not supported.
|
||||
return null;
|
||||
}
|
||||
|
||||
async saveDriverStats(driverId: string, stats: DriverStats): Promise<void> {
|
||||
const entity = this.mapper.toOrmEntity(driverId, stats);
|
||||
await this.repo.save(entity);
|
||||
}
|
||||
|
||||
async getAllStats(): Promise<Map<string, DriverStats>> {
|
||||
const entities = await this.repo.find();
|
||||
const statsMap = new Map<string, DriverStats>();
|
||||
|
||||
for (const entity of entities) {
|
||||
statsMap.set(entity.driverId, this.mapper.toDomain(entity));
|
||||
}
|
||||
|
||||
return statsMap;
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
await this.repo.clear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user