This commit is contained in:
2025-12-11 13:50:38 +01:00
parent e4c1be628d
commit c7e5de40d6
212 changed files with 2965 additions and 763 deletions

View File

@@ -1,15 +1,16 @@
/**
* Query: GetEntityAnalyticsQuery
*
*
* Retrieves analytics data for an entity (league, driver, team, race).
* Returns metrics formatted for display to sponsors and admins.
*/
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
import type { IAnalyticsSnapshotRepository } from '../../domain/repositories/IAnalyticsSnapshotRepository';
import type { EntityType } from '../../domain/entities/PageView';
import type { SnapshotPeriod } from '../../domain/entities/AnalyticsSnapshot';
import type { EntityType } from '../../domain/types/PageView';
import type { SnapshotPeriod } from '../../domain/types/AnalyticsSnapshot';
export interface GetEntityAnalyticsInput {
entityType: EntityType;
@@ -41,7 +42,8 @@ export interface EntityAnalyticsOutput {
};
}
export class GetEntityAnalyticsQuery {
export class GetEntityAnalyticsQuery
implements AsyncUseCase<GetEntityAnalyticsInput, EntityAnalyticsOutput> {
constructor(
private readonly pageViewRepository: IPageViewRepository,
private readonly engagementRepository: IEngagementRepository,

View File

@@ -1,9 +1,10 @@
/**
* Use Case: RecordEngagementUseCase
*
*
* Records an engagement event when a visitor interacts with an entity.
*/
import type { AsyncUseCase } from '@gridpilot/shared/application';
import { EngagementEvent, type EngagementAction, type EngagementEntityType } from '../../domain/entities/EngagementEvent';
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
@@ -22,7 +23,8 @@ export interface RecordEngagementOutput {
engagementWeight: number;
}
export class RecordEngagementUseCase {
export class RecordEngagementUseCase
implements AsyncUseCase<RecordEngagementInput, RecordEngagementOutput> {
constructor(private readonly engagementRepository: IEngagementRepository) {}
async execute(input: RecordEngagementInput): Promise<RecordEngagementOutput> {

View File

@@ -1,10 +1,12 @@
/**
* Use Case: RecordPageViewUseCase
*
*
* Records a page view event when a visitor accesses an entity page.
*/
import { PageView, type EntityType, type VisitorType } from '../../domain/entities/PageView';
import type { AsyncUseCase } from '@gridpilot/shared/application';
import { PageView } from '../../domain/entities/PageView';
import type { EntityType, VisitorType } from '../../domain/types/PageView';
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
export interface RecordPageViewInput {
@@ -22,7 +24,8 @@ export interface RecordPageViewOutput {
pageViewId: string;
}
export class RecordPageViewUseCase {
export class RecordPageViewUseCase
implements AsyncUseCase<RecordPageViewInput, RecordPageViewOutput> {
constructor(private readonly pageViewRepository: IPageViewRepository) {}
async execute(input: RecordPageViewInput): Promise<RecordPageViewOutput> {

View File

@@ -1,52 +1,34 @@
/**
* Domain Entity: AnalyticsSnapshot
*
*
* Aggregated analytics data for a specific entity over a time period.
* Pre-calculated metrics for sponsor dashboard and entity analytics.
*/
export type SnapshotPeriod = 'daily' | 'weekly' | 'monthly';
export type SnapshotEntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor';
import type { IEntity } from '@gridpilot/shared/domain';
import type {
AnalyticsSnapshotProps,
AnalyticsMetrics,
SnapshotEntityType,
SnapshotPeriod,
} from '../types/AnalyticsSnapshot';
import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId';
export interface AnalyticsMetrics {
pageViews: number;
uniqueVisitors: number;
avgSessionDuration: number;
bounceRate: number;
engagementScore: number;
sponsorClicks: number;
sponsorUrlClicks: number;
socialShares: number;
leagueJoins: number;
raceRegistrations: number;
exposureValue: number;
}
export interface AnalyticsSnapshotProps {
id: string;
entityType: SnapshotEntityType;
entityId: string;
period: SnapshotPeriod;
startDate: Date;
endDate: Date;
metrics: AnalyticsMetrics;
createdAt: Date;
}
export class AnalyticsSnapshot {
export class AnalyticsSnapshot implements IEntity<string> {
readonly id: string;
readonly entityType: SnapshotEntityType;
readonly entityId: string;
readonly period: SnapshotPeriod;
readonly startDate: Date;
readonly endDate: Date;
readonly metrics: AnalyticsMetrics;
readonly createdAt: Date;
private readonly entityIdVo: AnalyticsEntityId;
private constructor(props: AnalyticsSnapshotProps) {
this.id = props.id;
this.entityType = props.entityType;
this.entityId = props.entityId;
this.entityIdVo = AnalyticsEntityId.create(props.entityId);
this.period = props.period;
this.startDate = props.startDate;
this.endDate = props.endDate;
@@ -54,6 +36,10 @@ export class AnalyticsSnapshot {
this.createdAt = props.createdAt;
}
get entityId(): string {
return this.entityIdVo.value;
}
static create(props: Omit<AnalyticsSnapshotProps, 'createdAt'> & { createdAt?: Date }): AnalyticsSnapshot {
this.validate(props);

View File

@@ -1,51 +1,35 @@
/**
* Domain Entity: EngagementEvent
*
*
* Represents user interactions beyond page views.
* Tracks clicks, downloads, sign-ups, and other engagement actions.
*/
export type EngagementAction =
| 'click_sponsor_logo'
| 'click_sponsor_url'
| 'download_livery_pack'
| 'join_league'
| 'register_race'
| 'view_standings'
| 'view_schedule'
| 'share_social'
| 'contact_sponsor';
import type { IEntity } from '@gridpilot/shared/domain';
import type {
EngagementAction,
EngagementEntityType,
EngagementEventProps,
} from '../types/EngagementEvent';
import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId';
export type EngagementEntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor' | 'sponsorship';
export interface EngagementEventProps {
id: string;
action: EngagementAction;
entityType: EngagementEntityType;
entityId: string;
actorId?: string;
actorType: 'anonymous' | 'driver' | 'sponsor';
sessionId: string;
metadata?: Record<string, string | number | boolean>;
timestamp: Date;
}
export class EngagementEvent {
export class EngagementEvent implements IEntity<string> {
readonly id: string;
readonly action: EngagementAction;
readonly entityType: EngagementEntityType;
readonly entityId: string;
readonly actorId?: string;
readonly actorType: 'anonymous' | 'driver' | 'sponsor';
readonly sessionId: string;
readonly metadata?: Record<string, string | number | boolean>;
readonly timestamp: Date;
private readonly entityIdVo: AnalyticsEntityId;
private constructor(props: EngagementEventProps) {
this.id = props.id;
this.action = props.action;
this.entityType = props.entityType;
this.entityId = props.entityId;
this.entityIdVo = AnalyticsEntityId.create(props.entityId);
this.actorId = props.actorId;
this.actorType = props.actorType;
this.sessionId = props.sessionId;
@@ -53,6 +37,10 @@ export class EngagementEvent {
this.timestamp = props.timestamp;
}
get entityId(): string {
return this.entityIdVo.value;
}
static create(props: Omit<EngagementEventProps, 'timestamp'> & { timestamp?: Date }): EngagementEvent {
this.validate(props);

View File

@@ -1,47 +1,37 @@
/**
* Domain Entity: PageView
*
*
* Represents a single page view event for analytics tracking.
* Captures visitor interactions with leagues, drivers, teams, races.
*/
export type EntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor';
export type VisitorType = 'anonymous' | 'driver' | 'sponsor';
import type { IEntity } from '@gridpilot/shared/domain';
import type { EntityType, VisitorType, PageViewProps } from '../types/PageView';
import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId';
import { AnalyticsSessionId } from '../value-objects/AnalyticsSessionId';
import { PageViewId } from '../value-objects/PageViewId';
export interface PageViewProps {
id: string;
entityType: EntityType;
entityId: string;
visitorId?: string;
visitorType: VisitorType;
sessionId: string;
referrer?: string;
userAgent?: string;
country?: string;
timestamp: Date;
durationMs?: number;
}
export class PageView {
readonly id: string;
export class PageView implements IEntity<string> {
readonly entityType: EntityType;
readonly entityId: string;
readonly visitorId?: string;
readonly visitorType: VisitorType;
readonly sessionId: string;
readonly referrer?: string;
readonly userAgent?: string;
readonly country?: string;
readonly timestamp: Date;
readonly durationMs?: number;
private readonly idVo: PageViewId;
private readonly entityIdVo: AnalyticsEntityId;
private readonly sessionIdVo: AnalyticsSessionId;
private constructor(props: PageViewProps) {
this.id = props.id;
this.idVo = PageViewId.create(props.id);
this.entityType = props.entityType;
this.entityId = props.entityId;
this.entityIdVo = AnalyticsEntityId.create(props.entityId);
this.visitorId = props.visitorId;
this.visitorType = props.visitorType;
this.sessionId = props.sessionId;
this.sessionIdVo = AnalyticsSessionId.create(props.sessionId);
this.referrer = props.referrer;
this.userAgent = props.userAgent;
this.country = props.country;
@@ -49,6 +39,18 @@ export class PageView {
this.durationMs = props.durationMs;
}
get id(): string {
return this.idVo.value;
}
get entityId(): string {
return this.entityIdVo.value;
}
get sessionId(): string {
return this.sessionIdVo.value;
}
static create(props: Omit<PageViewProps, 'timestamp'> & { timestamp?: Date }): PageView {
this.validate(props);

View File

@@ -0,0 +1,35 @@
/**
* Domain Types: AnalyticsSnapshot
*
* Pure type/config definitions used by the AnalyticsSnapshot entity.
* Kept in domain/types so domain/entities contains only entity classes.
*/
export type SnapshotPeriod = 'daily' | 'weekly' | 'monthly';
export type SnapshotEntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor';
export interface AnalyticsMetrics {
pageViews: number;
uniqueVisitors: number;
avgSessionDuration: number;
bounceRate: number;
engagementScore: number;
sponsorClicks: number;
sponsorUrlClicks: number;
socialShares: number;
leagueJoins: number;
raceRegistrations: number;
exposureValue: number;
}
export interface AnalyticsSnapshotProps {
id: string;
entityType: SnapshotEntityType;
entityId: string;
period: SnapshotPeriod;
startDate: Date;
endDate: Date;
metrics: AnalyticsMetrics;
createdAt: Date;
}

View File

@@ -0,0 +1,37 @@
/**
* Domain Types: EngagementEvent
*
* Pure type/config definitions used by the EngagementEvent entity.
* Kept in domain/types so domain/entities contains only entity classes.
*/
export type EngagementAction =
| 'click_sponsor_logo'
| 'click_sponsor_url'
| 'download_livery_pack'
| 'join_league'
| 'register_race'
| 'view_standings'
| 'view_schedule'
| 'share_social'
| 'contact_sponsor';
export type EngagementEntityType =
| 'league'
| 'driver'
| 'team'
| 'race'
| 'sponsor'
| 'sponsorship';
export interface EngagementEventProps {
id: string;
action: EngagementAction;
entityType: EngagementEntityType;
entityId: string;
actorId?: string;
actorType: 'anonymous' | 'driver' | 'sponsor';
sessionId: string;
metadata?: Record<string, string | number | boolean>;
timestamp: Date;
}

View File

@@ -0,0 +1,24 @@
/**
* Domain Types: PageView
*
* Pure type/config definitions used by the PageView entity.
* Kept in domain/types so domain/entities contains only entity classes.
*/
export type EntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor';
export type VisitorType = 'anonymous' | 'driver' | 'sponsor';
export interface PageViewProps {
id: string;
entityType: EntityType;
entityId: string;
visitorId?: string;
visitorType: VisitorType;
sessionId: string;
referrer?: string;
userAgent?: string;
country?: string;
timestamp: Date;
durationMs?: number;
}

View File

@@ -0,0 +1,29 @@
import { AnalyticsEntityId } from './AnalyticsEntityId';
describe('AnalyticsEntityId', () => {
it('creates a valid AnalyticsEntityId from a non-empty string', () => {
const id = AnalyticsEntityId.create('entity_123');
expect(id.value).toBe('entity_123');
});
it('trims whitespace from the raw value', () => {
const id = AnalyticsEntityId.create(' entity_456 ');
expect(id.value).toBe('entity_456');
});
it('throws for empty or whitespace-only strings', () => {
expect(() => AnalyticsEntityId.create('')).toThrow(Error);
expect(() => AnalyticsEntityId.create(' ')).toThrow(Error);
});
it('compares equality based on underlying value', () => {
const a = AnalyticsEntityId.create('entity_1');
const b = AnalyticsEntityId.create('entity_1');
const c = AnalyticsEntityId.create('entity_2');
expect(a.equals(b)).toBe(true);
expect(a.equals(c)).toBe(false);
});
});

View File

@@ -0,0 +1,37 @@
import type { IValueObject } from '@gridpilot/shared/domain';
export interface AnalyticsEntityIdProps {
value: string;
}
/**
* Value Object: AnalyticsEntityId
*
* Represents the ID of an entity (league, driver, team, race, sponsor)
* within the analytics bounded context.
*/
export class AnalyticsEntityId implements IValueObject<AnalyticsEntityIdProps> {
public readonly props: AnalyticsEntityIdProps;
private constructor(value: string) {
this.props = { value };
}
static create(raw: string): AnalyticsEntityId {
const value = raw.trim();
if (!value) {
throw new Error('AnalyticsEntityId must be a non-empty string');
}
return new AnalyticsEntityId(value);
}
get value(): string {
return this.props.value;
}
equals(other: IValueObject<AnalyticsEntityIdProps>): boolean {
return this.props.value === other.props.value;
}
}

View File

@@ -0,0 +1,29 @@
import { AnalyticsSessionId } from './AnalyticsSessionId';
describe('AnalyticsSessionId', () => {
it('creates a valid AnalyticsSessionId from a non-empty string', () => {
const id = AnalyticsSessionId.create('session_123');
expect(id.value).toBe('session_123');
});
it('trims whitespace from the raw value', () => {
const id = AnalyticsSessionId.create(' session_456 ');
expect(id.value).toBe('session_456');
});
it('throws for empty or whitespace-only strings', () => {
expect(() => AnalyticsSessionId.create('')).toThrow(Error);
expect(() => AnalyticsSessionId.create(' ')).toThrow(Error);
});
it('compares equality based on underlying value', () => {
const a = AnalyticsSessionId.create('session_1');
const b = AnalyticsSessionId.create('session_1');
const c = AnalyticsSessionId.create('session_2');
expect(a.equals(b)).toBe(true);
expect(a.equals(c)).toBe(false);
});
});

View File

@@ -0,0 +1,36 @@
import type { IValueObject } from '@gridpilot/shared/domain';
export interface AnalyticsSessionIdProps {
value: string;
}
/**
* Value Object: AnalyticsSessionId
*
* Represents an analytics session identifier within the analytics bounded context.
*/
export class AnalyticsSessionId implements IValueObject<AnalyticsSessionIdProps> {
public readonly props: AnalyticsSessionIdProps;
private constructor(value: string) {
this.props = { value };
}
static create(raw: string): AnalyticsSessionId {
const value = raw.trim();
if (!value) {
throw new Error('AnalyticsSessionId must be a non-empty string');
}
return new AnalyticsSessionId(value);
}
get value(): string {
return this.props.value;
}
equals(other: IValueObject<AnalyticsSessionIdProps>): boolean {
return this.props.value === other.props.value;
}
}

View File

@@ -0,0 +1,29 @@
import { PageViewId } from './PageViewId';
describe('PageViewId', () => {
it('creates a valid PageViewId from a non-empty string', () => {
const id = PageViewId.create('pv_123');
expect(id.value).toBe('pv_123');
});
it('trims whitespace from the raw value', () => {
const id = PageViewId.create(' pv_456 ');
expect(id.value).toBe('pv_456');
});
it('throws for empty or whitespace-only strings', () => {
expect(() => PageViewId.create('')).toThrow(Error);
expect(() => PageViewId.create(' ')).toThrow(Error);
});
it('compares equality based on underlying value', () => {
const a = PageViewId.create('pv_1');
const b = PageViewId.create('pv_1');
const c = PageViewId.create('pv_2');
expect(a.equals(b)).toBe(true);
expect(a.equals(c)).toBe(false);
});
});

View File

@@ -0,0 +1,36 @@
import type { IValueObject } from '@gridpilot/shared/domain';
export interface PageViewIdProps {
value: string;
}
/**
* Value Object: PageViewId
*
* Represents the identifier of a PageView within the analytics bounded context.
*/
export class PageViewId implements IValueObject<PageViewIdProps> {
public readonly props: PageViewIdProps;
private constructor(value: string) {
this.props = { value };
}
static create(raw: string): PageViewId {
const value = raw.trim();
if (!value) {
throw new Error('PageViewId must be a non-empty string');
}
return new PageViewId(value);
}
get value(): string {
return this.props.value;
}
equals(other: IValueObject<PageViewIdProps>): boolean {
return this.props.value === other.props.value;
}
}