inmemory to postgres

This commit is contained in:
2025-12-29 18:34:12 +01:00
parent 9e17d0752a
commit f5639a367f
176 changed files with 10175 additions and 468 deletions

View File

@@ -0,0 +1,33 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import type { SnapshotEntityType, SnapshotPeriod } from '@core/analytics/domain/entities/AnalyticsSnapshot';
import type { AnalyticsMetrics } from '@core/analytics/domain/types/AnalyticsSnapshot';
@Index('IDX_analytics_snapshots_entity', ['entityType', 'entityId', 'period'])
@Index('IDX_analytics_snapshots_date_range', ['startDate', 'endDate'])
@Entity({ name: 'analytics_snapshots' })
export class AnalyticsSnapshotOrmEntity {
@PrimaryColumn({ type: 'text' })
id!: string;
@Column({ type: 'text' })
entityType!: SnapshotEntityType;
@Column({ type: 'text' })
entityId!: string;
@Column({ type: 'text' })
period!: SnapshotPeriod;
@Column({ type: 'timestamptz' })
startDate!: Date;
@Column({ type: 'timestamptz' })
endDate!: Date;
@Column({ type: 'jsonb' })
metrics!: AnalyticsMetrics;
@Column({ type: 'timestamptz' })
createdAt!: Date;
}

View File

@@ -0,0 +1,36 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import type { EngagementAction, EngagementEntityType } from '@core/analytics/domain/entities/EngagementEvent';
@Index('IDX_analytics_engagement_events_entity', ['entityType', 'entityId'])
@Index('IDX_analytics_engagement_events_action', ['action'])
@Index('IDX_analytics_engagement_events_timestamp', ['timestamp'])
@Entity({ name: 'analytics_engagement_events' })
export class EngagementEventOrmEntity {
@PrimaryColumn({ type: 'text' })
id!: string;
@Column({ type: 'text' })
action!: EngagementAction;
@Column({ type: 'text' })
entityType!: EngagementEntityType;
@Column({ type: 'text' })
entityId!: string;
@Column({ type: 'text', nullable: true })
actorId!: string | null;
@Column({ type: 'text' })
actorType!: 'anonymous' | 'driver' | 'sponsor';
@Column({ type: 'text' })
sessionId!: string;
@Column({ type: 'jsonb', nullable: true })
metadata!: Record<string, string | number | boolean> | null;
@Column({ type: 'timestamptz' })
timestamp!: Date;
}

View File

@@ -0,0 +1,42 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import type { EntityType, VisitorType } from '@core/analytics/domain/entities/PageView';
@Index('IDX_analytics_page_views_entity', ['entityType', 'entityId'])
@Index('IDX_analytics_page_views_session', ['sessionId'])
@Index('IDX_analytics_page_views_timestamp', ['timestamp'])
@Entity({ name: 'analytics_page_views' })
export class PageViewOrmEntity {
@PrimaryColumn({ type: 'text' })
id!: string;
@Column({ type: 'text' })
entityType!: EntityType;
@Column({ type: 'text' })
entityId!: string;
@Column({ type: 'text', nullable: true })
visitorId!: string | null;
@Column({ type: 'text' })
visitorType!: VisitorType;
@Column({ type: 'text' })
sessionId!: string;
@Column({ type: 'text', nullable: true })
referrer!: string | null;
@Column({ type: 'text', nullable: true })
userAgent!: string | null;
@Column({ type: 'text', nullable: true })
country!: string | null;
@Column({ type: 'timestamptz' })
timestamp!: Date;
@Column({ type: 'int', nullable: true })
durationMs!: number | null;
}

View File

@@ -0,0 +1,33 @@
export type TypeOrmAnalyticsSchemaErrorReason =
| 'missing'
| 'not_string'
| 'empty_string'
| 'not_number'
| 'not_integer'
| 'not_boolean'
| 'not_date'
| 'invalid_date'
| 'invalid_enum_value'
| 'not_object'
| 'invalid_shape';
export class TypeOrmAnalyticsSchemaError extends Error {
readonly entityName: string;
readonly fieldName: string;
readonly reason: TypeOrmAnalyticsSchemaErrorReason | (string & {});
constructor(params: {
entityName: string;
fieldName: string;
reason: TypeOrmAnalyticsSchemaError['reason'];
message?: string;
}) {
const { entityName, fieldName, reason, message } = params;
super(message);
this.name = 'TypeOrmAnalyticsSchemaError';
this.entityName = entityName;
this.fieldName = fieldName;
this.reason = reason;
}
}

