inmemory to postgres
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user