This commit is contained in:
2025-12-10 12:38:55 +01:00
parent 0f7fe67d3c
commit fbbcf414a4
87 changed files with 11972 additions and 390 deletions

View File

@@ -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)}`;
}
}

View File

@@ -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(),
};
}
}

View File

@@ -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 };
}
}

View 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)}`;
}
}

View 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;
}
}

View 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');
}
}

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,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';

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View 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": {}
}

View File

@@ -0,0 +1,390 @@
/**
* Domain Entity: Achievement
*
* Represents an achievement that can be earned by users.
* Achievements are categorized by role (driver, steward, admin) and type.
*/
export type AchievementCategory = 'driver' | 'steward' | 'admin' | 'community';
export type AchievementRarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
export interface AchievementProps {
id: string;
name: string;
description: string;
category: AchievementCategory;
rarity: AchievementRarity;
iconUrl?: string;
points: number;
requirements: AchievementRequirement[];
isSecret: boolean;
createdAt: Date;
}
export interface AchievementRequirement {
type: 'races_completed' | 'wins' | 'podiums' | 'clean_races' | 'protests_handled' |
'leagues_managed' | 'seasons_completed' | 'consecutive_clean' | 'rating_threshold' |
'trust_threshold' | 'events_stewarded' | 'members_managed' | 'championships_won';
value: number;
operator: '>=' | '>' | '=' | '<' | '<=';
}
export class Achievement {
readonly id: string;
readonly name: string;
readonly description: string;
readonly category: AchievementCategory;
readonly rarity: AchievementRarity;
readonly iconUrl?: string;
readonly points: number;
readonly requirements: AchievementRequirement[];
readonly isSecret: boolean;
readonly createdAt: Date;
private constructor(props: AchievementProps) {
this.id = props.id;
this.name = props.name;
this.description = props.description;
this.category = props.category;
this.rarity = props.rarity;
this.iconUrl = props.iconUrl;
this.points = props.points;
this.requirements = props.requirements;
this.isSecret = props.isSecret;
this.createdAt = props.createdAt;
}
static create(props: Omit<AchievementProps, 'createdAt'> & { createdAt?: Date }): Achievement {
if (!props.id || props.id.trim().length === 0) {
throw new Error('Achievement ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new Error('Achievement name is required');
}
if (props.requirements.length === 0) {
throw new Error('Achievement must have at least one requirement');
}
return new Achievement({
...props,
createdAt: props.createdAt ?? new Date(),
});
}
/**
* Check if user stats meet all requirements
*/
checkRequirements(stats: Record<string, number>): boolean {
return this.requirements.every(req => {
const value = stats[req.type] ?? 0;
switch (req.operator) {
case '>=': return value >= req.value;
case '>': return value > req.value;
case '=': return value === req.value;
case '<': return value < req.value;
case '<=': return value <= req.value;
default: return false;
}
});
}
/**
* Get rarity color for display
*/
getRarityColor(): string {
const colors: Record<AchievementRarity, string> = {
common: '#9CA3AF',
uncommon: '#22C55E',
rare: '#3B82F6',
epic: '#A855F7',
legendary: '#F59E0B',
};
return colors[this.rarity];
}
/**
* Get display text for hidden achievements
*/
getDisplayName(): string {
if (this.isSecret) {
return '???';
}
return this.name;
}
getDisplayDescription(): string {
if (this.isSecret) {
return 'This achievement is secret. Keep playing to unlock it!';
}
return this.description;
}
}
// Predefined achievements for drivers
export const DRIVER_ACHIEVEMENTS: Omit<AchievementProps, 'createdAt'>[] = [
{
id: 'first-race',
name: 'First Steps',
description: 'Complete your first race',
category: 'driver',
rarity: 'common',
points: 10,
requirements: [{ type: 'races_completed', value: 1, operator: '>=' }],
isSecret: false,
},
{
id: 'ten-races',
name: 'Getting Started',
description: 'Complete 10 races',
category: 'driver',
rarity: 'common',
points: 25,
requirements: [{ type: 'races_completed', value: 10, operator: '>=' }],
isSecret: false,
},
{
id: 'fifty-races',
name: 'Regular Racer',
description: 'Complete 50 races',
category: 'driver',
rarity: 'uncommon',
points: 50,
requirements: [{ type: 'races_completed', value: 50, operator: '>=' }],
isSecret: false,
},
{
id: 'hundred-races',
name: 'Veteran',
description: 'Complete 100 races',
category: 'driver',
rarity: 'rare',
points: 100,
requirements: [{ type: 'races_completed', value: 100, operator: '>=' }],
isSecret: false,
},
{
id: 'first-win',
name: 'Victory Lane',
description: 'Win your first race',
category: 'driver',
rarity: 'uncommon',
points: 50,
requirements: [{ type: 'wins', value: 1, operator: '>=' }],
isSecret: false,
},
{
id: 'ten-wins',
name: 'Serial Winner',
description: 'Win 10 races',
category: 'driver',
rarity: 'rare',
points: 100,
requirements: [{ type: 'wins', value: 10, operator: '>=' }],
isSecret: false,
},
{
id: 'first-podium',
name: 'Podium Finisher',
description: 'Finish on the podium',
category: 'driver',
rarity: 'common',
points: 25,
requirements: [{ type: 'podiums', value: 1, operator: '>=' }],
isSecret: false,
},
{
id: 'clean-streak-5',
name: 'Clean Racer',
description: 'Complete 5 consecutive races without incidents',
category: 'driver',
rarity: 'uncommon',
points: 50,
requirements: [{ type: 'consecutive_clean', value: 5, operator: '>=' }],
isSecret: false,
},
{
id: 'clean-streak-10',
name: 'Safety First',
description: 'Complete 10 consecutive races without incidents',
category: 'driver',
rarity: 'rare',
points: 100,
requirements: [{ type: 'consecutive_clean', value: 10, operator: '>=' }],
isSecret: false,
},
{
id: 'championship-win',
name: 'Champion',
description: 'Win a championship',
category: 'driver',
rarity: 'epic',
points: 200,
requirements: [{ type: 'championships_won', value: 1, operator: '>=' }],
isSecret: false,
},
{
id: 'triple-crown',
name: 'Triple Crown',
description: 'Win 3 championships',
category: 'driver',
rarity: 'legendary',
points: 500,
requirements: [{ type: 'championships_won', value: 3, operator: '>=' }],
isSecret: false,
},
{
id: 'elite-driver',
name: 'Elite Driver',
description: 'Reach Elite driver rating',
category: 'driver',
rarity: 'epic',
points: 250,
requirements: [{ type: 'rating_threshold', value: 90, operator: '>=' }],
isSecret: false,
},
];
// Predefined achievements for stewards
export const STEWARD_ACHIEVEMENTS: Omit<AchievementProps, 'createdAt'>[] = [
{
id: 'first-protest',
name: 'Justice Served',
description: 'Handle your first protest',
category: 'steward',
rarity: 'common',
points: 15,
requirements: [{ type: 'protests_handled', value: 1, operator: '>=' }],
isSecret: false,
},
{
id: 'ten-protests',
name: 'Fair Judge',
description: 'Handle 10 protests',
category: 'steward',
rarity: 'uncommon',
points: 50,
requirements: [{ type: 'protests_handled', value: 10, operator: '>=' }],
isSecret: false,
},
{
id: 'fifty-protests',
name: 'Senior Steward',
description: 'Handle 50 protests',
category: 'steward',
rarity: 'rare',
points: 100,
requirements: [{ type: 'protests_handled', value: 50, operator: '>=' }],
isSecret: false,
},
{
id: 'hundred-protests',
name: 'Chief Steward',
description: 'Handle 100 protests',
category: 'steward',
rarity: 'epic',
points: 200,
requirements: [{ type: 'protests_handled', value: 100, operator: '>=' }],
isSecret: false,
},
{
id: 'event-steward-10',
name: 'Event Official',
description: 'Steward 10 race events',
category: 'steward',
rarity: 'uncommon',
points: 50,
requirements: [{ type: 'events_stewarded', value: 10, operator: '>=' }],
isSecret: false,
},
{
id: 'trusted-steward',
name: 'Trusted Steward',
description: 'Achieve highly-trusted status',
category: 'steward',
rarity: 'rare',
points: 150,
requirements: [{ type: 'trust_threshold', value: 75, operator: '>=' }],
isSecret: false,
},
];
// Predefined achievements for admins
export const ADMIN_ACHIEVEMENTS: Omit<AchievementProps, 'createdAt'>[] = [
{
id: 'first-league',
name: 'League Founder',
description: 'Create your first league',
category: 'admin',
rarity: 'common',
points: 25,
requirements: [{ type: 'leagues_managed', value: 1, operator: '>=' }],
isSecret: false,
},
{
id: 'first-season',
name: 'Season Organizer',
description: 'Complete your first full season',
category: 'admin',
rarity: 'uncommon',
points: 50,
requirements: [{ type: 'seasons_completed', value: 1, operator: '>=' }],
isSecret: false,
},
{
id: 'five-seasons',
name: 'Experienced Organizer',
description: 'Complete 5 seasons',
category: 'admin',
rarity: 'rare',
points: 100,
requirements: [{ type: 'seasons_completed', value: 5, operator: '>=' }],
isSecret: false,
},
{
id: 'ten-seasons',
name: 'Veteran Organizer',
description: 'Complete 10 seasons',
category: 'admin',
rarity: 'epic',
points: 200,
requirements: [{ type: 'seasons_completed', value: 10, operator: '>=' }],
isSecret: false,
},
{
id: 'large-league',
name: 'Community Builder',
description: 'Manage a league with 50+ members',
category: 'admin',
rarity: 'rare',
points: 150,
requirements: [{ type: 'members_managed', value: 50, operator: '>=' }],
isSecret: false,
},
{
id: 'huge-league',
name: 'Empire Builder',
description: 'Manage a league with 100+ members',
category: 'admin',
rarity: 'epic',
points: 300,
requirements: [{ type: 'members_managed', value: 100, operator: '>=' }],
isSecret: false,
},
];
// Community achievements (for all roles)
export const COMMUNITY_ACHIEVEMENTS: Omit<AchievementProps, 'createdAt'>[] = [
{
id: 'community-leader',
name: 'Community Leader',
description: 'Achieve community leader trust level',
category: 'community',
rarity: 'legendary',
points: 500,
requirements: [{ type: 'trust_threshold', value: 90, operator: '>=' }],
isSecret: false,
},
];

View File

@@ -0,0 +1,151 @@
/**
* Domain Entity: SponsorAccount
*
* Represents a sponsor's login account in the identity bounded context.
* Separate from the racing domain's Sponsor entity which holds business data.
*/
import { UserId } from '../value-objects/UserId';
import type { EmailValidationResult } from '../value-objects/EmailAddress';
import { validateEmail } from '../value-objects/EmailAddress';
export interface SponsorAccountProps {
id: UserId;
sponsorId: string; // Reference to racing domain's Sponsor entity
email: string;
passwordHash: string;
companyName: string;
isActive: boolean;
createdAt: Date;
lastLoginAt?: Date;
}
export class SponsorAccount {
private readonly id: UserId;
private readonly sponsorId: string;
private email: string;
private passwordHash: string;
private companyName: string;
private isActive: boolean;
private readonly createdAt: Date;
private lastLoginAt?: Date;
private constructor(props: SponsorAccountProps) {
this.id = props.id;
this.sponsorId = props.sponsorId;
this.email = props.email;
this.passwordHash = props.passwordHash;
this.companyName = props.companyName;
this.isActive = props.isActive;
this.createdAt = props.createdAt;
this.lastLoginAt = props.lastLoginAt;
}
public static create(props: Omit<SponsorAccountProps, 'createdAt' | 'isActive'> & {
createdAt?: Date;
isActive?: boolean;
}): SponsorAccount {
if (!props.sponsorId || !props.sponsorId.trim()) {
throw new Error('SponsorAccount sponsorId is required');
}
if (!props.companyName || !props.companyName.trim()) {
throw new Error('SponsorAccount companyName is required');
}
if (!props.passwordHash || !props.passwordHash.trim()) {
throw new Error('SponsorAccount passwordHash is required');
}
const emailResult: EmailValidationResult = validateEmail(props.email);
if (!emailResult.success) {
throw new Error(emailResult.error);
}
return new SponsorAccount({
...props,
email: emailResult.email,
createdAt: props.createdAt ?? new Date(),
isActive: props.isActive ?? true,
});
}
public getId(): UserId {
return this.id;
}
public getSponsorId(): string {
return this.sponsorId;
}
public getEmail(): string {
return this.email;
}
public getPasswordHash(): string {
return this.passwordHash;
}
public getCompanyName(): string {
return this.companyName;
}
public getIsActive(): boolean {
return this.isActive;
}
public getCreatedAt(): Date {
return this.createdAt;
}
public getLastLoginAt(): Date | undefined {
return this.lastLoginAt;
}
public canLogin(): boolean {
return this.isActive;
}
public recordLogin(): SponsorAccount {
return new SponsorAccount({
id: this.id,
sponsorId: this.sponsorId,
email: this.email,
passwordHash: this.passwordHash,
companyName: this.companyName,
isActive: this.isActive,
createdAt: this.createdAt,
lastLoginAt: new Date(),
});
}
public deactivate(): SponsorAccount {
return new SponsorAccount({
id: this.id,
sponsorId: this.sponsorId,
email: this.email,
passwordHash: this.passwordHash,
companyName: this.companyName,
isActive: false,
createdAt: this.createdAt,
lastLoginAt: this.lastLoginAt,
});
}
public updatePassword(newPasswordHash: string): SponsorAccount {
if (!newPasswordHash || !newPasswordHash.trim()) {
throw new Error('Password hash cannot be empty');
}
return new SponsorAccount({
id: this.id,
sponsorId: this.sponsorId,
email: this.email,
passwordHash: newPasswordHash,
companyName: this.companyName,
isActive: this.isActive,
createdAt: this.createdAt,
lastLoginAt: this.lastLoginAt,
});
}
}

View File

@@ -0,0 +1,83 @@
/**
* Domain Entity: UserAchievement
*
* Represents an achievement earned by a specific user.
*/
export interface UserAchievementProps {
id: string;
userId: string;
achievementId: string;
earnedAt: Date;
notifiedAt?: Date;
progress?: number; // For partial progress tracking (0-100)
}
export class UserAchievement {
readonly id: string;
readonly userId: string;
readonly achievementId: string;
readonly earnedAt: Date;
readonly notifiedAt?: Date;
readonly progress: number;
private constructor(props: UserAchievementProps) {
this.id = props.id;
this.userId = props.userId;
this.achievementId = props.achievementId;
this.earnedAt = props.earnedAt;
this.notifiedAt = props.notifiedAt;
this.progress = props.progress ?? 100;
}
static create(props: UserAchievementProps): UserAchievement {
if (!props.id || props.id.trim().length === 0) {
throw new Error('UserAchievement ID is required');
}
if (!props.userId || props.userId.trim().length === 0) {
throw new Error('UserAchievement userId is required');
}
if (!props.achievementId || props.achievementId.trim().length === 0) {
throw new Error('UserAchievement achievementId is required');
}
return new UserAchievement(props);
}
/**
* Mark achievement as notified to user
*/
markNotified(): UserAchievement {
return new UserAchievement({
...this,
notifiedAt: new Date(),
});
}
/**
* Update progress towards achievement
*/
updateProgress(progress: number): UserAchievement {
const clampedProgress = Math.max(0, Math.min(100, progress));
return new UserAchievement({
...this,
progress: clampedProgress,
});
}
/**
* Check if achievement is fully earned
*/
isComplete(): boolean {
return this.progress >= 100;
}
/**
* Check if user has been notified
*/
isNotified(): boolean {
return this.notifiedAt !== undefined;
}
}

View File

@@ -0,0 +1,28 @@
/**
* Repository Interface: IAchievementRepository
*
* Defines operations for Achievement and UserAchievement entities
*/
import type { Achievement, AchievementCategory } from '../entities/Achievement';
import type { UserAchievement } from '../entities/UserAchievement';
export interface IAchievementRepository {
// Achievement operations
findAchievementById(id: string): Promise<Achievement | null>;
findAllAchievements(): Promise<Achievement[]>;
findAchievementsByCategory(category: AchievementCategory): Promise<Achievement[]>;
createAchievement(achievement: Achievement): Promise<Achievement>;
// UserAchievement operations
findUserAchievementById(id: string): Promise<UserAchievement | null>;
findUserAchievementsByUserId(userId: string): Promise<UserAchievement[]>;
findUserAchievementByUserAndAchievement(userId: string, achievementId: string): Promise<UserAchievement | null>;
hasUserEarnedAchievement(userId: string, achievementId: string): Promise<boolean>;
createUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement>;
updateUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement>;
// Stats
getAchievementLeaderboard(limit: number): Promise<{ userId: string; points: number; count: number }[]>;
getUserAchievementStats(userId: string): Promise<{ total: number; points: number; byCategory: Record<AchievementCategory, number> }>;
}

View File

@@ -0,0 +1,16 @@
/**
* Repository Interface: ISponsorAccountRepository
*
* Defines persistence operations for SponsorAccount entities.
*/
import type { SponsorAccount } from '../entities/SponsorAccount';
import type { UserId } from '../value-objects/UserId';
export interface ISponsorAccountRepository {
save(account: SponsorAccount): Promise<void>;
findById(id: UserId): Promise<SponsorAccount | null>;
findBySponsorId(sponsorId: string): Promise<SponsorAccount | null>;
findByEmail(email: string): Promise<SponsorAccount | null>;
delete(id: UserId): Promise<void>;
}

View File

@@ -0,0 +1,49 @@
/**
* Repository Interface: IUserRatingRepository
*
* Defines operations for UserRating value objects
*/
import type { UserRating } from '../value-objects/UserRating';
export interface IUserRatingRepository {
/**
* Find rating by user ID
*/
findByUserId(userId: string): Promise<UserRating | null>;
/**
* Find ratings by multiple user IDs
*/
findByUserIds(userIds: string[]): Promise<UserRating[]>;
/**
* Save or update a user rating
*/
save(rating: UserRating): Promise<UserRating>;
/**
* Get top rated drivers
*/
getTopDrivers(limit: number): Promise<UserRating[]>;
/**
* Get top trusted users
*/
getTopTrusted(limit: number): Promise<UserRating[]>;
/**
* Get eligible stewards (based on trust and fairness thresholds)
*/
getEligibleStewards(): Promise<UserRating[]>;
/**
* Get ratings by driver tier
*/
findByDriverTier(tier: 'rookie' | 'amateur' | 'semi-pro' | 'pro' | 'elite'): Promise<UserRating[]>;
/**
* Delete rating by user ID
*/
delete(userId: string): Promise<void>;
}

View File

@@ -0,0 +1,255 @@
/**
* Value Object: UserRating
*
* Multi-dimensional rating system for users covering:
* - Driver skill: racing ability, lap times, consistency
* - Admin competence: league management, event organization
* - Steward fairness: protest handling, penalty consistency
* - Trust score: reliability, sportsmanship, rule compliance
* - Fairness score: clean racing, incident involvement
*/
export interface RatingDimension {
value: number; // Current rating value (0-100 scale)
confidence: number; // Confidence level based on sample size (0-1)
sampleSize: number; // Number of events contributing to this rating
trend: 'rising' | 'stable' | 'falling';
lastUpdated: Date;
}
export interface UserRatingProps {
userId: string;
driver: RatingDimension;
admin: RatingDimension;
steward: RatingDimension;
trust: RatingDimension;
fairness: RatingDimension;
overallReputation: number;
createdAt: Date;
updatedAt: Date;
}
const DEFAULT_DIMENSION: RatingDimension = {
value: 50,
confidence: 0,
sampleSize: 0,
trend: 'stable',
lastUpdated: new Date(),
};
export class UserRating {
readonly userId: string;
readonly driver: RatingDimension;
readonly admin: RatingDimension;
readonly steward: RatingDimension;
readonly trust: RatingDimension;
readonly fairness: RatingDimension;
readonly overallReputation: number;
readonly createdAt: Date;
readonly updatedAt: Date;
private constructor(props: UserRatingProps) {
this.userId = props.userId;
this.driver = props.driver;
this.admin = props.admin;
this.steward = props.steward;
this.trust = props.trust;
this.fairness = props.fairness;
this.overallReputation = props.overallReputation;
this.createdAt = props.createdAt;
this.updatedAt = props.updatedAt;
}
static create(userId: string): UserRating {
if (!userId || userId.trim().length === 0) {
throw new Error('UserRating userId is required');
}
const now = new Date();
return new UserRating({
userId,
driver: { ...DEFAULT_DIMENSION, lastUpdated: now },
admin: { ...DEFAULT_DIMENSION, lastUpdated: now },
steward: { ...DEFAULT_DIMENSION, lastUpdated: now },
trust: { ...DEFAULT_DIMENSION, lastUpdated: now },
fairness: { ...DEFAULT_DIMENSION, lastUpdated: now },
overallReputation: 50,
createdAt: now,
updatedAt: now,
});
}
static restore(props: UserRatingProps): UserRating {
return new UserRating(props);
}
/**
* Update driver rating based on race performance
*/
updateDriverRating(
newValue: number,
weight: number = 1
): UserRating {
const updated = this.updateDimension(this.driver, newValue, weight);
return this.withUpdates({ driver: updated });
}
/**
* Update admin rating based on league management feedback
*/
updateAdminRating(
newValue: number,
weight: number = 1
): UserRating {
const updated = this.updateDimension(this.admin, newValue, weight);
return this.withUpdates({ admin: updated });
}
/**
* Update steward rating based on protest handling feedback
*/
updateStewardRating(
newValue: number,
weight: number = 1
): UserRating {
const updated = this.updateDimension(this.steward, newValue, weight);
return this.withUpdates({ steward: updated });
}
/**
* Update trust score based on reliability and sportsmanship
*/
updateTrustScore(
newValue: number,
weight: number = 1
): UserRating {
const updated = this.updateDimension(this.trust, newValue, weight);
return this.withUpdates({ trust: updated });
}
/**
* Update fairness score based on clean racing incidents
*/
updateFairnessScore(
newValue: number,
weight: number = 1
): UserRating {
const updated = this.updateDimension(this.fairness, newValue, weight);
return this.withUpdates({ fairness: updated });
}
/**
* Calculate weighted overall reputation
*/
calculateOverallReputation(): number {
// Weight dimensions by confidence and importance
const weights = {
driver: 0.25 * this.driver.confidence,
admin: 0.15 * this.admin.confidence,
steward: 0.15 * this.steward.confidence,
trust: 0.25 * this.trust.confidence,
fairness: 0.20 * this.fairness.confidence,
};
const totalWeight = Object.values(weights).reduce((sum, w) => sum + w, 0);
if (totalWeight === 0) {
return 50; // Default when no ratings yet
}
const weightedSum =
this.driver.value * weights.driver +
this.admin.value * weights.admin +
this.steward.value * weights.steward +
this.trust.value * weights.trust +
this.fairness.value * weights.fairness;
return Math.round(weightedSum / totalWeight);
}
/**
* Get rating tier for display
*/
getDriverTier(): 'rookie' | 'amateur' | 'semi-pro' | 'pro' | 'elite' {
if (this.driver.value >= 90) return 'elite';
if (this.driver.value >= 75) return 'pro';
if (this.driver.value >= 60) return 'semi-pro';
if (this.driver.value >= 40) return 'amateur';
return 'rookie';
}
/**
* Get trust level for matchmaking
*/
getTrustLevel(): 'unverified' | 'trusted' | 'highly-trusted' | 'community-leader' {
if (this.trust.value >= 90 && this.trust.sampleSize >= 50) return 'community-leader';
if (this.trust.value >= 75 && this.trust.sampleSize >= 20) return 'highly-trusted';
if (this.trust.value >= 60 && this.trust.sampleSize >= 5) return 'trusted';
return 'unverified';
}
/**
* Check if user is eligible to be a steward
*/
canBeSteward(): boolean {
return (
this.trust.value >= 70 &&
this.fairness.value >= 70 &&
this.trust.sampleSize >= 10
);
}
/**
* Check if user is eligible to be an admin
*/
canBeAdmin(): boolean {
return (
this.trust.value >= 60 &&
this.trust.sampleSize >= 5
);
}
private updateDimension(
dimension: RatingDimension,
newValue: number,
weight: number
): RatingDimension {
const clampedValue = Math.max(0, Math.min(100, newValue));
const newSampleSize = dimension.sampleSize + weight;
// Exponential moving average with decay based on sample size
const alpha = Math.min(0.3, 1 / (dimension.sampleSize + 1));
const updatedValue = dimension.value * (1 - alpha) + clampedValue * alpha;
// Calculate confidence (asymptotic to 1)
const confidence = 1 - Math.exp(-newSampleSize / 20);
// Determine trend
const valueDiff = updatedValue - dimension.value;
let trend: 'rising' | 'stable' | 'falling' = 'stable';
if (valueDiff > 2) trend = 'rising';
if (valueDiff < -2) trend = 'falling';
return {
value: Math.round(updatedValue * 10) / 10,
confidence: Math.round(confidence * 100) / 100,
sampleSize: newSampleSize,
trend,
lastUpdated: new Date(),
};
}
private withUpdates(updates: Partial<UserRatingProps>): UserRating {
const newRating = new UserRating({
...this,
...updates,
updatedAt: new Date(),
});
// Recalculate overall reputation
return new UserRating({
...newRating,
overallReputation: newRating.calculateOverallReputation(),
});
}
}

View File

@@ -1,6 +1,18 @@
export * from './domain/value-objects/EmailAddress';
export * from './domain/value-objects/UserId';
export * from './domain/value-objects/UserRating';
export * from './domain/entities/User';
export * from './domain/entities/SponsorAccount';
export * from './domain/entities/Achievement';
export * from './domain/entities/UserAchievement';
export * from './domain/repositories/IUserRepository';
export * from './domain/repositories/ISponsorAccountRepository';
export * from './domain/repositories/IUserRatingRepository';
export * from './domain/repositories/IAchievementRepository';
export * from './infrastructure/repositories/InMemoryUserRatingRepository';
export * from './infrastructure/repositories/InMemoryAchievementRepository';
export * from './application/dto/AuthenticatedUserDTO';
export * from './application/dto/AuthSessionDTO';

View File

@@ -0,0 +1,170 @@
/**
* Infrastructure Adapter: InMemoryAchievementRepository
*
* In-memory implementation of IAchievementRepository
*/
import {
Achievement,
AchievementCategory,
DRIVER_ACHIEVEMENTS,
STEWARD_ACHIEVEMENTS,
ADMIN_ACHIEVEMENTS,
COMMUNITY_ACHIEVEMENTS,
} from '../../domain/entities/Achievement';
import { UserAchievement } from '../../domain/entities/UserAchievement';
import type { IAchievementRepository } from '../../domain/repositories/IAchievementRepository';
export class InMemoryAchievementRepository implements IAchievementRepository {
private achievements: Map<string, Achievement> = new Map();
private userAchievements: Map<string, UserAchievement> = new Map();
constructor() {
// Seed with predefined achievements
this.seedAchievements();
}
private seedAchievements(): void {
const allAchievements = [
...DRIVER_ACHIEVEMENTS,
...STEWARD_ACHIEVEMENTS,
...ADMIN_ACHIEVEMENTS,
...COMMUNITY_ACHIEVEMENTS,
];
for (const props of allAchievements) {
const achievement = Achievement.create(props);
this.achievements.set(achievement.id, achievement);
}
}
// Achievement operations
async findAchievementById(id: string): Promise<Achievement | null> {
return this.achievements.get(id) ?? null;
}
async findAllAchievements(): Promise<Achievement[]> {
return Array.from(this.achievements.values());
}
async findAchievementsByCategory(category: AchievementCategory): Promise<Achievement[]> {
return Array.from(this.achievements.values())
.filter(a => a.category === category);
}
async createAchievement(achievement: Achievement): Promise<Achievement> {
if (this.achievements.has(achievement.id)) {
throw new Error('Achievement with this ID already exists');
}
this.achievements.set(achievement.id, achievement);
return achievement;
}
// UserAchievement operations
async findUserAchievementById(id: string): Promise<UserAchievement | null> {
return this.userAchievements.get(id) ?? null;
}
async findUserAchievementsByUserId(userId: string): Promise<UserAchievement[]> {
return Array.from(this.userAchievements.values())
.filter(ua => ua.userId === userId);
}
async findUserAchievementByUserAndAchievement(
userId: string,
achievementId: string
): Promise<UserAchievement | null> {
for (const ua of this.userAchievements.values()) {
if (ua.userId === userId && ua.achievementId === achievementId) {
return ua;
}
}
return null;
}
async hasUserEarnedAchievement(userId: string, achievementId: string): Promise<boolean> {
const ua = await this.findUserAchievementByUserAndAchievement(userId, achievementId);
return ua !== null && ua.isComplete();
}
async createUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement> {
if (this.userAchievements.has(userAchievement.id)) {
throw new Error('UserAchievement with this ID already exists');
}
this.userAchievements.set(userAchievement.id, userAchievement);
return userAchievement;
}
async updateUserAchievement(userAchievement: UserAchievement): Promise<UserAchievement> {
if (!this.userAchievements.has(userAchievement.id)) {
throw new Error('UserAchievement not found');
}
this.userAchievements.set(userAchievement.id, userAchievement);
return userAchievement;
}
// Stats
async getAchievementLeaderboard(limit: number): Promise<{ userId: string; points: number; count: number }[]> {
const userStats = new Map<string, { points: number; count: number }>();
for (const ua of this.userAchievements.values()) {
if (!ua.isComplete()) continue;
const achievement = this.achievements.get(ua.achievementId);
if (!achievement) continue;
const existing = userStats.get(ua.userId) ?? { points: 0, count: 0 };
userStats.set(ua.userId, {
points: existing.points + achievement.points,
count: existing.count + 1,
});
}
return Array.from(userStats.entries())
.map(([userId, stats]) => ({ userId, ...stats }))
.sort((a, b) => b.points - a.points)
.slice(0, limit);
}
async getUserAchievementStats(userId: string): Promise<{
total: number;
points: number;
byCategory: Record<AchievementCategory, number>
}> {
const userAchievements = await this.findUserAchievementsByUserId(userId);
const completedAchievements = userAchievements.filter(ua => ua.isComplete());
const byCategory: Record<AchievementCategory, number> = {
driver: 0,
steward: 0,
admin: 0,
community: 0,
};
let points = 0;
for (const ua of completedAchievements) {
const achievement = this.achievements.get(ua.achievementId);
if (achievement) {
points += achievement.points;
byCategory[achievement.category]++;
}
}
return {
total: completedAchievements.length,
points,
byCategory,
};
}
// Test helpers
clearUserAchievements(): void {
this.userAchievements.clear();
}
clear(): void {
this.achievements.clear();
this.userAchievements.clear();
}
}

View File

@@ -0,0 +1,48 @@
/**
* Infrastructure: InMemorySponsorAccountRepository
*
* In-memory implementation of ISponsorAccountRepository for development/testing.
*/
import type { ISponsorAccountRepository } from '../../domain/repositories/ISponsorAccountRepository';
import type { SponsorAccount } from '../../domain/entities/SponsorAccount';
import type { UserId } from '../../domain/value-objects/UserId';
export class InMemorySponsorAccountRepository implements ISponsorAccountRepository {
private accounts: Map<string, SponsorAccount> = new Map();
async save(account: SponsorAccount): Promise<void> {
this.accounts.set(account.getId().value, account);
}
async findById(id: UserId): Promise<SponsorAccount | null> {
return this.accounts.get(id.value) ?? null;
}
async findBySponsorId(sponsorId: string): Promise<SponsorAccount | null> {
return Array.from(this.accounts.values()).find(
a => a.getSponsorId() === sponsorId
) ?? null;
}
async findByEmail(email: string): Promise<SponsorAccount | null> {
const normalizedEmail = email.toLowerCase().trim();
return Array.from(this.accounts.values()).find(
a => a.getEmail().toLowerCase() === normalizedEmail
) ?? null;
}
async delete(id: UserId): Promise<void> {
this.accounts.delete(id.value);
}
// Helper for testing
clear(): void {
this.accounts.clear();
}
// Helper for seeding demo data
seed(accounts: SponsorAccount[]): void {
accounts.forEach(a => this.accounts.set(a.getId().value, a));
}
}

View File

@@ -0,0 +1,65 @@
/**
* Infrastructure Adapter: InMemoryUserRatingRepository
*
* In-memory implementation of IUserRatingRepository
*/
import { UserRating } from '../../domain/value-objects/UserRating';
import type { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
export class InMemoryUserRatingRepository implements IUserRatingRepository {
private ratings: Map<string, UserRating> = new Map();
async findByUserId(userId: string): Promise<UserRating | null> {
return this.ratings.get(userId) ?? null;
}
async findByUserIds(userIds: string[]): Promise<UserRating[]> {
const results: UserRating[] = [];
for (const userId of userIds) {
const rating = this.ratings.get(userId);
if (rating) {
results.push(rating);
}
}
return results;
}
async save(rating: UserRating): Promise<UserRating> {
this.ratings.set(rating.userId, rating);
return rating;
}
async getTopDrivers(limit: number): Promise<UserRating[]> {
return Array.from(this.ratings.values())
.filter(r => r.driver.sampleSize > 0)
.sort((a, b) => b.driver.value - a.driver.value)
.slice(0, limit);
}
async getTopTrusted(limit: number): Promise<UserRating[]> {
return Array.from(this.ratings.values())
.filter(r => r.trust.sampleSize > 0)
.sort((a, b) => b.trust.value - a.trust.value)
.slice(0, limit);
}
async getEligibleStewards(): Promise<UserRating[]> {
return Array.from(this.ratings.values())
.filter(r => r.canBeSteward());
}
async findByDriverTier(tier: 'rookie' | 'amateur' | 'semi-pro' | 'pro' | 'elite'): Promise<UserRating[]> {
return Array.from(this.ratings.values())
.filter(r => r.getDriverTier() === tier);
}
async delete(userId: string): Promise<void> {
this.ratings.delete(userId);
}
// Test helper
clear(): void {
this.ratings.clear();
}
}

View File

@@ -31,6 +31,13 @@ export type NotificationType =
| 'team_invite' // You were invited to a team
| 'team_join_request' // Someone requested to join your team
| 'team_join_approved' // Your team join request was approved
// Sponsorship-related
| 'sponsorship_request_received' // A sponsor wants to sponsor you/your entity
| 'sponsorship_request_accepted' // Your sponsorship request was accepted
| 'sponsorship_request_rejected' // Your sponsorship request was rejected
| 'sponsorship_request_withdrawn' // A sponsor withdrew their request
| 'sponsorship_activated' // Sponsorship is now active
| 'sponsorship_payment_received' // Payment received for sponsorship
// System
| 'system_announcement'; // System-wide announcement
@@ -60,6 +67,12 @@ export function getNotificationTypeTitle(type: NotificationType): string {
team_invite: 'Team Invitation',
team_join_request: 'Team Join Request',
team_join_approved: 'Team Request Approved',
sponsorship_request_received: 'Sponsorship Request',
sponsorship_request_accepted: 'Sponsorship Accepted',
sponsorship_request_rejected: 'Sponsorship Rejected',
sponsorship_request_withdrawn: 'Sponsorship Withdrawn',
sponsorship_activated: 'Sponsorship Active',
sponsorship_payment_received: 'Payment Received',
system_announcement: 'Announcement',
};
return titles[type];
@@ -91,6 +104,12 @@ export function getNotificationTypePriority(type: NotificationType): number {
team_invite: 5,
team_join_request: 4,
team_join_approved: 6,
sponsorship_request_received: 7,
sponsorship_request_accepted: 8,
sponsorship_request_rejected: 6,
sponsorship_request_withdrawn: 5,
sponsorship_activated: 7,
sponsorship_payment_received: 8,
system_announcement: 10,
};
return priorities[type];

View File

@@ -33,6 +33,13 @@ export * from './use-cases/GetRaceProtestsQuery';
export * from './use-cases/GetRacePenaltiesQuery';
export * from './use-cases/RequestProtestDefenseUseCase';
export * from './use-cases/SubmitProtestDefenseUseCase';
export * from './use-cases/GetSponsorDashboardQuery';
export * from './use-cases/GetSponsorSponsorshipsQuery';
export * from './use-cases/ApplyForSponsorshipUseCase';
export * from './use-cases/AcceptSponsorshipRequestUseCase';
export * from './use-cases/RejectSponsorshipRequestUseCase';
export * from './use-cases/GetPendingSponsorshipRequestsQuery';
export * from './use-cases/GetEntitySponsorshipPricingQuery';
// Export ports
export * from './ports/DriverRatingProvider';

View File

@@ -0,0 +1,76 @@
/**
* Use Case: AcceptSponsorshipRequestUseCase
*
* Allows an entity owner to accept a sponsorship request.
* This creates an active sponsorship and notifies the sponsor.
*/
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
export interface AcceptSponsorshipRequestDTO {
requestId: string;
respondedBy: string; // driverId of the person accepting
}
export interface AcceptSponsorshipRequestResultDTO {
requestId: string;
sponsorshipId: string;
status: 'accepted';
acceptedAt: Date;
platformFee: number;
netAmount: number;
}
export class AcceptSponsorshipRequestUseCase {
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
) {}
async execute(dto: AcceptSponsorshipRequestDTO): Promise<AcceptSponsorshipRequestResultDTO> {
// Find the request
const request = await this.sponsorshipRequestRepo.findById(dto.requestId);
if (!request) {
throw new Error('Sponsorship request not found');
}
if (!request.isPending()) {
throw new Error(`Cannot accept a ${request.status} sponsorship request`);
}
// Accept the request
const acceptedRequest = request.accept(dto.respondedBy);
await this.sponsorshipRequestRepo.update(acceptedRequest);
// If this is a season sponsorship, create the SeasonSponsorship record
let sponsorshipId = `spons_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
if (request.entityType === 'season') {
const sponsorship = SeasonSponsorship.create({
id: sponsorshipId,
seasonId: request.entityId,
sponsorId: request.sponsorId,
tier: request.tier,
pricing: request.offeredAmount,
status: 'active',
});
await this.seasonSponsorshipRepo.create(sponsorship);
}
// TODO: In a real implementation, we would:
// 1. Create notification for the sponsor
// 2. Process payment
// 3. Update wallet balances
return {
requestId: acceptedRequest.id,
sponsorshipId,
status: 'accepted',
acceptedAt: acceptedRequest.respondedAt!,
platformFee: acceptedRequest.getPlatformFee().amount,
netAmount: acceptedRequest.getNetAmount().amount,
};
}
}

View File

@@ -0,0 +1,99 @@
/**
* Use Case: ApplyForSponsorshipUseCase
*
* Allows a sponsor to apply for a sponsorship slot on any entity
* (driver, team, race, or season/league).
*/
import { SponsorshipRequest, type SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import { Money, type Currency } from '../../domain/value-objects/Money';
export interface ApplyForSponsorshipDTO {
sponsorId: string;
entityType: SponsorableEntityType;
entityId: string;
tier: SponsorshipTier;
offeredAmount: number; // in cents
currency?: Currency;
message?: string;
}
export interface ApplyForSponsorshipResultDTO {
requestId: string;
status: 'pending';
createdAt: Date;
}
export class ApplyForSponsorshipUseCase {
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository,
private readonly sponsorRepo: ISponsorRepository,
) {}
async execute(dto: ApplyForSponsorshipDTO): Promise<ApplyForSponsorshipResultDTO> {
// Validate sponsor exists
const sponsor = await this.sponsorRepo.findById(dto.sponsorId);
if (!sponsor) {
throw new Error('Sponsor not found');
}
// Check if entity accepts sponsorship applications
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
if (!pricing) {
throw new Error('This entity has not set up sponsorship pricing');
}
if (!pricing.acceptingApplications) {
throw new Error('This entity is not currently accepting sponsorship applications');
}
// Check if the requested tier slot is available
const slotAvailable = pricing.isSlotAvailable(dto.tier);
if (!slotAvailable) {
throw new Error(`No ${dto.tier} sponsorship slots are available`);
}
// Check if sponsor already has a pending request for this entity
const hasPending = await this.sponsorshipRequestRepo.hasPendingRequest(
dto.sponsorId,
dto.entityType,
dto.entityId
);
if (hasPending) {
throw new Error('You already have a pending sponsorship request for this entity');
}
// Validate offered amount meets minimum price
const minPrice = pricing.getPrice(dto.tier);
if (minPrice && dto.offeredAmount < minPrice.amount) {
throw new Error(`Offered amount must be at least ${minPrice.format()}`);
}
// Create the sponsorship request
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const offeredAmount = Money.create(dto.offeredAmount, dto.currency ?? 'USD');
const request = SponsorshipRequest.create({
id: requestId,
sponsorId: dto.sponsorId,
entityType: dto.entityType,
entityId: dto.entityId,
tier: dto.tier,
offeredAmount,
message: dto.message,
});
await this.sponsorshipRequestRepo.create(request);
return {
requestId: request.id,
status: 'pending',
createdAt: request.createdAt,
};
}
}

View File

@@ -0,0 +1,112 @@
/**
* Query: GetEntitySponsorshipPricingQuery
*
* Retrieves sponsorship pricing configuration for any entity.
* Used by sponsors to see available slots and prices.
*/
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
export interface GetEntitySponsorshipPricingDTO {
entityType: SponsorableEntityType;
entityId: string;
}
export interface SponsorshipSlotDTO {
tier: SponsorshipTier;
price: number;
currency: string;
formattedPrice: string;
benefits: string[];
available: boolean;
maxSlots: number;
filledSlots: number;
pendingRequests: number;
}
export interface GetEntitySponsorshipPricingResultDTO {
entityType: SponsorableEntityType;
entityId: string;
acceptingApplications: boolean;
customRequirements?: string;
mainSlot?: SponsorshipSlotDTO;
secondarySlot?: SponsorshipSlotDTO;
}
export class GetEntitySponsorshipPricingQuery {
constructor(
private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository,
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
) {}
async execute(dto: GetEntitySponsorshipPricingDTO): Promise<GetEntitySponsorshipPricingResultDTO | null> {
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
if (!pricing) {
return null;
}
// Count pending requests by tier
const pendingRequests = await this.sponsorshipRequestRepo.findPendingByEntity(
dto.entityType,
dto.entityId
);
const pendingMainCount = pendingRequests.filter(r => r.tier === 'main').length;
const pendingSecondaryCount = pendingRequests.filter(r => r.tier === 'secondary').length;
// Count filled slots (for seasons, check SeasonSponsorship table)
let filledMainSlots = 0;
let filledSecondarySlots = 0;
if (dto.entityType === 'season') {
const sponsorships = await this.seasonSponsorshipRepo.findBySeasonId(dto.entityId);
const activeSponsorships = sponsorships.filter(s => s.isActive());
filledMainSlots = activeSponsorships.filter(s => s.tier === 'main').length;
filledSecondarySlots = activeSponsorships.filter(s => s.tier === 'secondary').length;
}
const result: GetEntitySponsorshipPricingResultDTO = {
entityType: dto.entityType,
entityId: dto.entityId,
acceptingApplications: pricing.acceptingApplications,
customRequirements: pricing.customRequirements,
};
if (pricing.mainSlot) {
const mainMaxSlots = pricing.mainSlot.maxSlots;
result.mainSlot = {
tier: 'main',
price: pricing.mainSlot.price.amount,
currency: pricing.mainSlot.price.currency,
formattedPrice: pricing.mainSlot.price.format(),
benefits: pricing.mainSlot.benefits,
available: pricing.mainSlot.available && filledMainSlots < mainMaxSlots,
maxSlots: mainMaxSlots,
filledSlots: filledMainSlots,
pendingRequests: pendingMainCount,
};
}
if (pricing.secondarySlots) {
const secondaryMaxSlots = pricing.secondarySlots.maxSlots;
result.secondarySlot = {
tier: 'secondary',
price: pricing.secondarySlots.price.amount,
currency: pricing.secondarySlots.price.currency,
formattedPrice: pricing.secondarySlots.price.format(),
benefits: pricing.secondarySlots.benefits,
available: pricing.secondarySlots.available && filledSecondarySlots < secondaryMaxSlots,
maxSlots: secondaryMaxSlots,
filledSlots: filledSecondarySlots,
pendingRequests: pendingSecondaryCount,
};
}
return result;
}
}

View File

@@ -119,6 +119,16 @@ export class GetLeagueFullConfigQuery {
sessionCount,
roundsPlanned,
},
stewarding: {
decisionMode: 'admin_only',
requireDefense: true,
defenseTimeLimit: 48,
voteTimeLimit: 72,
protestDeadlineHours: 72,
stewardingClosesHours: 168,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
},
};
return form;

View File

@@ -0,0 +1,82 @@
/**
* Query: GetPendingSponsorshipRequestsQuery
*
* Retrieves pending sponsorship requests for an entity owner to review.
*/
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
export interface GetPendingSponsorshipRequestsDTO {
entityType: SponsorableEntityType;
entityId: string;
}
export interface PendingSponsorshipRequestDTO {
id: string;
sponsorId: string;
sponsorName: string;
sponsorLogo?: string;
tier: SponsorshipTier;
offeredAmount: number;
currency: string;
formattedAmount: string;
message?: string;
createdAt: Date;
platformFee: number;
netAmount: number;
}
export interface GetPendingSponsorshipRequestsResultDTO {
entityType: SponsorableEntityType;
entityId: string;
requests: PendingSponsorshipRequestDTO[];
totalCount: number;
}
export class GetPendingSponsorshipRequestsQuery {
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly sponsorRepo: ISponsorRepository,
) {}
async execute(dto: GetPendingSponsorshipRequestsDTO): Promise<GetPendingSponsorshipRequestsResultDTO> {
const requests = await this.sponsorshipRequestRepo.findPendingByEntity(
dto.entityType,
dto.entityId
);
const requestDTOs: PendingSponsorshipRequestDTO[] = [];
for (const request of requests) {
const sponsor = await this.sponsorRepo.findById(request.sponsorId);
requestDTOs.push({
id: request.id,
sponsorId: request.sponsorId,
sponsorName: sponsor?.name ?? 'Unknown Sponsor',
sponsorLogo: sponsor?.logoUrl,
tier: request.tier,
offeredAmount: request.offeredAmount.amount,
currency: request.offeredAmount.currency,
formattedAmount: request.offeredAmount.format(),
message: request.message,
createdAt: request.createdAt,
platformFee: request.getPlatformFee().amount,
netAmount: request.getNetAmount().amount,
});
}
// Sort by creation date (newest first)
requestDTOs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
return {
entityType: dto.entityType,
entityId: dto.entityId,
requests: requestDTOs,
totalCount: requestDTOs.length,
};
}
}

View File

@@ -0,0 +1,164 @@
/**
* Application Query: GetSponsorDashboardQuery
*
* Returns sponsor dashboard metrics including sponsorships, impressions, and investment data.
*/
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
export interface GetSponsorDashboardQueryParams {
sponsorId: string;
}
export interface SponsoredLeagueDTO {
id: string;
name: string;
tier: 'main' | 'secondary';
drivers: number;
races: number;
impressions: number;
status: 'active' | 'upcoming' | 'completed';
}
export interface SponsorDashboardDTO {
sponsorId: string;
sponsorName: string;
metrics: {
impressions: number;
impressionsChange: number;
uniqueViewers: number;
viewersChange: number;
races: number;
drivers: number;
exposure: number;
exposureChange: number;
};
sponsoredLeagues: SponsoredLeagueDTO[];
investment: {
activeSponsorships: number;
totalInvestment: number;
costPerThousandViews: number;
};
}
export class GetSponsorDashboardQuery {
constructor(
private readonly sponsorRepository: ISponsorRepository,
private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly raceRepository: IRaceRepository,
) {}
async execute(params: GetSponsorDashboardQueryParams): Promise<SponsorDashboardDTO | null> {
const { sponsorId } = params;
const sponsor = await this.sponsorRepository.findById(sponsorId);
if (!sponsor) {
return null;
}
// Get all sponsorships for this sponsor
const sponsorships = await this.seasonSponsorshipRepository.findBySponsorId(sponsorId);
// Aggregate data across all sponsorships
let totalImpressions = 0;
let totalDrivers = 0;
let totalRaces = 0;
let totalInvestment = 0;
const sponsoredLeagues: SponsoredLeagueDTO[] = [];
const seenLeagues = new Set<string>();
for (const sponsorship of sponsorships) {
// Get season to find league
const season = await this.seasonRepository.findById(sponsorship.seasonId);
if (!season) continue;
// Only process each league once
if (seenLeagues.has(season.leagueId)) continue;
seenLeagues.add(season.leagueId);
const league = await this.leagueRepository.findById(season.leagueId);
if (!league) continue;
// Get membership count for this league
const memberships = await this.leagueMembershipRepository.getLeagueMembers(season.leagueId);
const driverCount = memberships.length;
totalDrivers += driverCount;
// Get races for this league
const races = await this.raceRepository.findByLeagueId(season.leagueId);
const raceCount = races.length;
totalRaces += raceCount;
// Calculate impressions based on completed races and drivers
// This is a simplified calculation - in production would come from analytics
const completedRaces = races.filter(r => r.status === 'completed').length;
const leagueImpressions = completedRaces * driverCount * 100; // Simplified: 100 views per driver per race
totalImpressions += leagueImpressions;
// Determine status based on season dates
const now = new Date();
let status: 'active' | 'upcoming' | 'completed' = 'active';
if (season.endDate && season.endDate < now) {
status = 'completed';
} else if (season.startDate && season.startDate > now) {
status = 'upcoming';
}
// Add investment
totalInvestment += sponsorship.pricing.amount;
sponsoredLeagues.push({
id: league.id,
name: league.name,
tier: sponsorship.tier,
drivers: driverCount,
races: raceCount,
impressions: leagueImpressions,
status,
});
}
const activeSponsorships = sponsorships.filter(s => s.status === 'active').length;
const costPerThousandViews = totalImpressions > 0
? (totalInvestment / (totalImpressions / 1000))
: 0;
// Calculate unique viewers (simplified: assume 70% of impressions are unique)
const uniqueViewers = Math.round(totalImpressions * 0.7);
// Calculate exposure score (0-100 based on tier distribution)
const mainSponsorships = sponsorships.filter(s => s.tier === 'main').length;
const exposure = sponsorships.length > 0
? Math.min(100, (mainSponsorships * 30) + (sponsorships.length * 10))
: 0;
return {
sponsorId,
sponsorName: sponsor.name,
metrics: {
impressions: totalImpressions,
impressionsChange: 12.5, // Would come from analytics comparison
uniqueViewers,
viewersChange: 8.3, // Would come from analytics comparison
races: totalRaces,
drivers: totalDrivers,
exposure,
exposureChange: 5.2, // Would come from analytics comparison
},
sponsoredLeagues,
investment: {
activeSponsorships,
totalInvestment,
costPerThousandViews: Math.round(costPerThousandViews * 100) / 100,
},
};
}
}

View File

@@ -0,0 +1,159 @@
/**
* Application Query: GetSponsorSponsorshipsQuery
*
* Returns detailed sponsorship information for a sponsor's campaigns/sponsorships page.
*/
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { SponsorshipTier, SponsorshipStatus } from '../../domain/entities/SeasonSponsorship';
export interface GetSponsorSponsorshipsQueryParams {
sponsorId: string;
}
export interface SponsorshipDetailDTO {
id: string;
leagueId: string;
leagueName: string;
seasonId: string;
seasonName: string;
tier: SponsorshipTier;
status: SponsorshipStatus;
pricing: {
amount: number;
currency: string;
};
platformFee: {
amount: number;
currency: string;
};
netAmount: {
amount: number;
currency: string;
};
metrics: {
drivers: number;
races: number;
completedRaces: number;
impressions: number;
};
createdAt: Date;
activatedAt?: Date;
}
export interface SponsorSponsorshipsDTO {
sponsorId: string;
sponsorName: string;
sponsorships: SponsorshipDetailDTO[];
summary: {
totalSponsorships: number;
activeSponsorships: number;
totalInvestment: number;
totalPlatformFees: number;
currency: string;
};
}
export class GetSponsorSponsorshipsQuery {
constructor(
private readonly sponsorRepository: ISponsorRepository,
private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly raceRepository: IRaceRepository,
) {}
async execute(params: GetSponsorSponsorshipsQueryParams): Promise<SponsorSponsorshipsDTO | null> {
const { sponsorId } = params;
const sponsor = await this.sponsorRepository.findById(sponsorId);
if (!sponsor) {
return null;
}
// Get all sponsorships for this sponsor
const sponsorships = await this.seasonSponsorshipRepository.findBySponsorId(sponsorId);
const sponsorshipDetails: SponsorshipDetailDTO[] = [];
let totalInvestment = 0;
let totalPlatformFees = 0;
for (const sponsorship of sponsorships) {
// Get season to find league
const season = await this.seasonRepository.findById(sponsorship.seasonId);
if (!season) continue;
const league = await this.leagueRepository.findById(season.leagueId);
if (!league) continue;
// Get membership count for this league
const memberships = await this.leagueMembershipRepository.getLeagueMembers(season.leagueId);
const driverCount = memberships.length;
// Get races for this league
const races = await this.raceRepository.findByLeagueId(season.leagueId);
const completedRaces = races.filter(r => r.status === 'completed').length;
// Calculate impressions
const impressions = completedRaces * driverCount * 100;
// Calculate platform fee (10%)
const platformFee = sponsorship.getPlatformFee();
const netAmount = sponsorship.getNetAmount();
totalInvestment += sponsorship.pricing.amount;
totalPlatformFees += platformFee.amount;
sponsorshipDetails.push({
id: sponsorship.id,
leagueId: league.id,
leagueName: league.name,
seasonId: season.id,
seasonName: season.name,
tier: sponsorship.tier,
status: sponsorship.status,
pricing: {
amount: sponsorship.pricing.amount,
currency: sponsorship.pricing.currency,
},
platformFee: {
amount: platformFee.amount,
currency: platformFee.currency,
},
netAmount: {
amount: netAmount.amount,
currency: netAmount.currency,
},
metrics: {
drivers: driverCount,
races: races.length,
completedRaces,
impressions,
},
createdAt: sponsorship.createdAt,
activatedAt: sponsorship.activatedAt,
});
}
const activeSponsorships = sponsorships.filter(s => s.status === 'active').length;
return {
sponsorId,
sponsorName: sponsor.name,
sponsorships: sponsorshipDetails,
summary: {
totalSponsorships: sponsorships.length,
activeSponsorships,
totalInvestment,
totalPlatformFees,
currency: 'USD',
},
};
}
}

View File

@@ -0,0 +1,51 @@
/**
* Use Case: RejectSponsorshipRequestUseCase
*
* Allows an entity owner to reject a sponsorship request.
*/
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
export interface RejectSponsorshipRequestDTO {
requestId: string;
respondedBy: string; // driverId of the person rejecting
reason?: string;
}
export interface RejectSponsorshipRequestResultDTO {
requestId: string;
status: 'rejected';
rejectedAt: Date;
reason?: string;
}
export class RejectSponsorshipRequestUseCase {
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
) {}
async execute(dto: RejectSponsorshipRequestDTO): Promise<RejectSponsorshipRequestResultDTO> {
// Find the request
const request = await this.sponsorshipRequestRepo.findById(dto.requestId);
if (!request) {
throw new Error('Sponsorship request not found');
}
if (!request.isPending()) {
throw new Error(`Cannot reject a ${request.status} sponsorship request`);
}
// Reject the request
const rejectedRequest = request.reject(dto.respondedBy, dto.reason);
await this.sponsorshipRequestRepo.update(rejectedRequest);
// TODO: In a real implementation, notify the sponsor
return {
requestId: rejectedRequest.id,
status: 'rejected',
rejectedAt: rejectedRequest.respondedAt!,
reason: rejectedRequest.rejectionReason,
};
}
}

View File

@@ -18,6 +18,7 @@ export interface DecalOverride {
export interface DriverLiveryProps {
id: string;
driverId: string;
gameId: string;
carId: string;
uploadedImageUrl: string;
userDecals: LiveryDecal[];
@@ -30,6 +31,7 @@ export interface DriverLiveryProps {
export class DriverLivery {
readonly id: string;
readonly driverId: string;
readonly gameId: string;
readonly carId: string;
readonly uploadedImageUrl: string;
readonly userDecals: LiveryDecal[];
@@ -41,6 +43,7 @@ export class DriverLivery {
private constructor(props: DriverLiveryProps) {
this.id = props.id;
this.driverId = props.driverId;
this.gameId = props.gameId;
this.carId = props.carId;
this.uploadedImageUrl = props.uploadedImageUrl;
this.userDecals = props.userDecals;
@@ -74,6 +77,10 @@ export class DriverLivery {
throw new Error('DriverLivery driverId is required');
}
if (!props.gameId || props.gameId.trim().length === 0) {
throw new Error('DriverLivery gameId is required');
}
if (!props.carId || props.carId.trim().length === 0) {
throw new Error('DriverLivery carId is required');
}

View File

@@ -0,0 +1,184 @@
/**
* Domain Entity: SponsorshipRequest
*
* Represents a sponsorship application from a Sponsor to any sponsorable entity
* (driver, team, race, or league/season). The entity owner must approve/reject.
*/
import type { Money } from '../value-objects/Money';
import type { SponsorshipTier } from './SeasonSponsorship';
export type SponsorableEntityType = 'driver' | 'team' | 'race' | 'season';
export type SponsorshipRequestStatus = 'pending' | 'accepted' | 'rejected' | 'withdrawn';
export interface SponsorshipRequestProps {
id: string;
sponsorId: string;
entityType: SponsorableEntityType;
entityId: string;
tier: SponsorshipTier;
offeredAmount: Money;
message?: string;
status: SponsorshipRequestStatus;
createdAt: Date;
respondedAt?: Date;
respondedBy?: string; // driverId of the person who accepted/rejected
rejectionReason?: string;
}
export class SponsorshipRequest {
readonly id: string;
readonly sponsorId: string;
readonly entityType: SponsorableEntityType;
readonly entityId: string;
readonly tier: SponsorshipTier;
readonly offeredAmount: Money;
readonly message?: string;
readonly status: SponsorshipRequestStatus;
readonly createdAt: Date;
readonly respondedAt?: Date;
readonly respondedBy?: string;
readonly rejectionReason?: string;
private constructor(props: SponsorshipRequestProps) {
this.id = props.id;
this.sponsorId = props.sponsorId;
this.entityType = props.entityType;
this.entityId = props.entityId;
this.tier = props.tier;
this.offeredAmount = props.offeredAmount;
this.message = props.message;
this.status = props.status;
this.createdAt = props.createdAt;
this.respondedAt = props.respondedAt;
this.respondedBy = props.respondedBy;
this.rejectionReason = props.rejectionReason;
}
static create(props: Omit<SponsorshipRequestProps, 'createdAt' | 'status'> & {
createdAt?: Date;
status?: SponsorshipRequestStatus;
}): SponsorshipRequest {
this.validate(props);
return new SponsorshipRequest({
...props,
createdAt: props.createdAt ?? new Date(),
status: props.status ?? 'pending',
});
}
private static validate(props: Omit<SponsorshipRequestProps, 'createdAt' | 'status'>): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('SponsorshipRequest ID is required');
}
if (!props.sponsorId || props.sponsorId.trim().length === 0) {
throw new Error('SponsorshipRequest sponsorId is required');
}
if (!props.entityType) {
throw new Error('SponsorshipRequest entityType is required');
}
if (!props.entityId || props.entityId.trim().length === 0) {
throw new Error('SponsorshipRequest entityId is required');
}
if (!props.tier) {
throw new Error('SponsorshipRequest tier is required');
}
if (!props.offeredAmount) {
throw new Error('SponsorshipRequest offeredAmount is required');
}
if (props.offeredAmount.amount <= 0) {
throw new Error('SponsorshipRequest offeredAmount must be greater than zero');
}
}
/**
* Accept the sponsorship request
*/
accept(respondedBy: string): SponsorshipRequest {
if (this.status !== 'pending') {
throw new Error(`Cannot accept a ${this.status} sponsorship request`);
}
if (!respondedBy || respondedBy.trim().length === 0) {
throw new Error('respondedBy is required when accepting');
}
return new SponsorshipRequest({
...this,
status: 'accepted',
respondedAt: new Date(),
respondedBy,
});
}
/**
* Reject the sponsorship request
*/
reject(respondedBy: string, reason?: string): SponsorshipRequest {
if (this.status !== 'pending') {
throw new Error(`Cannot reject a ${this.status} sponsorship request`);
}
if (!respondedBy || respondedBy.trim().length === 0) {
throw new Error('respondedBy is required when rejecting');
}
return new SponsorshipRequest({
...this,
status: 'rejected',
respondedAt: new Date(),
respondedBy,
rejectionReason: reason,
});
}
/**
* Withdraw the sponsorship request (by the sponsor)
*/
withdraw(): SponsorshipRequest {
if (this.status !== 'pending') {
throw new Error(`Cannot withdraw a ${this.status} sponsorship request`);
}
return new SponsorshipRequest({
...this,
status: 'withdrawn',
respondedAt: new Date(),
});
}
/**
* Check if request is pending
*/
isPending(): boolean {
return this.status === 'pending';
}
/**
* Check if request was accepted
*/
isAccepted(): boolean {
return this.status === 'accepted';
}
/**
* Get platform fee for this request
*/
getPlatformFee(): Money {
return this.offeredAmount.calculatePlatformFee();
}
/**
* Get net amount after platform fee
*/
getNetAmount(): Money {
return this.offeredAmount.calculateNetAmount();
}
}

View File

@@ -12,6 +12,8 @@ export interface ILiveryRepository {
findDriverLiveryById(id: string): Promise<DriverLivery | null>;
findDriverLiveriesByDriverId(driverId: string): Promise<DriverLivery[]>;
findDriverLiveryByDriverAndCar(driverId: string, carId: string): Promise<DriverLivery | null>;
findDriverLiveriesByGameId(gameId: string): Promise<DriverLivery[]>;
findDriverLiveryByDriverAndGame(driverId: string, gameId: string): Promise<DriverLivery[]>;
createDriverLivery(livery: DriverLivery): Promise<DriverLivery>;
updateDriverLivery(livery: DriverLivery): Promise<DriverLivery>;
deleteDriverLivery(id: string): Promise<void>;

View File

@@ -0,0 +1,39 @@
/**
* Repository Interface: ISponsorshipPricingRepository
*
* Stores sponsorship pricing configuration for any sponsorable entity.
* This allows drivers, teams, races, and leagues to define their sponsorship slots.
*/
import type { SponsorshipPricing } from '../value-objects/SponsorshipPricing';
import type { SponsorableEntityType } from '../entities/SponsorshipRequest';
export interface ISponsorshipPricingRepository {
/**
* Get pricing configuration for an entity
*/
findByEntity(entityType: SponsorableEntityType, entityId: string): Promise<SponsorshipPricing | null>;
/**
* Save or update pricing configuration for an entity
*/
save(entityType: SponsorableEntityType, entityId: string, pricing: SponsorshipPricing): Promise<void>;
/**
* Delete pricing configuration for an entity
*/
delete(entityType: SponsorableEntityType, entityId: string): Promise<void>;
/**
* Check if entity has pricing configured
*/
exists(entityType: SponsorableEntityType, entityId: string): Promise<boolean>;
/**
* Find all entities accepting sponsorship applications
*/
findAcceptingApplications(entityType: SponsorableEntityType): Promise<Array<{
entityId: string;
pricing: SponsorshipPricing;
}>>;
}

View File

@@ -0,0 +1,51 @@
/**
* Repository Interface: ISponsorshipRequestRepository
*
* Defines operations for SponsorshipRequest aggregate persistence
*/
import type { SponsorshipRequest, SponsorableEntityType, SponsorshipRequestStatus } from '../entities/SponsorshipRequest';
export interface ISponsorshipRequestRepository {
findById(id: string): Promise<SponsorshipRequest | null>;
/**
* Find all requests for a specific entity (driver, team, race, or season)
*/
findByEntity(entityType: SponsorableEntityType, entityId: string): Promise<SponsorshipRequest[]>;
/**
* Find pending requests for an entity that need review
*/
findPendingByEntity(entityType: SponsorableEntityType, entityId: string): Promise<SponsorshipRequest[]>;
/**
* Find all requests made by a sponsor
*/
findBySponsorId(sponsorId: string): Promise<SponsorshipRequest[]>;
/**
* Find requests by status
*/
findByStatus(status: SponsorshipRequestStatus): Promise<SponsorshipRequest[]>;
/**
* Find requests by sponsor and status
*/
findBySponsorIdAndStatus(sponsorId: string, status: SponsorshipRequestStatus): Promise<SponsorshipRequest[]>;
/**
* Check if a sponsor already has a pending request for an entity
*/
hasPendingRequest(sponsorId: string, entityType: SponsorableEntityType, entityId: string): Promise<boolean>;
/**
* Count pending requests for an entity
*/
countPendingByEntity(entityType: SponsorableEntityType, entityId: string): Promise<number>;
create(request: SponsorshipRequest): Promise<SponsorshipRequest>;
update(request: SponsorshipRequest): Promise<SponsorshipRequest>;
delete(id: string): Promise<void>;
exists(id: string): Promise<boolean>;
}

View File

@@ -12,6 +12,7 @@ export interface LiveryDecalProps {
y: number;
width: number;
height: number;
rotation: number; // Degrees, 0-360
zIndex: number;
type: DecalType;
}
@@ -23,6 +24,7 @@ export class LiveryDecal {
readonly y: number;
readonly width: number;
readonly height: number;
readonly rotation: number;
readonly zIndex: number;
readonly type: DecalType;
@@ -33,13 +35,18 @@ export class LiveryDecal {
this.y = props.y;
this.width = props.width;
this.height = props.height;
this.rotation = props.rotation;
this.zIndex = props.zIndex;
this.type = props.type;
}
static create(props: LiveryDecalProps): LiveryDecal {
this.validate(props);
return new LiveryDecal(props);
static create(props: Omit<LiveryDecalProps, 'rotation'> & { rotation?: number }): LiveryDecal {
const propsWithRotation = {
...props,
rotation: props.rotation ?? 0,
};
this.validate(propsWithRotation);
return new LiveryDecal(propsWithRotation);
}
private static validate(props: LiveryDecalProps): void {
@@ -71,6 +78,10 @@ export class LiveryDecal {
throw new Error('LiveryDecal zIndex must be a non-negative integer');
}
if (props.rotation < 0 || props.rotation > 360) {
throw new Error('LiveryDecal rotation must be between 0 and 360 degrees');
}
if (!props.type) {
throw new Error('LiveryDecal type is required');
}
@@ -108,6 +119,25 @@ export class LiveryDecal {
});
}
/**
* Rotate decal
*/
rotate(rotation: number): LiveryDecal {
// Normalize rotation to 0-360 range
const normalizedRotation = ((rotation % 360) + 360) % 360;
return LiveryDecal.create({
...this,
rotation: normalizedRotation,
});
}
/**
* Get CSS transform string for rendering
*/
getCssTransform(): string {
return `rotate(${this.rotation}deg)`;
}
/**
* Check if this decal overlaps with another
*/

View File

@@ -0,0 +1,208 @@
/**
* Value Object: SponsorshipPricing
*
* Represents the sponsorship slot configuration and pricing for any sponsorable entity.
* Used by drivers, teams, races, and leagues to define their sponsorship offerings.
*/
import { Money } from './Money';
export interface SponsorshipSlotConfig {
tier: 'main' | 'secondary';
price: Money;
benefits: string[];
available: boolean;
maxSlots: number; // How many sponsors of this tier can exist (1 for main, 2 for secondary typically)
}
export interface SponsorshipPricingProps {
mainSlot?: SponsorshipSlotConfig;
secondarySlots?: SponsorshipSlotConfig;
acceptingApplications: boolean;
customRequirements?: string;
}
export class SponsorshipPricing {
readonly mainSlot?: SponsorshipSlotConfig;
readonly secondarySlots?: SponsorshipSlotConfig;
readonly acceptingApplications: boolean;
readonly customRequirements?: string;
private constructor(props: SponsorshipPricingProps) {
this.mainSlot = props.mainSlot;
this.secondarySlots = props.secondarySlots;
this.acceptingApplications = props.acceptingApplications;
this.customRequirements = props.customRequirements;
}
static create(props: Partial<SponsorshipPricingProps> = {}): SponsorshipPricing {
return new SponsorshipPricing({
mainSlot: props.mainSlot,
secondarySlots: props.secondarySlots,
acceptingApplications: props.acceptingApplications ?? true,
customRequirements: props.customRequirements,
});
}
/**
* Create default pricing for a driver
*/
static defaultDriver(): SponsorshipPricing {
return new SponsorshipPricing({
mainSlot: {
tier: 'main',
price: Money.create(200, 'USD'),
benefits: ['Suit logo', 'Helmet branding', 'Social mentions'],
available: true,
maxSlots: 1,
},
acceptingApplications: true,
});
}
/**
* Create default pricing for a team
*/
static defaultTeam(): SponsorshipPricing {
return new SponsorshipPricing({
mainSlot: {
tier: 'main',
price: Money.create(500, 'USD'),
benefits: ['Team name suffix', 'Car livery', 'All driver suits'],
available: true,
maxSlots: 1,
},
secondarySlots: {
tier: 'secondary',
price: Money.create(250, 'USD'),
benefits: ['Team page logo', 'Minor livery placement'],
available: true,
maxSlots: 2,
},
acceptingApplications: true,
});
}
/**
* Create default pricing for a race
*/
static defaultRace(): SponsorshipPricing {
return new SponsorshipPricing({
mainSlot: {
tier: 'main',
price: Money.create(300, 'USD'),
benefits: ['Race title sponsor', 'Stream overlay', 'Results banner'],
available: true,
maxSlots: 1,
},
acceptingApplications: true,
});
}
/**
* Create default pricing for a league/season
*/
static defaultLeague(): SponsorshipPricing {
return new SponsorshipPricing({
mainSlot: {
tier: 'main',
price: Money.create(800, 'USD'),
benefits: ['Hood placement', 'League banner', 'Prominent logo', 'League page URL'],
available: true,
maxSlots: 1,
},
secondarySlots: {
tier: 'secondary',
price: Money.create(250, 'USD'),
benefits: ['Side logo placement', 'League page listing'],
available: true,
maxSlots: 2,
},
acceptingApplications: true,
});
}
/**
* Check if a specific tier is available
*/
isSlotAvailable(tier: 'main' | 'secondary'): boolean {
if (tier === 'main') {
return !!this.mainSlot?.available;
}
return !!this.secondarySlots?.available;
}
/**
* Get price for a specific tier
*/
getPrice(tier: 'main' | 'secondary'): Money | null {
if (tier === 'main') {
return this.mainSlot?.price ?? null;
}
return this.secondarySlots?.price ?? null;
}
/**
* Get benefits for a specific tier
*/
getBenefits(tier: 'main' | 'secondary'): string[] {
if (tier === 'main') {
return this.mainSlot?.benefits ?? [];
}
return this.secondarySlots?.benefits ?? [];
}
/**
* Update main slot pricing
*/
updateMainSlot(config: Partial<SponsorshipSlotConfig>): SponsorshipPricing {
const currentMain = this.mainSlot ?? {
tier: 'main' as const,
price: Money.create(0, 'USD'),
benefits: [],
available: true,
maxSlots: 1,
};
return new SponsorshipPricing({
...this,
mainSlot: {
...currentMain,
...config,
tier: 'main',
},
});
}
/**
* Update secondary slot pricing
*/
updateSecondarySlot(config: Partial<SponsorshipSlotConfig>): SponsorshipPricing {
const currentSecondary = this.secondarySlots ?? {
tier: 'secondary' as const,
price: Money.create(0, 'USD'),
benefits: [],
available: true,
maxSlots: 2,
};
return new SponsorshipPricing({
...this,
secondarySlots: {
...currentSecondary,
...config,
tier: 'secondary',
},
});
}
/**
* Enable/disable accepting applications
*/
setAcceptingApplications(accepting: boolean): SponsorshipPricing {
return new SponsorshipPricing({
...this,
acceptingApplications: accepting,
});
}
}

View File

@@ -27,7 +27,30 @@ export * from './domain/repositories/IPenaltyRepository';
export * from './domain/services/StrengthOfFieldCalculator';
export * from './domain/value-objects/Money';
export * from './domain/value-objects/SponsorshipPricing';
export * from './domain/entities/Sponsor';
export * from './domain/entities/SeasonSponsorship';
export * from './domain/entities/SponsorshipRequest';
export * from './domain/repositories/ISponsorRepository';
export * from './domain/repositories/ISeasonSponsorshipRepository';
export * from './domain/repositories/ISponsorshipRequestRepository';
export * from './domain/repositories/ISponsorshipPricingRepository';
export * from './infrastructure/repositories/InMemorySponsorRepository';
export * from './infrastructure/repositories/InMemorySeasonSponsorshipRepository';
export * from './infrastructure/repositories/InMemorySponsorshipRequestRepository';
export * from './infrastructure/repositories/InMemorySponsorshipPricingRepository';
export * from './application/mappers/EntityMappers';
export * from './application/dto/DriverDTO';
export * from './application/dto/LeagueDriverSeasonStatsDTO';
export * from './application/dto/LeagueScoringConfigDTO';
export * from './application/dto/LeagueScoringConfigDTO';
export * from './application/use-cases/GetSponsorDashboardQuery';
export * from './application/use-cases/GetSponsorSponsorshipsQuery';
export * from './application/use-cases/ApplyForSponsorshipUseCase';
export * from './application/use-cases/AcceptSponsorshipRequestUseCase';
export * from './application/use-cases/RejectSponsorshipRequestUseCase';
export * from './application/use-cases/GetPendingSponsorshipRequestsQuery';
export * from './application/use-cases/GetEntitySponsorshipPricingQuery';

View File

@@ -0,0 +1,61 @@
/**
* Infrastructure Adapter: InMemoryGameRepository
*
* In-memory implementation of IGameRepository.
*/
import { Game } from '../../domain/entities/Game';
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
export class InMemoryGameRepository implements IGameRepository {
private games: Map<string, Game>;
constructor(seedData?: Game[]) {
this.games = new Map();
if (seedData) {
seedData.forEach(game => {
this.games.set(game.id, game);
});
} else {
// Default seed data for common sim racing games
const defaultGames = [
Game.create({ id: 'iracing', name: 'iRacing' }),
Game.create({ id: 'acc', name: 'Assetto Corsa Competizione' }),
Game.create({ id: 'ac', name: 'Assetto Corsa' }),
Game.create({ id: 'rf2', name: 'rFactor 2' }),
Game.create({ id: 'ams2', name: 'Automobilista 2' }),
Game.create({ id: 'lmu', name: 'Le Mans Ultimate' }),
];
defaultGames.forEach(game => {
this.games.set(game.id, game);
});
}
}
async findById(id: string): Promise<Game | null> {
return this.games.get(id) ?? null;
}
async findAll(): Promise<Game[]> {
return Array.from(this.games.values()).sort((a, b) => a.name.localeCompare(b.name));
}
/**
* Utility method to add a game
*/
async create(game: Game): Promise<Game> {
if (this.games.has(game.id)) {
throw new Error(`Game with ID ${game.id} already exists`);
}
this.games.set(game.id, game);
return game;
}
/**
* Test helper to clear data
*/
clear(): void {
this.games.clear();
}
}

View File

@@ -30,6 +30,16 @@ export class InMemoryLiveryRepository implements ILiveryRepository {
return null;
}
async findDriverLiveriesByGameId(gameId: string): Promise<DriverLivery[]> {
return Array.from(this.driverLiveries.values()).filter(l => l.gameId === gameId);
}
async findDriverLiveryByDriverAndGame(driverId: string, gameId: string): Promise<DriverLivery[]> {
return Array.from(this.driverLiveries.values()).filter(
l => l.driverId === driverId && l.gameId === gameId
);
}
async createDriverLivery(livery: DriverLivery): Promise<DriverLivery> {
if (this.driverLiveries.has(livery.id)) {
throw new Error('DriverLivery with this ID already exists');

View File

@@ -0,0 +1,67 @@
/**
* InMemory implementation of ISponsorshipPricingRepository
*/
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
import { SponsorshipPricing } from '../../domain/value-objects/SponsorshipPricing';
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
interface StorageKey {
entityType: SponsorableEntityType;
entityId: string;
}
export class InMemorySponsorshipPricingRepository implements ISponsorshipPricingRepository {
private pricings: Map<string, { entityType: SponsorableEntityType; entityId: string; pricing: SponsorshipPricing }> = new Map();
private makeKey(entityType: SponsorableEntityType, entityId: string): string {
return `${entityType}:${entityId}`;
}
async findByEntity(entityType: SponsorableEntityType, entityId: string): Promise<SponsorshipPricing | null> {
const key = this.makeKey(entityType, entityId);
const entry = this.pricings.get(key);
return entry?.pricing ?? null;
}
async save(entityType: SponsorableEntityType, entityId: string, pricing: SponsorshipPricing): Promise<void> {
const key = this.makeKey(entityType, entityId);
this.pricings.set(key, { entityType, entityId, pricing });
}
async delete(entityType: SponsorableEntityType, entityId: string): Promise<void> {
const key = this.makeKey(entityType, entityId);
this.pricings.delete(key);
}
async exists(entityType: SponsorableEntityType, entityId: string): Promise<boolean> {
const key = this.makeKey(entityType, entityId);
return this.pricings.has(key);
}
async findAcceptingApplications(entityType: SponsorableEntityType): Promise<Array<{
entityId: string;
pricing: SponsorshipPricing;
}>> {
return Array.from(this.pricings.values())
.filter(entry => entry.entityType === entityType && entry.pricing.acceptingApplications)
.map(entry => ({ entityId: entry.entityId, pricing: entry.pricing }));
}
/**
* Seed initial data
*/
seed(data: Array<{ entityType: SponsorableEntityType; entityId: string; pricing: SponsorshipPricing }>): void {
for (const entry of data) {
const key = this.makeKey(entry.entityType, entry.entityId);
this.pricings.set(key, entry);
}
}
/**
* Clear all data (for testing)
*/
clear(): void {
this.pricings.clear();
}
}

View File

@@ -0,0 +1,107 @@
/**
* InMemory implementation of ISponsorshipRequestRepository
*/
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import {
SponsorshipRequest,
type SponsorableEntityType,
type SponsorshipRequestStatus
} from '../../domain/entities/SponsorshipRequest';
export class InMemorySponsorshipRequestRepository implements ISponsorshipRequestRepository {
private requests: Map<string, SponsorshipRequest> = new Map();
async findById(id: string): Promise<SponsorshipRequest | null> {
return this.requests.get(id) ?? null;
}
async findByEntity(entityType: SponsorableEntityType, entityId: string): Promise<SponsorshipRequest[]> {
return Array.from(this.requests.values()).filter(
request => request.entityType === entityType && request.entityId === entityId
);
}
async findPendingByEntity(entityType: SponsorableEntityType, entityId: string): Promise<SponsorshipRequest[]> {
return Array.from(this.requests.values()).filter(
request =>
request.entityType === entityType &&
request.entityId === entityId &&
request.status === 'pending'
);
}
async findBySponsorId(sponsorId: string): Promise<SponsorshipRequest[]> {
return Array.from(this.requests.values()).filter(
request => request.sponsorId === sponsorId
);
}
async findByStatus(status: SponsorshipRequestStatus): Promise<SponsorshipRequest[]> {
return Array.from(this.requests.values()).filter(
request => request.status === status
);
}
async findBySponsorIdAndStatus(sponsorId: string, status: SponsorshipRequestStatus): Promise<SponsorshipRequest[]> {
return Array.from(this.requests.values()).filter(
request => request.sponsorId === sponsorId && request.status === status
);
}
async hasPendingRequest(sponsorId: string, entityType: SponsorableEntityType, entityId: string): Promise<boolean> {
return Array.from(this.requests.values()).some(
request =>
request.sponsorId === sponsorId &&
request.entityType === entityType &&
request.entityId === entityId &&
request.status === 'pending'
);
}
async countPendingByEntity(entityType: SponsorableEntityType, entityId: string): Promise<number> {
return Array.from(this.requests.values()).filter(
request =>
request.entityType === entityType &&
request.entityId === entityId &&
request.status === 'pending'
).length;
}
async create(request: SponsorshipRequest): Promise<SponsorshipRequest> {
this.requests.set(request.id, request);
return request;
}
async update(request: SponsorshipRequest): Promise<SponsorshipRequest> {
if (!this.requests.has(request.id)) {
throw new Error(`SponsorshipRequest ${request.id} not found`);
}
this.requests.set(request.id, request);
return request;
}
async delete(id: string): Promise<void> {
this.requests.delete(id);
}
async exists(id: string): Promise<boolean> {
return this.requests.has(id);
}
/**
* Seed initial data
*/
seed(requests: SponsorshipRequest[]): void {
for (const request of requests) {
this.requests.set(request.id, request);
}
}
/**
* Clear all data (for testing)
*/
clear(): void {
this.requests.clear();
}
}

View File

@@ -30,6 +30,63 @@ export interface DemoTeamDTO {
memberCount: number;
}
/**
* Demo sponsor data for seeding
*/
export interface DemoSponsorDTO {
id: string;
name: string;
contactEmail: string;
logoUrl: string;
websiteUrl: string;
tagline: string;
}
/**
* Demo season sponsorship data
*/
export interface DemoSeasonSponsorshipDTO {
id: string;
seasonId: string;
sponsorId: string;
tier: 'main' | 'secondary';
pricingAmount: number;
pricingCurrency: 'USD' | 'EUR' | 'GBP';
status: 'pending' | 'active' | 'cancelled';
description?: string;
}
/**
* Demo sponsorship request data for seeding pending requests
*/
export interface DemoSponsorshipRequestDTO {
id: string;
sponsorId: string;
entityType: 'driver' | 'team' | 'race' | 'season';
entityId: string;
tier: 'main' | 'secondary';
offeredAmount: number;
currency: 'USD' | 'EUR' | 'GBP';
message?: string;
status: 'pending' | 'accepted' | 'rejected' | 'withdrawn';
createdAt: Date;
}
/**
* Demo sponsorship pricing configuration for entities
*/
export interface DemoSponsorshipPricingDTO {
entityType: 'driver' | 'team' | 'race' | 'season';
entityId: string;
mainSlotPrice: number;
mainSlotBenefits: string[];
secondarySlotPrice?: number;
secondarySlotBenefits?: string[];
secondaryMaxSlots?: number;
acceptingApplications: boolean;
customRequirements?: string;
}
export type RacingSeedData = {
drivers: Driver[];
leagues: League[];
@@ -40,6 +97,10 @@ export type RacingSeedData = {
friendships: Friendship[];
feedEvents: FeedItem[];
teams: DemoTeamDTO[];
sponsors: DemoSponsorDTO[];
seasonSponsorships: DemoSeasonSponsorshipDTO[];
sponsorshipRequests: DemoSponsorshipRequestDTO[];
sponsorshipPricings: DemoSponsorshipPricingDTO[];
};
const POINTS_TABLE: Record<number, number> = {
@@ -519,6 +580,316 @@ function createFeedEvents(
return sorted;
}
/**
* Demo sponsors data - realistic sim racing sponsors
*/
const DEMO_SPONSORS: DemoSponsorDTO[] = [
{
id: 'sponsor-fanatec',
name: 'Fanatec',
contactEmail: 'partnerships@fanatec.com',
logoUrl: '/images/sponsors/fanatec.svg',
websiteUrl: 'https://fanatec.com',
tagline: 'The world\'s leading sim racing hardware',
},
{
id: 'sponsor-simucube',
name: 'Simucube',
contactEmail: 'sponsors@simucube.com',
logoUrl: '/images/sponsors/simucube.svg',
websiteUrl: 'https://simucube.com',
tagline: 'Professional Direct Drive Wheels',
},
{
id: 'sponsor-heusinkveld',
name: 'Heusinkveld',
contactEmail: 'info@heusinkveld.com',
logoUrl: '/images/sponsors/heusinkveld.svg',
websiteUrl: 'https://heusinkveld.com',
tagline: 'Sim Racing Pedals & Hardware',
},
{
id: 'sponsor-trak-racer',
name: 'Trak Racer',
contactEmail: 'partnerships@trakracer.com',
logoUrl: '/images/sponsors/trak-racer.svg',
websiteUrl: 'https://trakracer.com',
tagline: 'Premium Racing Simulators & Cockpits',
},
{
id: 'sponsor-simlab',
name: 'Sim-Lab',
contactEmail: 'sponsor@sim-lab.eu',
logoUrl: '/images/sponsors/simlab.svg',
websiteUrl: 'https://sim-lab.eu',
tagline: 'Aluminum Profile Sim Racing Rigs',
},
{
id: 'sponsor-motionrig',
name: 'MotionRig Pro',
contactEmail: 'business@motionrigpro.com',
logoUrl: '/images/sponsors/motionrig.svg',
websiteUrl: 'https://motionrigpro.com',
tagline: 'Feel every turn, every bump',
},
];
/**
* Create season sponsorships linking sponsors to leagues
*/
function createSeasonSponsorships(
leagues: League[],
sponsors: DemoSponsorDTO[],
): DemoSeasonSponsorshipDTO[] {
const sponsorships: DemoSeasonSponsorshipDTO[] = [];
// GridPilot Sprint Series - sponsored by Fanatec (main) + Heusinkveld & Simucube (secondary)
const sprintLeague = leagues.find(l => l.name === 'GridPilot Sprint Series');
if (sprintLeague) {
sponsorships.push({
id: `sponsorship-${sprintLeague.id}-fanatec`,
seasonId: `season-${sprintLeague.id}-demo`,
sponsorId: 'sponsor-fanatec',
tier: 'main',
pricingAmount: 5000,
pricingCurrency: 'USD',
status: 'active',
description: 'Main sponsor for the Sprint Series - premium wheel branding',
});
sponsorships.push({
id: `sponsorship-${sprintLeague.id}-heusinkveld`,
seasonId: `season-${sprintLeague.id}-demo`,
sponsorId: 'sponsor-heusinkveld',
tier: 'secondary',
pricingAmount: 2000,
pricingCurrency: 'USD',
status: 'active',
});
sponsorships.push({
id: `sponsorship-${sprintLeague.id}-simucube`,
seasonId: `season-${sprintLeague.id}-demo`,
sponsorId: 'sponsor-simucube',
tier: 'secondary',
pricingAmount: 2000,
pricingCurrency: 'USD',
status: 'active',
});
}
// GridPilot Endurance Cup - sponsored by Trak Racer (main) + Sim-Lab (secondary)
const enduranceLeague = leagues.find(l => l.name === 'GridPilot Endurance Cup');
if (enduranceLeague) {
sponsorships.push({
id: `sponsorship-${enduranceLeague.id}-trakracer`,
seasonId: `season-${enduranceLeague.id}-demo`,
sponsorId: 'sponsor-trak-racer',
tier: 'main',
pricingAmount: 7500,
pricingCurrency: 'USD',
status: 'active',
description: 'Endurance series naming rights',
});
sponsorships.push({
id: `sponsorship-${enduranceLeague.id}-simlab`,
seasonId: `season-${enduranceLeague.id}-demo`,
sponsorId: 'sponsor-simlab',
tier: 'secondary',
pricingAmount: 3000,
pricingCurrency: 'USD',
status: 'active',
});
}
// GridPilot Club Ladder - sponsored by MotionRig Pro (main)
const clubLeague = leagues.find(l => l.name === 'GridPilot Club Ladder');
if (clubLeague) {
sponsorships.push({
id: `sponsorship-${clubLeague.id}-motionrig`,
seasonId: `season-${clubLeague.id}-demo`,
sponsorId: 'sponsor-motionrig',
tier: 'main',
pricingAmount: 3500,
pricingCurrency: 'USD',
status: 'active',
description: 'Club ladder motion platform showcase',
});
}
return sponsorships;
}
/**
* Create sponsorship pricing configurations for demo entities
*/
function createSponsorshipPricings(
leagues: League[],
teams: DemoTeamDTO[],
drivers: Driver[],
races: Race[],
): DemoSponsorshipPricingDTO[] {
const pricings: DemoSponsorshipPricingDTO[] = [];
// League/Season pricing - all leagues can accept sponsorships
leagues.forEach((league, index) => {
const basePrice = 500 + (index * 100);
pricings.push({
entityType: 'season',
entityId: `season-${league.id}-demo`,
mainSlotPrice: basePrice * 10, // Main sponsor is more expensive
mainSlotBenefits: ['Hood placement', 'League banner', 'Prominent logo', 'League page URL'],
secondarySlotPrice: basePrice * 3,
secondarySlotBenefits: ['Side logo placement', 'League page listing'],
secondaryMaxSlots: 2,
acceptingApplications: true,
});
});
// Team pricing - first 10 teams accept sponsorships
teams.slice(0, 10).forEach((team, index) => {
pricings.push({
entityType: 'team',
entityId: team.id,
mainSlotPrice: 500 + (index * 50),
mainSlotBenefits: ['Team name suffix', 'Car livery', 'All driver suits'],
secondarySlotPrice: 250 + (index * 25),
secondarySlotBenefits: ['Team page logo', 'Minor livery placement'],
secondaryMaxSlots: 2,
acceptingApplications: true,
});
});
// Driver pricing - first 20 drivers accept sponsorships
drivers.slice(0, 20).forEach((driver, index) => {
pricings.push({
entityType: 'driver',
entityId: driver.id,
mainSlotPrice: 200 + (index * 20),
mainSlotBenefits: ['Suit logo', 'Helmet branding', 'Social mentions'],
acceptingApplications: index % 3 !== 0, // Some drivers not accepting
});
});
// Race pricing - upcoming races can have title sponsors
const upcomingRaces = races.filter(r => r.status === 'scheduled').slice(0, 10);
upcomingRaces.forEach((race, index) => {
pricings.push({
entityType: 'race',
entityId: race.id,
mainSlotPrice: 300 + (index * 30),
mainSlotBenefits: ['Race title sponsor', 'Stream overlay', 'Results banner'],
acceptingApplications: true,
});
});
return pricings;
}
/**
* Create demo sponsorship requests (some pending, some accepted/rejected)
*/
function createSponsorshipRequests(
sponsors: DemoSponsorDTO[],
leagues: League[],
teams: DemoTeamDTO[],
drivers: Driver[],
races: Race[],
): DemoSponsorshipRequestDTO[] {
const requests: DemoSponsorshipRequestDTO[] = [];
const now = new Date();
// Pending request: Simucube wants to sponsor a driver
requests.push({
id: 'req-simucube-driver-1',
sponsorId: 'sponsor-simucube',
entityType: 'driver',
entityId: drivers[5].id,
tier: 'main',
offeredAmount: 25000, // $250.00 in cents
currency: 'USD',
message: 'We would love to sponsor your racing career! Simucube offers the best direct drive wheels in sim racing.',
status: 'pending',
createdAt: new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000), // 2 days ago
});
// Pending request: Heusinkveld wants to sponsor a team
requests.push({
id: 'req-heusinkveld-team-1',
sponsorId: 'sponsor-heusinkveld',
entityType: 'team',
entityId: teams[2].id,
tier: 'main',
offeredAmount: 55000, // $550.00 in cents
currency: 'USD',
message: 'Heusinkveld pedals are known for their precision. We believe your team embodies the same values.',
status: 'pending',
createdAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000), // 3 days ago
});
// Pending request: Trak Racer wants to sponsor a race
const upcomingRace = races.find(r => r.status === 'scheduled');
if (upcomingRace) {
requests.push({
id: 'req-trakracer-race-1',
sponsorId: 'sponsor-trak-racer',
entityType: 'race',
entityId: upcomingRace.id,
tier: 'main',
offeredAmount: 35000, // $350.00 in cents
currency: 'USD',
message: 'We would like to be the title sponsor for this exciting race event!',
status: 'pending',
createdAt: new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000), // 1 day ago
});
}
// Pending request: MotionRig Pro wants secondary spot on a league
const clubLeague = leagues.find(l => l.name === 'Sprint Challenge League');
if (clubLeague) {
requests.push({
id: 'req-motionrig-league-1',
sponsorId: 'sponsor-motionrig',
entityType: 'season',
entityId: `season-${clubLeague.id}-demo`,
tier: 'secondary',
offeredAmount: 150000, // $1500.00 in cents
currency: 'USD',
message: 'MotionRig Pro would love to be a secondary sponsor. Our motion platforms are perfect for your competitive drivers.',
status: 'pending',
createdAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), // 5 days ago
});
}
// Already accepted request (for history)
requests.push({
id: 'req-simlab-team-accepted',
sponsorId: 'sponsor-simlab',
entityType: 'team',
entityId: teams[0].id,
tier: 'secondary',
offeredAmount: 30000,
currency: 'USD',
message: 'Sim-Lab rigs are the foundation of any competitive setup.',
status: 'accepted',
createdAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), // 30 days ago
});
// Already rejected request (for history)
requests.push({
id: 'req-motionrig-driver-rejected',
sponsorId: 'sponsor-motionrig',
entityType: 'driver',
entityId: drivers[10].id,
tier: 'main',
offeredAmount: 15000,
currency: 'USD',
message: 'Would you like to represent MotionRig Pro?',
status: 'rejected',
createdAt: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), // 20 days ago
});
return requests;
}
export function createStaticRacingSeed(seed: number): RacingSeedData {
faker.seed(seed);
@@ -531,6 +902,10 @@ export function createStaticRacingSeed(seed: number): RacingSeedData {
const friendships = createFriendships(drivers);
const feedEvents = createFeedEvents(drivers, leagues, races, friendships);
const standings = createStandings(leagues, results);
const sponsors = DEMO_SPONSORS;
const seasonSponsorships = createSeasonSponsorships(leagues, sponsors);
const sponsorshipPricings = createSponsorshipPricings(leagues, teams, drivers, races);
const sponsorshipRequests = createSponsorshipRequests(sponsors, leagues, teams, drivers, races);
return {
drivers,
@@ -542,6 +917,10 @@ export function createStaticRacingSeed(seed: number): RacingSeedData {
friendships,
feedEvents,
teams,
sponsors,
seasonSponsorships,
sponsorshipRequests,
sponsorshipPricings,
};
}
@@ -568,6 +947,10 @@ export const teams = staticSeed.teams;
export const memberships = staticSeed.memberships;
export const friendships = staticSeed.friendships;
export const feedEvents = staticSeed.feedEvents;
export const sponsors = staticSeed.sponsors;
export const seasonSponsorships = staticSeed.seasonSponsorships;
export const sponsorshipRequests = staticSeed.sponsorshipRequests;
export const sponsorshipPricings = staticSeed.sponsorshipPricings;
/**
* Derived friend DTOs for UI consumption.