View File

@@ -0,0 +1,78 @@
import { AnalyticsSnapshot } from '@core/analytics/domain/entities/AnalyticsSnapshot';
import type { AnalyticsMetrics, SnapshotEntityType, SnapshotPeriod } from '@core/analytics/domain/types/AnalyticsSnapshot';
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
import { AnalyticsSnapshotOrmEntity } from '../entities/AnalyticsSnapshotOrmEntity';
import { assertDate, assertEnumValue, assertNonEmptyString, assertNumber, assertRecord } from '../schema/TypeOrmAnalyticsSchemaGuards';
const VALID_ENTITY_TYPES: readonly SnapshotEntityType[] = ['league', 'driver', 'team', 'race', 'sponsor'] as const;
const VALID_PERIODS: readonly SnapshotPeriod[] = ['daily', 'weekly', 'monthly'] as const;
function assertMetrics(entityName: string, fieldName: string, value: unknown): asserts value is AnalyticsMetrics {
assertRecord(entityName, fieldName, value);
const metrics = value as Record<string, unknown>;
assertNumber(entityName, `${fieldName}.pageViews`, metrics.pageViews);
assertNumber(entityName, `${fieldName}.uniqueVisitors`, metrics.uniqueVisitors);
assertNumber(entityName, `${fieldName}.avgSessionDuration`, metrics.avgSessionDuration);
assertNumber(entityName, `${fieldName}.bounceRate`, metrics.bounceRate);
assertNumber(entityName, `${fieldName}.engagementScore`, metrics.engagementScore);
assertNumber(entityName, `${fieldName}.sponsorClicks`, metrics.sponsorClicks);
assertNumber(entityName, `${fieldName}.sponsorUrlClicks`, metrics.sponsorUrlClicks);
assertNumber(entityName, `${fieldName}.socialShares`, metrics.socialShares);
assertNumber(entityName, `${fieldName}.leagueJoins`, metrics.leagueJoins);
assertNumber(entityName, `${fieldName}.raceRegistrations`, metrics.raceRegistrations);
assertNumber(entityName, `${fieldName}.exposureValue`, metrics.exposureValue);
}
export class AnalyticsSnapshotOrmMapper {
toOrmEntity(domain: AnalyticsSnapshot): AnalyticsSnapshotOrmEntity {
const entity = new AnalyticsSnapshotOrmEntity();
entity.id = domain.id;
entity.entityType = domain.entityType;
entity.entityId = domain.entityId;
entity.period = domain.period;
entity.startDate = domain.startDate;
entity.endDate = domain.endDate;
entity.metrics = domain.metrics;
entity.createdAt = domain.createdAt;
return entity;
}
toDomain(entity: AnalyticsSnapshotOrmEntity): AnalyticsSnapshot {
const entityName = 'AnalyticsSnapshot';
try {
assertNonEmptyString(entityName, 'id', entity.id);
assertEnumValue<SnapshotEntityType>(entityName, 'entityType', entity.entityType, VALID_ENTITY_TYPES);
assertNonEmptyString(entityName, 'entityId', entity.entityId);
assertEnumValue<SnapshotPeriod>(entityName, 'period', entity.period, VALID_PERIODS);
assertDate(entityName, 'startDate', entity.startDate);
assertDate(entityName, 'endDate', entity.endDate);
assertMetrics(entityName, 'metrics', entity.metrics);
assertDate(entityName, 'createdAt', entity.createdAt);
return AnalyticsSnapshot.create({
id: entity.id,
entityType: entity.entityType,
entityId: entity.entityId,
period: entity.period,
startDate: entity.startDate,
endDate: entity.endDate,
metrics: entity.metrics,
createdAt: entity.createdAt,
});
} catch (error) {
if (error instanceof TypeOrmAnalyticsSchemaError) {
throw error;
}
const message = error instanceof Error ? error.message : 'Invalid persisted AnalyticsSnapshot';
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
}
}
}

