rename to core

This commit is contained in:
2025-12-15 13:46:07 +01:00
parent aedf58643d
commit 5c22f8820c
559 changed files with 415 additions and 767 deletions

View File

@@ -0,0 +1,149 @@
/**
* Domain Entity: AnalyticsSnapshot
*
* Aggregated analytics data for a specific entity over a time period.
* Pre-calculated metrics for sponsor dashboard and entity analytics.
*/
import type { IEntity } from '@gridpilot/shared/domain';
import type {
AnalyticsSnapshotProps,
AnalyticsMetrics,
SnapshotEntityType,
SnapshotPeriod,
} from '../types/AnalyticsSnapshot';
export type { SnapshotEntityType, SnapshotPeriod } from '../types/AnalyticsSnapshot';
import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId';
export class AnalyticsSnapshot implements IEntity<string> {
readonly id: string;
readonly entityType: SnapshotEntityType;
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.entityIdVo = AnalyticsEntityId.create(props.entityId);
this.period = props.period;
this.startDate = props.startDate;
this.endDate = props.endDate;
this.metrics = props.metrics;
this.createdAt = props.createdAt;
}
get entityId(): string {
return this.entityIdVo.value;
}
static create(props: Omit<AnalyticsSnapshotProps, 'createdAt'> & { createdAt?: Date }): AnalyticsSnapshot {
this.validate(props);
return new AnalyticsSnapshot({
...props,
createdAt: props.createdAt ?? new Date(),
});
}
static createEmpty(
id: string,
entityType: SnapshotEntityType,
entityId: string,
period: SnapshotPeriod,
startDate: Date,
endDate: Date
): AnalyticsSnapshot {
return new AnalyticsSnapshot({
id,
entityType,
entityId,
period,
startDate,
endDate,
metrics: {
pageViews: 0,
uniqueVisitors: 0,
avgSessionDuration: 0,
bounceRate: 0,
engagementScore: 0,
sponsorClicks: 0,
sponsorUrlClicks: 0,
socialShares: 0,
leagueJoins: 0,
raceRegistrations: 0,
exposureValue: 0,
},
createdAt: new Date(),
});
}
private static validate(props: Omit<AnalyticsSnapshotProps, 'createdAt'>): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('AnalyticsSnapshot ID is required');
}
if (!props.entityType) {
throw new Error('AnalyticsSnapshot entityType is required');
}
if (!props.entityId || props.entityId.trim().length === 0) {
throw new Error('AnalyticsSnapshot entityId is required');
}
if (!props.period) {
throw new Error('AnalyticsSnapshot period is required');
}
if (props.endDate < props.startDate) {
throw new Error('AnalyticsSnapshot endDate must be after startDate');
}
}
/**
* Calculate exposure score for sponsors (weighted combination of metrics)
*/
calculateExposureScore(): number {
const { pageViews, uniqueVisitors, sponsorClicks, sponsorUrlClicks, socialShares } = this.metrics;
return (
pageViews * 1 +
uniqueVisitors * 2 +
sponsorClicks * 10 +
sponsorUrlClicks * 25 +
socialShares * 5
);
}
/**
* Calculate trust indicator based on engagement quality
*/
getTrustIndicator(): 'high' | 'medium' | 'low' {
const { bounceRate, avgSessionDuration, engagementScore } = this.metrics;
if (bounceRate < 30 && avgSessionDuration > 120000 && engagementScore > 50) {
return 'high';
}
if (bounceRate < 60 && avgSessionDuration > 30000 && engagementScore > 20) {
return 'medium';
}
return 'low';
}
/**
* Get period comparison label
*/
getPeriodLabel(): string {
const formatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: this.period === 'monthly' ? 'numeric' : undefined
});
return `${formatter.format(this.startDate)} - ${formatter.format(this.endDate)}`;
}
}

View File

