wip
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Query: GetEntityAnalyticsQuery
|
||||
*
|
||||
* Retrieves analytics data for an entity (league, driver, team, race).
|
||||
* Returns metrics formatted for display to sponsors and admins.
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
export interface GetEntityAnalyticsInput {
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
period?: SnapshotPeriod;
|
||||
since?: Date;
|
||||
}
|
||||
|
||||
export interface EntityAnalyticsOutput {
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
summary: {
|
||||
totalPageViews: number;
|
||||
uniqueVisitors: number;
|
||||
sponsorClicks: number;
|
||||
engagementScore: number;
|
||||
trustIndicator: 'high' | 'medium' | 'low';
|
||||
exposureValue: number;
|
||||
};
|
||||
trends: {
|
||||
pageViewsChange: number;
|
||||
uniqueVisitorsChange: number;
|
||||
engagementChange: number;
|
||||
};
|
||||
period: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
label: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class GetEntityAnalyticsQuery {
|
||||
constructor(
|
||||
private readonly pageViewRepository: IPageViewRepository,
|
||||
private readonly engagementRepository: IEngagementRepository,
|
||||
private readonly snapshotRepository: IAnalyticsSnapshotRepository
|
||||
) {}
|
||||
|
||||
async execute(input: GetEntityAnalyticsInput): Promise<EntityAnalyticsOutput> {
|
||||
const period = input.period ?? 'weekly';
|
||||
const now = new Date();
|
||||
const since = input.since ?? this.getPeriodStartDate(now, period);
|
||||
|
||||
// Get current metrics
|
||||
const totalPageViews = await this.pageViewRepository.countByEntityId(
|
||||
input.entityType,
|
||||
input.entityId,
|
||||
since
|
||||
);
|
||||
|
||||
const uniqueVisitors = await this.pageViewRepository.countUniqueVisitors(
|
||||
input.entityType,
|
||||
input.entityId,
|
||||
since
|
||||
);
|
||||
|
||||
const sponsorClicks = await this.engagementRepository.getSponsorClicksForEntity(
|
||||
input.entityId,
|
||||
since
|
||||
);
|
||||
|
||||
// Calculate engagement score (weighted sum of actions)
|
||||
const engagementScore = await this.calculateEngagementScore(input.entityId, since);
|
||||
|
||||
// Determine trust indicator
|
||||
const trustIndicator = this.determineTrustIndicator(totalPageViews, uniqueVisitors, engagementScore);
|
||||
|
||||
// Calculate exposure value (for sponsor ROI)
|
||||
const exposureValue = this.calculateExposureValue(totalPageViews, uniqueVisitors, sponsorClicks);
|
||||
|
||||
// Get previous period for trends
|
||||
const previousPeriodStart = this.getPreviousPeriodStart(since, period);
|
||||
const previousPageViews = await this.pageViewRepository.countByEntityId(
|
||||
input.entityType,
|
||||
input.entityId,
|
||||
previousPeriodStart
|
||||
) - totalPageViews;
|
||||
|
||||
const previousUniqueVisitors = await this.pageViewRepository.countUniqueVisitors(
|
||||
input.entityType,
|
||||
input.entityId,
|
||||
previousPeriodStart
|
||||
) - uniqueVisitors;
|
||||
|
||||
return {
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
summary: {
|
||||
totalPageViews,
|
||||
uniqueVisitors,
|
||||
sponsorClicks,
|
||||
engagementScore,
|
||||
trustIndicator,
|
||||
exposureValue,
|
||||
},
|
||||
trends: {
|
||||
pageViewsChange: this.calculatePercentageChange(previousPageViews, totalPageViews),
|
||||
uniqueVisitorsChange: this.calculatePercentageChange(previousUniqueVisitors, uniqueVisitors),
|
||||
engagementChange: 0, // Would need historical engagement data
|
||||
},
|
||||
period: {
|
||||
start: since,
|
||||
end: now,
|
||||
label: this.formatPeriodLabel(since, now),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private getPeriodStartDate(now: Date, period: SnapshotPeriod): Date {
|
||||
const start = new Date(now);
|
||||
switch (period) {
|
||||
case 'daily':
|
||||
start.setDate(start.getDate() - 1);
|
||||
break;
|
||||
case 'weekly':
|
||||
start.setDate(start.getDate() - 7);
|
||||
break;
|
||||
case 'monthly':
|
||||
start.setMonth(start.getMonth() - 1);
|
||||
break;
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
private getPreviousPeriodStart(currentStart: Date, period: SnapshotPeriod): Date {
|
||||
const start = new Date(currentStart);
|
||||
switch (period) {
|
||||
case 'daily':
|
||||
start.setDate(start.getDate() - 1);
|
||||
break;
|
||||
case 'weekly':
|
||||
start.setDate(start.getDate() - 7);
|
||||
break;
|
||||
case 'monthly':
|
||||
start.setMonth(start.getMonth() - 1);
|
||||
break;
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
private async calculateEngagementScore(entityId: string, since: Date): Promise<number> {
|
||||
// Base engagement from sponsor interactions
|
||||
const sponsorClicks = await this.engagementRepository.getSponsorClicksForEntity(entityId, since);
|
||||
return sponsorClicks * 10; // Weighted score
|
||||
}
|
||||
|
||||
private determineTrustIndicator(
|
||||
pageViews: number,
|
||||
uniqueVisitors: number,
|
||||
engagementScore: number
|
||||
): 'high' | 'medium' | 'low' {
|
||||
const engagementRate = pageViews > 0 ? engagementScore / pageViews : 0;
|
||||
const returningVisitorRate = pageViews > 0 ? (pageViews - uniqueVisitors) / pageViews : 0;
|
||||
|
||||
if (engagementRate > 0.1 && returningVisitorRate > 0.3) {
|
||||
return 'high';
|
||||
}
|
||||
if (engagementRate > 0.05 || returningVisitorRate > 0.1) {
|
||||
return 'medium';
|
||||
}
|
||||
return 'low';
|
||||
}
|
||||
|
||||
private calculateExposureValue(
|
||||
pageViews: number,
|
||||
uniqueVisitors: number,
|
||||
sponsorClicks: number
|
||||
): number {
|
||||
// Simple exposure value calculation (could be monetized)
|
||||
return (pageViews * 0.01) + (uniqueVisitors * 0.05) + (sponsorClicks * 0.50);
|
||||
}
|
||||
|
||||
private calculatePercentageChange(previous: number, current: number): number {
|
||||
if (previous === 0) return current > 0 ? 100 : 0;
|
||||
return Math.round(((current - previous) / previous) * 100);
|
||||
}
|
||||
|
||||
private formatPeriodLabel(start: Date, end: Date): string {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
return `${formatter.format(start)} - ${formatter.format(end)}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Use Case: RecordEngagementUseCase
|
||||
*
|
||||
* Records an engagement event when a visitor interacts with an entity.
|
||||
*/
|
||||
|
||||
import { EngagementEvent, type EngagementAction, type EngagementEntityType } from '../../domain/entities/EngagementEvent';
|
||||
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
|
||||
|
||||
export interface RecordEngagementInput {
|
||||
action: EngagementAction;
|
||||
entityType: EngagementEntityType;
|
||||
entityId: string;
|
||||
actorId?: string;
|
||||
actorType: 'anonymous' | 'driver' | 'sponsor';
|
||||
sessionId: string;
|
||||
metadata?: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
export interface RecordEngagementOutput {
|
||||
eventId: string;
|
||||
engagementWeight: number;
|
||||
}
|
||||
|
||||
export class RecordEngagementUseCase {
|
||||
constructor(private readonly engagementRepository: IEngagementRepository) {}
|
||||
|
||||
async execute(input: RecordEngagementInput): Promise<RecordEngagementOutput> {
|
||||
const eventId = `eng-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const event = EngagementEvent.create({
|
||||
id: eventId,
|
||||
action: input.action,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
actorId: input.actorId,
|
||||
actorType: input.actorType,
|
||||
sessionId: input.sessionId,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
|
||||
await this.engagementRepository.save(event);
|
||||
|
||||
return {
|
||||
eventId,
|
||||
engagementWeight: event.getEngagementWeight(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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 { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
|
||||
|
||||
export interface RecordPageViewInput {
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
visitorId?: string;
|
||||
visitorType: VisitorType;
|
||||
sessionId: string;
|
||||
referrer?: string;
|
||||
userAgent?: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
export interface RecordPageViewOutput {
|
||||
pageViewId: string;
|
||||
}
|
||||
|
||||
export class RecordPageViewUseCase {
|
||||
constructor(private readonly pageViewRepository: IPageViewRepository) {}
|
||||
|
||||
async execute(input: RecordPageViewInput): Promise<RecordPageViewOutput> {
|
||||
const pageViewId = `pv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const pageView = PageView.create({
|
||||
id: pageViewId,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
visitorId: input.visitorId,
|
||||
visitorType: input.visitorType,
|
||||
sessionId: input.sessionId,
|
||||
referrer: input.referrer,
|
||||
userAgent: input.userAgent,
|
||||
country: input.country,
|
||||
});
|
||||
|
||||
await this.pageViewRepository.save(pageView);
|
||||
|
||||
return { pageViewId };
|
||||
}
|
||||
}
|
||||
162
packages/analytics/domain/entities/AnalyticsSnapshot.ts
Normal file
162
packages/analytics/domain/entities/AnalyticsSnapshot.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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 {
|
||||
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 constructor(props: AnalyticsSnapshotProps) {
|
||||
this.id = props.id;
|
||||
this.entityType = props.entityType;
|
||||
this.entityId = props.entityId;
|
||||
this.period = props.period;
|
||||
this.startDate = props.startDate;
|
||||
this.endDate = props.endDate;
|
||||
this.metrics = props.metrics;
|
||||
this.createdAt = props.createdAt;
|
||||
}
|
||||
|
||||
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)}`;
|
||||
}
|
||||
}
|
||||
121
packages/analytics/domain/entities/EngagementEvent.ts
Normal file
121
packages/analytics/domain/entities/EngagementEvent.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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 {
|
||||
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 constructor(props: EngagementEventProps) {
|
||||
this.id = props.id;
|
||||
this.action = props.action;
|
||||
this.entityType = props.entityType;
|
||||
this.entityId = props.entityId;
|
||||
this.actorId = props.actorId;
|
||||
this.actorType = props.actorType;
|
||||
this.sessionId = props.sessionId;
|
||||
this.metadata = props.metadata;
|
||||
this.timestamp = props.timestamp;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
107
packages/analytics/domain/entities/PageView.ts
Normal file
107
packages/analytics/domain/entities/PageView.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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;
|
||||
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 constructor(props: PageViewProps) {
|
||||
this.id = props.id;
|
||||
this.entityType = props.entityType;
|
||||
this.entityId = props.entityId;
|
||||
this.visitorId = props.visitorId;
|
||||
this.visitorType = props.visitorType;
|
||||
this.sessionId = props.sessionId;
|
||||
this.referrer = props.referrer;
|
||||
this.userAgent = props.userAgent;
|
||||
this.country = props.country;
|
||||
this.timestamp = props.timestamp;
|
||||
this.durationMs = props.durationMs;
|
||||
}
|
||||
|
||||
static create(props: Omit<PageViewProps, 'timestamp'> & { timestamp?: Date }): PageView {
|
||||
this.validate(props);
|
||||
|
||||
return new PageView({
|
||||
...props,
|
||||
timestamp: props.timestamp ?? new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
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 new PageView({
|
||||
...this,
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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[]>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
26
packages/analytics/index.ts
Normal file
26
packages/analytics/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @gridpilot/analytics
|
||||
*
|
||||
* Analytics bounded context - tracks page views, engagement events,
|
||||
* and generates analytics snapshots for sponsor exposure metrics.
|
||||
*/
|
||||
|
||||
// Domain entities
|
||||
export * from './domain/entities/PageView';
|
||||
export * from './domain/entities/EngagementEvent';
|
||||
export * from './domain/entities/AnalyticsSnapshot';
|
||||
|
||||
// Domain repositories
|
||||
export * from './domain/repositories/IPageViewRepository';
|
||||
export * from './domain/repositories/IEngagementRepository';
|
||||
export * from './domain/repositories/IAnalyticsSnapshotRepository';
|
||||
|
||||
// Application use cases
|
||||
export * from './application/use-cases/RecordPageViewUseCase';
|
||||
export * from './application/use-cases/RecordEngagementUseCase';
|
||||
export * from './application/use-cases/GetEntityAnalyticsQuery';
|
||||
|
||||
// Infrastructure
|
||||
export * from './infrastructure/repositories/InMemoryPageViewRepository';
|
||||
export * from './infrastructure/repositories/InMemoryEngagementRepository';
|
||||
export * from './infrastructure/repositories/InMemoryAnalyticsSnapshotRepository';
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Infrastructure: InMemoryAnalyticsSnapshotRepository
|
||||
*
|
||||
* In-memory implementation of IAnalyticsSnapshotRepository for development/testing.
|
||||
*/
|
||||
|
||||
import type { IAnalyticsSnapshotRepository } from '../../domain/repositories/IAnalyticsSnapshotRepository';
|
||||
import { AnalyticsSnapshot, type SnapshotPeriod, type SnapshotEntityType } from '../../domain/entities/AnalyticsSnapshot';
|
||||
|
||||
export class InMemoryAnalyticsSnapshotRepository implements IAnalyticsSnapshotRepository {
|
||||
private snapshots: Map<string, AnalyticsSnapshot> = new Map();
|
||||
|
||||
async save(snapshot: AnalyticsSnapshot): Promise<void> {
|
||||
this.snapshots.set(snapshot.id, snapshot);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<AnalyticsSnapshot | null> {
|
||||
return this.snapshots.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findByEntity(entityType: SnapshotEntityType, entityId: string): Promise<AnalyticsSnapshot[]> {
|
||||
return Array.from(this.snapshots.values()).filter(
|
||||
s => s.entityType === entityType && s.entityId === entityId
|
||||
);
|
||||
}
|
||||
|
||||
async findByPeriod(
|
||||
entityType: SnapshotEntityType,
|
||||
entityId: string,
|
||||
period: SnapshotPeriod,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<AnalyticsSnapshot | null> {
|
||||
return Array.from(this.snapshots.values()).find(
|
||||
s => s.entityType === entityType &&
|
||||
s.entityId === entityId &&
|
||||
s.period === period &&
|
||||
s.startDate >= startDate &&
|
||||
s.endDate <= endDate
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
async findLatest(
|
||||
entityType: SnapshotEntityType,
|
||||
entityId: string,
|
||||
period: SnapshotPeriod
|
||||
): Promise<AnalyticsSnapshot | null> {
|
||||
const matching = Array.from(this.snapshots.values())
|
||||
.filter(s => s.entityType === entityType && s.entityId === entityId && s.period === period)
|
||||
.sort((a, b) => b.endDate.getTime() - a.endDate.getTime());
|
||||
|
||||
return matching[0] ?? null;
|
||||
}
|
||||
|
||||
async getHistoricalSnapshots(
|
||||
entityType: SnapshotEntityType,
|
||||
entityId: string,
|
||||
period: SnapshotPeriod,
|
||||
limit: number
|
||||
): Promise<AnalyticsSnapshot[]> {
|
||||
return Array.from(this.snapshots.values())
|
||||
.filter(s => s.entityType === entityType && s.entityId === entityId && s.period === period)
|
||||
.sort((a, b) => b.endDate.getTime() - a.endDate.getTime())
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
// Helper for testing
|
||||
clear(): void {
|
||||
this.snapshots.clear();
|
||||
}
|
||||
|
||||
// Helper for seeding demo data
|
||||
seed(snapshots: AnalyticsSnapshot[]): void {
|
||||
snapshots.forEach(s => this.snapshots.set(s.id, s));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Infrastructure: InMemoryEngagementRepository
|
||||
*
|
||||
* In-memory implementation of IEngagementRepository for development/testing.
|
||||
*/
|
||||
|
||||
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
|
||||
import { EngagementEvent, type EngagementAction, type EngagementEntityType } from '../../domain/entities/EngagementEvent';
|
||||
|
||||
export class InMemoryEngagementRepository implements IEngagementRepository {
|
||||
private events: Map<string, EngagementEvent> = new Map();
|
||||
|
||||
async save(event: EngagementEvent): Promise<void> {
|
||||
this.events.set(event.id, event);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<EngagementEvent | null> {
|
||||
return this.events.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findByEntityId(entityType: EngagementEntityType, entityId: string): Promise<EngagementEvent[]> {
|
||||
return Array.from(this.events.values()).filter(
|
||||
e => e.entityType === entityType && e.entityId === entityId
|
||||
);
|
||||
}
|
||||
|
||||
async findByAction(action: EngagementAction): Promise<EngagementEvent[]> {
|
||||
return Array.from(this.events.values()).filter(
|
||||
e => e.action === action
|
||||
);
|
||||
}
|
||||
|
||||
async findByDateRange(startDate: Date, endDate: Date): Promise<EngagementEvent[]> {
|
||||
return Array.from(this.events.values()).filter(
|
||||
e => e.timestamp >= startDate && e.timestamp <= endDate
|
||||
);
|
||||
}
|
||||
|
||||
async countByAction(action: EngagementAction, entityId?: string, since?: Date): Promise<number> {
|
||||
return Array.from(this.events.values()).filter(
|
||||
e => e.action === action &&
|
||||
(!entityId || e.entityId === entityId) &&
|
||||
(!since || e.timestamp >= since)
|
||||
).length;
|
||||
}
|
||||
|
||||
async getSponsorClicksForEntity(entityId: string, since?: Date): Promise<number> {
|
||||
return Array.from(this.events.values()).filter(
|
||||
e => e.entityId === entityId &&
|
||||
(e.action === 'click_sponsor_logo' || e.action === 'click_sponsor_url') &&
|
||||
(!since || e.timestamp >= since)
|
||||
).length;
|
||||
}
|
||||
|
||||
// Helper for testing
|
||||
clear(): void {
|
||||
this.events.clear();
|
||||
}
|
||||
|
||||
// Helper for seeding demo data
|
||||
seed(events: EngagementEvent[]): void {
|
||||
events.forEach(e => this.events.set(e.id, e));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Infrastructure: InMemoryPageViewRepository
|
||||
*
|
||||
* In-memory implementation of IPageViewRepository for development/testing.
|
||||
*/
|
||||
|
||||
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
|
||||
import { PageView, type EntityType } from '../../domain/entities/PageView';
|
||||
|
||||
export class InMemoryPageViewRepository implements IPageViewRepository {
|
||||
private pageViews: Map<string, PageView> = new Map();
|
||||
|
||||
async save(pageView: PageView): Promise<void> {
|
||||
this.pageViews.set(pageView.id, pageView);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<PageView | null> {
|
||||
return this.pageViews.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findByEntityId(entityType: EntityType, entityId: string): Promise<PageView[]> {
|
||||
return Array.from(this.pageViews.values()).filter(
|
||||
pv => pv.entityType === entityType && pv.entityId === entityId
|
||||
);
|
||||
}
|
||||
|
||||
async findByDateRange(startDate: Date, endDate: Date): Promise<PageView[]> {
|
||||
return Array.from(this.pageViews.values()).filter(
|
||||
pv => pv.timestamp >= startDate && pv.timestamp <= endDate
|
||||
);
|
||||
}
|
||||
|
||||
async findBySession(sessionId: string): Promise<PageView[]> {
|
||||
return Array.from(this.pageViews.values()).filter(
|
||||
pv => pv.sessionId === sessionId
|
||||
);
|
||||
}
|
||||
|
||||
async countByEntityId(entityType: EntityType, entityId: string, since?: Date): Promise<number> {
|
||||
return Array.from(this.pageViews.values()).filter(
|
||||
pv => pv.entityType === entityType &&
|
||||
pv.entityId === entityId &&
|
||||
(!since || pv.timestamp >= since)
|
||||
).length;
|
||||
}
|
||||
|
||||
async countUniqueVisitors(entityType: EntityType, entityId: string, since?: Date): Promise<number> {
|
||||
const visitors = new Set<string>();
|
||||
Array.from(this.pageViews.values())
|
||||
.filter(
|
||||
pv => pv.entityType === entityType &&
|
||||
pv.entityId === entityId &&
|
||||
(!since || pv.timestamp >= since)
|
||||
)
|
||||
.forEach(pv => {
|
||||
visitors.add(pv.visitorId ?? pv.sessionId);
|
||||
});
|
||||
return visitors.size;
|
||||
}
|
||||
|
||||
// Helper for testing
|
||||
clear(): void {
|
||||
this.pageViews.clear();
|
||||
}
|
||||
|
||||
// Helper for seeding demo data
|
||||
seed(pageViews: PageView[]): void {
|
||||
pageViews.forEach(pv => this.pageViews.set(pv.id, pv));
|
||||
}
|
||||
}
|
||||
14
packages/analytics/package.json
Normal file
14
packages/analytics/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@gridpilot/analytics",
|
||||
"version": "0.1.0",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./domain/*": "./domain/*",
|
||||
"./application/*": "./application/*",
|
||||
"./infrastructure/*": "./infrastructure/*"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
Reference in New Issue
Block a user