View File

@@ -0,0 +1,115 @@
import { EngagementEvent } from '@core/analytics/domain/entities/EngagementEvent';
import type { EngagementAction, EngagementEntityType } from '@core/analytics/domain/types/EngagementEvent';
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
import { EngagementEventOrmEntity } from '../entities/EngagementEventOrmEntity';
import {
assertDate,
assertEnumValue,
assertNonEmptyString,
assertOptionalStringOrNull,
assertRecord,
} from '../schema/TypeOrmAnalyticsSchemaGuards';
const VALID_ACTIONS: readonly EngagementAction[] = [
'click_sponsor_logo',
'click_sponsor_url',
'download_livery_pack',
'join_league',
'register_race',
'view_standings',
'view_schedule',
'share_social',
'contact_sponsor',
] as const;
const VALID_ENTITY_TYPES: readonly EngagementEntityType[] = [
'league',
'driver',
'team',
'race',
'sponsor',
'sponsorship',
] as const;
const VALID_ACTOR_TYPES: readonly EngagementEvent['actorType'][] = ['anonymous', 'driver', 'sponsor'] as const;
function assertMetadataShape(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is Record<string, string | number | boolean> | null | undefined {
if (value === undefined || value === null) return;
assertRecord(entityName, fieldName, value);
for (const [k, v] of Object.entries(value)) {
const allowed =
typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' || v === undefined || v === null;
if (!allowed) {
throw new TypeOrmAnalyticsSchemaError({
entityName,
fieldName,
reason: 'invalid_shape',
message: `Invalid metadata value for key "${k}"`,
});
}
}
}
export class EngagementEventOrmMapper {
toOrmEntity(domain: EngagementEvent): EngagementEventOrmEntity {
const entity = new EngagementEventOrmEntity();
entity.id = domain.id;
entity.action = domain.action;
entity.entityType = domain.entityType;
entity.entityId = domain.entityId;
entity.actorId = domain.actorId ?? null;
entity.actorType = domain.actorType;
entity.sessionId = domain.sessionId;
entity.metadata = domain.metadata ?? null;
entity.timestamp = domain.timestamp;
return entity;
}
toDomain(entity: EngagementEventOrmEntity): EngagementEvent {
const entityName = 'AnalyticsEngagementEvent';
try {
assertNonEmptyString(entityName, 'id', entity.id);
assertEnumValue<EngagementAction>(entityName, 'action', entity.action, VALID_ACTIONS);
assertEnumValue<EngagementEntityType>(entityName, 'entityType', entity.entityType, VALID_ENTITY_TYPES);
assertNonEmptyString(entityName, 'entityId', entity.entityId);
assertOptionalStringOrNull(entityName, 'actorId', entity.actorId);
assertEnumValue<EngagementEvent['actorType']>(entityName, 'actorType', entity.actorType, VALID_ACTOR_TYPES);
assertNonEmptyString(entityName, 'sessionId', entity.sessionId);
assertMetadataShape(entityName, 'metadata', entity.metadata);
assertDate(entityName, 'timestamp', entity.timestamp);
return EngagementEvent.create({
id: entity.id,
action: entity.action,
entityType: entity.entityType,
entityId: entity.entityId,
actorType: entity.actorType,
sessionId: entity.sessionId,
timestamp: entity.timestamp,
...(entity.actorId !== null && entity.actorId !== undefined ? { actorId: entity.actorId } : {}),
...(entity.metadata !== null && entity.metadata !== undefined ? { metadata: entity.metadata } : {}),
});
} catch (error) {
if (error instanceof TypeOrmAnalyticsSchemaError) {
throw error;
}
const message = error instanceof Error ? error.message : 'Invalid persisted AnalyticsEngagementEvent';
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
}
}
}

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { PageView } from '@core/analytics/domain/entities/PageView';
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
import { PageViewOrmEntity } from '../entities/PageViewOrmEntity';
import { PageViewOrmMapper } from './PageViewOrmMapper';
describe('PageViewOrmMapper', () => {
it('maps domain -> orm -> domain (round-trip)', () => {
const mapper = new PageViewOrmMapper();
const domain = PageView.create({
id: 'pv_1',
entityType: 'league',
entityId: 'league-1',
visitorType: 'anonymous',
sessionId: 'sess-1',
timestamp: new Date('2025-01-01T00:00:00.000Z'),
visitorId: 'visitor-1',
referrer: 'https://example.com',
userAgent: 'ua',
country: 'DE',
durationMs: 1234,
});
const orm = mapper.toOrmEntity(domain);
expect(orm).toBeInstanceOf(PageViewOrmEntity);
expect(orm.id).toBe(domain.id);
const rehydrated = mapper.toDomain(orm);
expect(rehydrated.id).toBe(domain.id);
expect(rehydrated.entityType).toBe(domain.entityType);
expect(rehydrated.entityId).toBe(domain.entityId);
expect(rehydrated.sessionId).toBe(domain.sessionId);
expect(rehydrated.visitorType).toBe(domain.visitorType);
expect(rehydrated.visitorId).toBe(domain.visitorId);
expect(rehydrated.referrer).toBe(domain.referrer);
expect(rehydrated.userAgent).toBe(domain.userAgent);
expect(rehydrated.country).toBe(domain.country);
expect(rehydrated.durationMs).toBe(domain.durationMs);
expect(rehydrated.timestamp.toISOString()).toBe(domain.timestamp.toISOString());
});
it('throws TypeOrmAnalyticsSchemaError for invalid persisted shape', () => {
const mapper = new PageViewOrmMapper();
const orm = new PageViewOrmEntity();
orm.id = '';
orm.entityType = 'league' as never;
orm.entityId = 'league-1';
orm.visitorId = null;
orm.visitorType = 'anonymous' as never;
orm.sessionId = 'sess-1';
orm.referrer = null;
orm.userAgent = null;
orm.country = null;
orm.timestamp = new Date('2025-01-01T00:00:00.000Z');
orm.durationMs = null;
expect(() => mapper.toDomain(orm)).toThrow(TypeOrmAnalyticsSchemaError);
});
});

View File

@@ -0,0 +1,80 @@
import { PageView } from '@core/analytics/domain/entities/PageView';
import type { EntityType, VisitorType } from '@core/analytics/domain/types/PageView';
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
import { PageViewOrmEntity } from '../entities/PageViewOrmEntity';
import {
assertDate,
assertEnumValue,
assertNonEmptyString,
assertOptionalIntegerOrNull,
assertOptionalStringOrNull,
} from '../schema/TypeOrmAnalyticsSchemaGuards';
const VALID_ENTITY_TYPES: readonly EntityType[] = ['league', 'driver', 'team', 'race', 'sponsor'] as const;
const VALID_VISITOR_TYPES: readonly VisitorType[] = ['anonymous', 'driver', 'sponsor'] as const;
export class PageViewOrmMapper {
toOrmEntity(domain: PageView): PageViewOrmEntity {
const entity = new PageViewOrmEntity();
entity.id = domain.id;
entity.entityType = domain.entityType;
entity.entityId = domain.entityId;
entity.visitorId = domain.visitorId ?? null;
entity.visitorType = domain.visitorType;
entity.sessionId = domain.sessionId;
entity.referrer = domain.referrer ?? null;
entity.userAgent = domain.userAgent ?? null;
entity.country = domain.country ?? null;
entity.timestamp = domain.timestamp;
entity.durationMs = domain.durationMs ?? null;
return entity;
}
toDomain(entity: PageViewOrmEntity): PageView {
const entityName = 'AnalyticsPageView';
try {
assertNonEmptyString(entityName, 'id', entity.id);
assertEnumValue<EntityType>(entityName, 'entityType', entity.entityType, VALID_ENTITY_TYPES);
assertNonEmptyString(entityName, 'entityId', entity.entityId);
assertOptionalStringOrNull(entityName, 'visitorId', entity.visitorId);
assertEnumValue<VisitorType>(entityName, 'visitorType', entity.visitorType, VALID_VISITOR_TYPES);
assertNonEmptyString(entityName, 'sessionId', entity.sessionId);
assertOptionalStringOrNull(entityName, 'referrer', entity.referrer);
assertOptionalStringOrNull(entityName, 'userAgent', entity.userAgent);
assertOptionalStringOrNull(entityName, 'country', entity.country);
assertDate(entityName, 'timestamp', entity.timestamp);
assertOptionalIntegerOrNull(entityName, 'durationMs', entity.durationMs);
return PageView.create({
id: entity.id,
entityType: entity.entityType,
entityId: entity.entityId,
visitorType: entity.visitorType,
sessionId: entity.sessionId,
timestamp: entity.timestamp,
...(entity.visitorId !== null && entity.visitorId !== undefined ? { visitorId: entity.visitorId } : {}),
...(entity.referrer !== null && entity.referrer !== undefined ? { referrer: entity.referrer } : {}),
...(entity.userAgent !== null && entity.userAgent !== undefined ? { userAgent: entity.userAgent } : {}),
...(entity.country !== null && entity.country !== undefined ? { country: entity.country } : {}),
...(entity.durationMs !== null && entity.durationMs !== undefined ? { durationMs: entity.durationMs } : {}),
});
} catch (error) {
if (error instanceof TypeOrmAnalyticsSchemaError) {
throw error;
}
const message = error instanceof Error ? error.message : 'Invalid persisted AnalyticsPageView';
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
}
}
}

View File

@@ -0,0 +1,82 @@
import { type Repository } from 'typeorm';
import type { IAnalyticsSnapshotRepository } from '@core/analytics/domain/repositories/IAnalyticsSnapshotRepository';
import type { SnapshotEntityType, SnapshotPeriod } from '@core/analytics/domain/types/AnalyticsSnapshot';
import { AnalyticsSnapshot } from '@core/analytics/domain/entities/AnalyticsSnapshot';
import { AnalyticsSnapshotOrmEntity } from '../entities/AnalyticsSnapshotOrmEntity';
import { AnalyticsSnapshotOrmMapper } from '../mappers/AnalyticsSnapshotOrmMapper';
export class TypeOrmAnalyticsSnapshotRepository implements IAnalyticsSnapshotRepository {
constructor(
private readonly snapshotRepo: Repository<AnalyticsSnapshotOrmEntity>,
private readonly mapper: AnalyticsSnapshotOrmMapper,
) {}
async save(snapshot: AnalyticsSnapshot): Promise<void> {
await this.snapshotRepo.save(this.mapper.toOrmEntity(snapshot));
}
async findById(id: string): Promise<AnalyticsSnapshot | null> {
const entity = await this.snapshotRepo.findOneBy({ id });
return entity ? this.mapper.toDomain(entity) : null;
}
async findByEntity(entityType: SnapshotEntityType, entityId: string): Promise<AnalyticsSnapshot[]> {
const entities = await this.snapshotRepo.find({
where: { entityType, entityId },
order: { endDate: 'DESC' },
});
return entities.map((e) => this.mapper.toDomain(e));
}
async findByPeriod(
entityType: SnapshotEntityType,
entityId: string,
period: SnapshotPeriod,
startDate: Date,
endDate: Date,
): Promise<AnalyticsSnapshot | null> {
const qb = this.snapshotRepo
.createQueryBuilder('s')
.where('s.entityType = :entityType', { entityType })
.andWhere('s.entityId = :entityId', { entityId })
.andWhere('s.period = :period', { period })
.andWhere('s.startDate >= :startDate', { startDate })
.andWhere('s.endDate <= :endDate', { endDate })
.orderBy('s.endDate', 'DESC')
.limit(1);
const entity = await qb.getOne();
return entity ? this.mapper.toDomain(entity) : null;
}
async findLatest(
entityType: SnapshotEntityType,
entityId: string,
period: SnapshotPeriod,
): Promise<AnalyticsSnapshot | null> {
const entity = await this.snapshotRepo.findOne({
where: { entityType, entityId, period },
order: { endDate: 'DESC' },
});
return entity ? this.mapper.toDomain(entity) : null;
}
async getHistoricalSnapshots(
entityType: SnapshotEntityType,
entityId: string,
period: SnapshotPeriod,
limit: number,
): Promise<AnalyticsSnapshot[]> {
const entities = await this.snapshotRepo.find({
where: { entityType, entityId, period },
order: { endDate: 'DESC' },
...(typeof limit === 'number' ? { take: limit } : {}),
});
return entities.map((e) => this.mapper.toDomain(e));
}
}

View File

@@ -0,0 +1,80 @@
import { Between, MoreThanOrEqual, type FindOptionsWhere, type Repository } from 'typeorm';
import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository';
import type { EngagementAction, EngagementEntityType } from '@core/analytics/domain/types/EngagementEvent';
import { EngagementEvent } from '@core/analytics/domain/entities/EngagementEvent';
import { EngagementEventOrmEntity } from '../entities/EngagementEventOrmEntity';
import { EngagementEventOrmMapper } from '../mappers/EngagementEventOrmMapper';
export class TypeOrmEngagementRepository implements IEngagementRepository {
constructor(
private readonly engagementRepo: Repository<EngagementEventOrmEntity>,
private readonly mapper: EngagementEventOrmMapper,
) {}
async save(event: EngagementEvent): Promise<void> {
await this.engagementRepo.save(this.mapper.toOrmEntity(event));
}
async findById(id: string): Promise<EngagementEvent | null> {
const entity = await this.engagementRepo.findOneBy({ id });
return entity ? this.mapper.toDomain(entity) : null;
}
async findByEntityId(entityType: EngagementEntityType, entityId: string): Promise<EngagementEvent[]> {
const entities = await this.engagementRepo.find({
where: { entityType, entityId },
order: { timestamp: 'DESC' },
});
return entities.map((e) => this.mapper.toDomain(e));
}
async findByAction(action: EngagementAction): Promise<EngagementEvent[]> {
const entities = await this.engagementRepo.find({
where: { action },
order: { timestamp: 'DESC' },
});
return entities.map((e) => this.mapper.toDomain(e));
}
async findByDateRange(startDate: Date, endDate: Date): Promise<EngagementEvent[]> {
const entities = await this.engagementRepo.find({
where: { timestamp: Between(startDate, endDate) },
order: { timestamp: 'DESC' },
});
return entities.map((e) => this.mapper.toDomain(e));
}
async countByAction(action: EngagementAction, entityId?: string, since?: Date): Promise<number> {
const where: FindOptionsWhere<EngagementEventOrmEntity> = { action };
if (entityId) {
where.entityId = entityId;
}
if (since) {
where.timestamp = MoreThanOrEqual(since);
}
return this.engagementRepo.count({ where });
}
async getSponsorClicksForEntity(entityId: string, since?: Date): Promise<number> {
const actions: EngagementAction[] = ['click_sponsor_logo', 'click_sponsor_url'];
const qb = this.engagementRepo
.createQueryBuilder('e')
.where('e.entityId = :entityId', { entityId })
.andWhere('e.action IN (:...actions)', { actions });
if (since) {
qb.andWhere('e.timestamp >= :since', { since });
}
return qb.getCount();
}
}

View File

@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from 'vitest';
import type { Repository } from 'typeorm';
import { PageView } from '@core/analytics/domain/entities/PageView';
import { PageViewOrmEntity } from '../entities/PageViewOrmEntity';
import { PageViewOrmMapper } from '../mappers/PageViewOrmMapper';
import { TypeOrmPageViewRepository } from './TypeOrmPageViewRepository';
describe('TypeOrmPageViewRepository', () => {
it('saves mapped entities via injected mapper', async () => {
const orm = new PageViewOrmEntity();
orm.id = 'pv_1';
orm.entityType = 'league';
orm.entityId = 'league-1';
orm.visitorId = null;
orm.visitorType = 'anonymous';
orm.sessionId = 'sess-1';
orm.referrer = null;
orm.userAgent = null;
orm.country = null;
orm.timestamp = new Date('2025-01-01T00:00:00.000Z');
orm.durationMs = null;
const mapper: PageViewOrmMapper = {
toOrmEntity: vi.fn().mockReturnValue(orm),
toDomain: vi.fn(),
} as unknown as PageViewOrmMapper;
const repo: Repository<PageViewOrmEntity> = {
save: vi.fn().mockResolvedValue(orm),
} as unknown as Repository<PageViewOrmEntity>;
const sut = new TypeOrmPageViewRepository(repo, mapper);
const domain = PageView.create({
id: 'pv_1',
entityType: 'league',
entityId: 'league-1',
visitorType: 'anonymous',
sessionId: 'sess-1',
timestamp: new Date('2025-01-01T00:00:00.000Z'),
});
await sut.save(domain);
expect(mapper.toOrmEntity).toHaveBeenCalledTimes(1);
expect(repo.save).toHaveBeenCalledWith(orm);
});
it('findById maps entity -> domain', async () => {
const orm = new PageViewOrmEntity();
orm.id = 'pv_1';
orm.entityType = 'league';
orm.entityId = 'league-1';
orm.visitorId = null;
orm.visitorType = 'anonymous';
orm.sessionId = 'sess-1';
orm.referrer = null;
orm.userAgent = null;
orm.country = null;
orm.timestamp = new Date('2025-01-01T00:00:00.000Z');
orm.durationMs = null;
const domain = PageView.create({
id: 'pv_1',
entityType: 'league',
entityId: 'league-1',
visitorType: 'anonymous',
sessionId: 'sess-1',
timestamp: new Date('2025-01-01T00:00:00.000Z'),
});
const mapper: PageViewOrmMapper = {
toOrmEntity: vi.fn(),
toDomain: vi.fn().mockReturnValue(domain),
} as unknown as PageViewOrmMapper;
const repo: Repository<PageViewOrmEntity> = {
findOneBy: vi.fn().mockResolvedValue(orm),
} as unknown as Repository<PageViewOrmEntity>;
const sut = new TypeOrmPageViewRepository(repo, mapper);
const result = await sut.findById('pv_1');
expect(repo.findOneBy).toHaveBeenCalledWith({ id: 'pv_1' });
expect(mapper.toDomain).toHaveBeenCalledWith(orm);
expect(result?.id).toBe('pv_1');
});
});

View File

@@ -0,0 +1,82 @@
import { Between, MoreThanOrEqual, type Repository } from 'typeorm';
import type { IPageViewRepository } from '@core/analytics/application/repositories/IPageViewRepository';
import type { EntityType } from '@core/analytics/domain/types/PageView';
import { PageView } from '@core/analytics/domain/entities/PageView';
import { PageViewOrmEntity } from '../entities/PageViewOrmEntity';
import { PageViewOrmMapper } from '../mappers/PageViewOrmMapper';
type CountRow = { count: string | number } | null | undefined;
export class TypeOrmPageViewRepository implements IPageViewRepository {
constructor(
private readonly pageViewRepo: Repository<PageViewOrmEntity>,
private readonly mapper: PageViewOrmMapper,
) {}
async save(pageView: PageView): Promise<void> {
await this.pageViewRepo.save(this.mapper.toOrmEntity(pageView));
}
async findById(id: string): Promise<PageView | null> {
const entity = await this.pageViewRepo.findOneBy({ id });
return entity ? this.mapper.toDomain(entity) : null;
}
async findByEntityId(entityType: EntityType, entityId: string): Promise<PageView[]> {
const entities = await this.pageViewRepo.find({
where: { entityType, entityId },
order: { timestamp: 'DESC' },
});
return entities.map((e) => this.mapper.toDomain(e));
}
async findByDateRange(startDate: Date, endDate: Date): Promise<PageView[]> {
const entities = await this.pageViewRepo.find({
where: { timestamp: Between(startDate, endDate) },
order: { timestamp: 'DESC' },
});
return entities.map((e) => this.mapper.toDomain(e));
}
async findBySession(sessionId: string): Promise<PageView[]> {
const entities = await this.pageViewRepo.find({
where: { sessionId },
order: { timestamp: 'DESC' },
});
return entities.map((e) => this.mapper.toDomain(e));
}
async countByEntityId(entityType: EntityType, entityId: string, since?: Date): Promise<number> {
if (!since) {
return this.pageViewRepo.count({ where: { entityType, entityId } });
}
return this.pageViewRepo.count({
where: { entityType, entityId, timestamp: MoreThanOrEqual(since) },
});
}
async countUniqueVisitors(entityType: EntityType, entityId: string, since?: Date): Promise<number> {
const qb = this.pageViewRepo
.createQueryBuilder('pv')
.select('COUNT(DISTINCT COALESCE(pv.visitorId, pv.sessionId))', 'count')
.where('pv.entityType = :entityType', { entityType })
.andWhere('pv.entityId = :entityId', { entityId });
if (since) {
qb.andWhere('pv.timestamp >= :since', { since });
}
const row: CountRow = await qb.getRawOne();
const raw = row?.count;
if (typeof raw === 'number') return raw;
if (typeof raw === 'string') return Number.parseInt(raw, 10);
return 0;
}
}

View File

@@ -0,0 +1,128 @@
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
export function assertNonEmptyString(entityName: string, fieldName: string, value: unknown): asserts value is string {
if (value === undefined || value === null) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'missing' });
}
if (typeof value !== 'string') {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_string' });
}
if (value.trim().length === 0) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'empty_string' });
}
}
export function assertOptionalStringOrNull(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is string | null | undefined {
if (value === undefined || value === null) return;
if (typeof value !== 'string') {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_string' });
}
}
export function assertNumber(entityName: string, fieldName: string, value: unknown): asserts value is number {
if (value === undefined || value === null) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'missing' });
}
if (typeof value !== 'number' || Number.isNaN(value)) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_number' });
}
}
export function assertOptionalNumberOrNull(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is number | null | undefined {
if (value === undefined || value === null) return;
if (typeof value !== 'number' || Number.isNaN(value)) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_number' });
}
}
export function assertInteger(entityName: string, fieldName: string, value: unknown): asserts value is number {
if (value === undefined || value === null) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'missing' });
}
if (typeof value !== 'number' || !Number.isInteger(value)) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_integer' });
}
}
export function assertOptionalIntegerOrNull(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is number | null | undefined {
if (value === undefined || value === null) return;
if (typeof value !== 'number' || !Number.isInteger(value)) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_integer' });
}
}
export function assertBoolean(entityName: string, fieldName: string, value: unknown): asserts value is boolean {
if (value === undefined || value === null) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'missing' });
}
if (typeof value !== 'boolean') {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_boolean' });
}
}
export function assertDate(entityName: string, fieldName: string, value: unknown): asserts value is Date {
if (value === undefined || value === null) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'missing' });
}
if (!(value instanceof Date)) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_date' });
}
if (Number.isNaN(value.getTime())) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'invalid_date' });
}
}
export function assertEnumValue<TAllowed extends string>(
entityName: string,
fieldName: string,
value: unknown,
allowed: readonly TAllowed[],
): asserts value is TAllowed {
if (value === undefined || value === null) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'missing' });
}
if (typeof value !== 'string') {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_string' });
}
if (!allowed.includes(value as TAllowed)) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' });
}
}
export function assertRecord(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is Record<string, unknown> {
if (value === undefined || value === null) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'missing' });
}
if (typeof value !== 'object' || Array.isArray(value)) {
throw new TypeOrmAnalyticsSchemaError({ entityName, fieldName, reason: 'not_object' });
}
}
export const TypeOrmAnalyticsSchemaGuards = {
assertNonEmptyString,
assertOptionalStringOrNull,
assertNumber,
assertOptionalNumberOrNull,
assertInteger,
assertOptionalIntegerOrNull,
assertBoolean,
assertDate,
assertEnumValue,
assertRecord,
};