@@ -0,0 +1,111 @@
/**
* Domain Entity: EngagementEvent
*
* Represents user interactions beyond page views.
* Tracks clicks, downloads, sign-ups, and other engagement actions.
*/
import type { IEntity } from '@gridpilot/shared/domain';
import type {
EngagementAction,
EngagementEntityType,
EngagementEventProps,
} from '../types/EngagementEvent';
export type { EngagementAction, EngagementEntityType } from '../types/EngagementEvent';
import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId';
export class EngagementEvent implements IEntity<string> {
readonly id: string;
readonly action: EngagementAction;
readonly entityType: EngagementEntityType;
readonly actorId: string | undefined;
readonly actorType: 'anonymous' | 'driver' | 'sponsor';
readonly sessionId: string;
readonly metadata: Record<string, string | number | boolean> | undefined;
readonly timestamp: Date;
private readonly entityIdVo: AnalyticsEntityId;
private constructor(props: EngagementEventProps) {
this.id = props.id;
this.action = props.action;
this.entityType = props.entityType;
this.entityIdVo = AnalyticsEntityId.create(props.entityId);
this.actorId = props.actorId;
this.actorType = props.actorType;
this.sessionId = props.sessionId;
this.metadata = props.metadata;
this.timestamp = props.timestamp;
}
get entityId(): string {
return this.entityIdVo.value;
}
static create(props: Omit<EngagementEventProps, 'timestamp'> & { timestamp?: Date }): EngagementEvent {
this.validate(props);
return new EngagementEvent({
...props,
timestamp: props.timestamp ?? new Date(),
});
}
private static validate(props: Omit<EngagementEventProps, 'timestamp'>): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('EngagementEvent ID is required');
}
if (!props.action) {
throw new Error('EngagementEvent action is required');
}
if (!props.entityType) {
throw new Error('EngagementEvent entityType is required');
}
if (!props.entityId || props.entityId.trim().length === 0) {
throw new Error('EngagementEvent entityId is required');
}
if (!props.sessionId || props.sessionId.trim().length === 0) {
throw new Error('EngagementEvent sessionId is required');
}
}
/**
* Check if this is a sponsor-related engagement
*/
isSponsorEngagement(): boolean {
return this.action.startsWith('click_sponsor') ||
this.action === 'contact_sponsor' ||
this.entityType === 'sponsor' ||
this.entityType === 'sponsorship';
}
/**
* Check if this is a conversion event (high-value action)
*/
isConversionEvent(): boolean {
return ['join_league', 'register_race', 'click_sponsor_url', 'contact_sponsor'].includes(this.action);
}
/**
* Get engagement weight for analytics calculations
*/
getEngagementWeight(): number {
const weights: Record<EngagementAction, number> = {
'click_sponsor_logo': 2,
'click_sponsor_url': 5,
'download_livery_pack': 3,
'join_league': 10,
'register_race': 8,
'view_standings': 1,
'view_schedule': 1,
'share_social': 4,
'contact_sponsor': 15,
};
return weights[this.action] || 1;
}
}

View File

@@ -0,0 +1,131 @@
/**
* Domain Entity: PageView
*
* Represents a single page view event for analytics tracking.
* Captures visitor interactions with leagues, drivers, teams, races.
*/
import type { IEntity } from '@gridpilot/shared/domain';
import type { EntityType, VisitorType, PageViewProps } from '../types/PageView';
export type { EntityType, VisitorType } from '../types/PageView';
import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId';
import { AnalyticsSessionId } from '../value-objects/AnalyticsSessionId';
import { PageViewId } from '../value-objects/PageViewId';
export class PageView implements IEntity<string> {
readonly entityType: EntityType;
readonly visitorId: string | undefined;
readonly visitorType: VisitorType;
readonly referrer: string | undefined;
readonly userAgent: string | undefined;
readonly country: string | undefined;
readonly timestamp: Date;
readonly durationMs: number | undefined;
private readonly idVo: PageViewId;
private readonly entityIdVo: AnalyticsEntityId;
private readonly sessionIdVo: AnalyticsSessionId;
private constructor(props: PageViewProps) {
this.idVo = PageViewId.create(props.id);
this.entityType = props.entityType;
this.entityIdVo = AnalyticsEntityId.create(props.entityId);
this.visitorId = props.visitorId;
this.visitorType = props.visitorType;
this.sessionIdVo = AnalyticsSessionId.create(props.sessionId);
this.referrer = props.referrer;
this.userAgent = props.userAgent;
this.country = props.country;
this.timestamp = props.timestamp;
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);
const baseProps: PageViewProps = {
id: props.id,
entityType: props.entityType,
entityId: props.entityId,
visitorType: props.visitorType,
sessionId: props.sessionId,
timestamp: props.timestamp ?? new Date(),
...(props.visitorId !== undefined ? { visitorId: props.visitorId } : {}),
...(props.referrer !== undefined ? { referrer: props.referrer } : {}),
...(props.userAgent !== undefined ? { userAgent: props.userAgent } : {}),
...(props.country !== undefined ? { country: props.country } : {}),
...(props.durationMs !== undefined ? { durationMs: props.durationMs } : {}),
};
return new PageView(baseProps);
}
private static validate(props: Omit<PageViewProps, 'timestamp'>): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('PageView ID is required');
}
if (!props.entityType) {
throw new Error('PageView entityType is required');
}
if (!props.entityId || props.entityId.trim().length === 0) {
throw new Error('PageView entityId is required');
}
if (!props.sessionId || props.sessionId.trim().length === 0) {
throw new Error('PageView sessionId is required');
}
}
/**
* Update duration when visitor leaves page
*/
withDuration(durationMs: number): PageView {
if (durationMs < 0) {
throw new Error('Duration must be non-negative');
}
return PageView.create({
id: this.id,
entityType: this.entityType,
entityId: this.entityId,
visitorType: this.visitorType,
sessionId: this.sessionId,
timestamp: this.timestamp,
...(this.visitorId !== undefined ? { visitorId: this.visitorId } : {}),
...(this.referrer !== undefined ? { referrer: this.referrer } : {}),
...(this.userAgent !== undefined ? { userAgent: this.userAgent } : {}),
...(this.country !== undefined ? { country: this.country } : {}),
durationMs,
});
}
/**
* Check if this is a meaningful view (not a bounce)
*/
isMeaningfulView(): boolean {
return this.durationMs !== undefined && this.durationMs >= 5000; // 5+ seconds
}
/**
* Check if view came from external source
*/
isExternalReferral(): boolean {
if (!this.referrer) return false;
return !this.referrer.includes('gridpilot');
}
}

View File

@@ -0,0 +1,6 @@
export interface ILogger {
debug(message: string, ...args: any[]): void;
info(message: string, ...args: any[]): void;
warn(message: string, ...args: any[]): void;
error(message: string, ...args: any[]): void;
}

View File

@@ -0,0 +1,27 @@
/**
* Repository Interface: IAnalyticsSnapshotRepository
*
* Defines persistence operations for AnalyticsSnapshot entities.
*/
import type { AnalyticsSnapshot, SnapshotPeriod, SnapshotEntityType } from '../entities/AnalyticsSnapshot';
export interface IAnalyticsSnapshotRepository {
save(snapshot: AnalyticsSnapshot): Promise<void>;
findById(id: string): Promise<AnalyticsSnapshot | null>;
findByEntity(entityType: SnapshotEntityType, entityId: string): Promise<AnalyticsSnapshot[]>;
findByPeriod(
entityType: SnapshotEntityType,
entityId: string,
period: SnapshotPeriod,
startDate: Date,
endDate: Date
): Promise<AnalyticsSnapshot | null>;
findLatest(entityType: SnapshotEntityType, entityId: string, period: SnapshotPeriod): Promise<AnalyticsSnapshot | null>;
getHistoricalSnapshots(
entityType: SnapshotEntityType,
entityId: string,
period: SnapshotPeriod,
limit: number
): Promise<AnalyticsSnapshot[]>;
}

View File

@@ -0,0 +1,17 @@
/**
* Repository Interface: IEngagementRepository
*
* Defines persistence operations for EngagementEvent entities.
*/
import type { EngagementEvent, EngagementAction, EngagementEntityType } from '../entities/EngagementEvent';
export interface IEngagementRepository {
save(event: EngagementEvent): Promise<void>;
findById(id: string): Promise<EngagementEvent | null>;
findByEntityId(entityType: EngagementEntityType, entityId: string): Promise<EngagementEvent[]>;
findByAction(action: EngagementAction): Promise<EngagementEvent[]>;
findByDateRange(startDate: Date, endDate: Date): Promise<EngagementEvent[]>;
countByAction(action: EngagementAction, entityId?: string, since?: Date): Promise<number>;
getSponsorClicksForEntity(entityId: string, since?: Date): Promise<number>;
}

View File

@@ -0,0 +1,17 @@
/**
* Repository Interface: IPageViewRepository
*
* Defines persistence operations for PageView entities.
*/
import type { PageView, EntityType } from '../entities/PageView';
export interface IPageViewRepository {
save(pageView: PageView): Promise<void>;
findById(id: string): Promise<PageView | null>;
findByEntityId(entityType: EntityType, entityId: string): Promise<PageView[]>;
findByDateRange(startDate: Date, endDate: Date): Promise<PageView[]>;
findBySession(sessionId: string): Promise<PageView[]>;
countByEntityId(entityType: EntityType, entityId: string, since?: Date): Promise<number>;
countUniqueVisitors(entityType: EntityType, entityId: string, since?: Date): Promise<number>;
}

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,34 @@
/**
* Domain Types: PageView
*
* Pure type/config definitions used by the PageView entity.
* Kept in domain/types so domain/entities contains only entity classes.
*/
export enum EntityType {
LEAGUE = 'league',
DRIVER = 'driver',
TEAM = 'team',
RACE = 'race',
SPONSOR = 'sponsor',
}
export enum VisitorType {
ANONYMOUS = 'anonymous',
DRIVER = 'driver',
SPONSOR = '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,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,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,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;
}
}