wip
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Query: GetEntityAnalyticsQuery
|
||||
*
|
||||
* Retrieves analytics data for an entity (league, driver, team, race).
|
||||
* Returns metrics formatted for display to sponsors and admins.
|
||||
*/
|
||||
|
||||
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
|
||||
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
|
||||
import type { IAnalyticsSnapshotRepository } from '../../domain/repositories/IAnalyticsSnapshotRepository';
|
||||
import type { EntityType } from '../../domain/entities/PageView';
|
||||
import type { SnapshotPeriod } from '../../domain/entities/AnalyticsSnapshot';
|
||||
|
||||
export interface GetEntityAnalyticsInput {
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
period?: SnapshotPeriod;
|
||||
since?: Date;
|
||||
}
|
||||
|
||||
export interface EntityAnalyticsOutput {
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
summary: {
|
||||
totalPageViews: number;
|
||||
uniqueVisitors: number;
|
||||
sponsorClicks: number;
|
||||
engagementScore: number;
|
||||
trustIndicator: 'high' | 'medium' | 'low';
|
||||
exposureValue: number;
|
||||
};
|
||||
trends: {
|
||||
pageViewsChange: number;
|
||||
uniqueVisitorsChange: number;
|
||||
engagementChange: number;
|
||||
};
|
||||
period: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
label: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class GetEntityAnalyticsQuery {
|
||||
constructor(
|
||||
private readonly pageViewRepository: IPageViewRepository,
|
||||
private readonly engagementRepository: IEngagementRepository,
|
||||
private readonly snapshotRepository: IAnalyticsSnapshotRepository
|
||||
) {}
|
||||
|
||||
async execute(input: GetEntityAnalyticsInput): Promise<EntityAnalyticsOutput> {
|
||||
const period = input.period ?? 'weekly';
|
||||
const now = new Date();
|
||||
const since = input.since ?? this.getPeriodStartDate(now, period);
|
||||
|
||||
// Get current metrics
|
||||
const totalPageViews = await this.pageViewRepository.countByEntityId(
|
||||
input.entityType,
|
||||
input.entityId,
|
||||
since
|
||||
);
|
||||
|
||||
const uniqueVisitors = await this.pageViewRepository.countUniqueVisitors(
|
||||
input.entityType,
|
||||
input.entityId,
|
||||
since
|
||||
);
|
||||
|
||||
const sponsorClicks = await this.engagementRepository.getSponsorClicksForEntity(
|
||||
input.entityId,
|
||||
since
|
||||
);
|
||||
|
||||
// Calculate engagement score (weighted sum of actions)
|
||||
const engagementScore = await this.calculateEngagementScore(input.entityId, since);
|
||||
|
||||
// Determine trust indicator
|
||||
const trustIndicator = this.determineTrustIndicator(totalPageViews, uniqueVisitors, engagementScore);
|
||||
|
||||
// Calculate exposure value (for sponsor ROI)
|
||||
const exposureValue = this.calculateExposureValue(totalPageViews, uniqueVisitors, sponsorClicks);
|
||||
|
||||
// Get previous period for trends
|
||||
const previousPeriodStart = this.getPreviousPeriodStart(since, period);
|
||||
const previousPageViews = await this.pageViewRepository.countByEntityId(
|
||||
input.entityType,
|
||||
input.entityId,
|
||||
previousPeriodStart
|
||||
) - totalPageViews;
|
||||
|
||||
const previousUniqueVisitors = await this.pageViewRepository.countUniqueVisitors(
|
||||
input.entityType,
|
||||
input.entityId,
|
||||
previousPeriodStart
|
||||
) - uniqueVisitors;
|
||||
|
||||
return {
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
summary: {
|
||||
totalPageViews,
|
||||
uniqueVisitors,
|
||||
sponsorClicks,
|
||||
engagementScore,
|
||||
trustIndicator,
|
||||
exposureValue,
|
||||
},
|
||||
trends: {
|
||||
pageViewsChange: this.calculatePercentageChange(previousPageViews, totalPageViews),
|
||||
uniqueVisitorsChange: this.calculatePercentageChange(previousUniqueVisitors, uniqueVisitors),
|
||||
engagementChange: 0, // Would need historical engagement data
|
||||
},
|
||||
period: {
|
||||
start: since,
|
||||
end: now,
|
||||
label: this.formatPeriodLabel(since, now),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private getPeriodStartDate(now: Date, period: SnapshotPeriod): Date {
|
||||
const start = new Date(now);
|
||||
switch (period) {
|
||||
case 'daily':
|
||||
start.setDate(start.getDate() - 1);
|
||||
break;
|
||||
case 'weekly':
|
||||
start.setDate(start.getDate() - 7);
|
||||
break;
|
||||
case 'monthly':
|
||||
start.setMonth(start.getMonth() - 1);
|
||||
break;
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
private getPreviousPeriodStart(currentStart: Date, period: SnapshotPeriod): Date {
|
||||
const start = new Date(currentStart);
|
||||
switch (period) {
|
||||
case 'daily':
|
||||
start.setDate(start.getDate() - 1);
|
||||
break;
|
||||
case 'weekly':
|
||||
start.setDate(start.getDate() - 7);
|
||||
break;
|
||||
case 'monthly':
|
||||
start.setMonth(start.getMonth() - 1);
|
||||
break;
|
||||
}
|
||||
return start;
|
||||
}
|
||||
|
||||
private async calculateEngagementScore(entityId: string, since: Date): Promise<number> {
|
||||
// Base engagement from sponsor interactions
|
||||
const sponsorClicks = await this.engagementRepository.getSponsorClicksForEntity(entityId, since);
|
||||
return sponsorClicks * 10; // Weighted score
|
||||
}
|
||||
|
||||
private determineTrustIndicator(
|
||||
pageViews: number,
|
||||
uniqueVisitors: number,
|
||||
engagementScore: number
|
||||
): 'high' | 'medium' | 'low' {
|
||||
const engagementRate = pageViews > 0 ? engagementScore / pageViews : 0;
|
||||
const returningVisitorRate = pageViews > 0 ? (pageViews - uniqueVisitors) / pageViews : 0;
|
||||
|
||||
if (engagementRate > 0.1 && returningVisitorRate > 0.3) {
|
||||
return 'high';
|
||||
}
|
||||
if (engagementRate > 0.05 || returningVisitorRate > 0.1) {
|
||||
return 'medium';
|
||||
}
|
||||
return 'low';
|
||||
}
|
||||
|
||||
private calculateExposureValue(
|
||||
pageViews: number,
|
||||
uniqueVisitors: number,
|
||||
sponsorClicks: number
|
||||
): number {
|
||||
// Simple exposure value calculation (could be monetized)
|
||||
return (pageViews * 0.01) + (uniqueVisitors * 0.05) + (sponsorClicks * 0.50);
|
||||
}
|
||||
|
||||
private calculatePercentageChange(previous: number, current: number): number {
|
||||
if (previous === 0) return current > 0 ? 100 : 0;
|
||||
return Math.round(((current - previous) / previous) * 100);
|
||||
}
|
||||
|
||||
private formatPeriodLabel(start: Date, end: Date): string {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
return `${formatter.format(start)} - ${formatter.format(end)}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Use Case: RecordEngagementUseCase
|
||||
*
|
||||
* Records an engagement event when a visitor interacts with an entity.
|
||||
*/
|
||||
|
||||
import { EngagementEvent, type EngagementAction, type EngagementEntityType } from '../../domain/entities/EngagementEvent';
|
||||
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
|
||||
|
||||
export interface RecordEngagementInput {
|
||||
action: EngagementAction;
|
||||
entityType: EngagementEntityType;
|
||||
entityId: string;
|
||||
actorId?: string;
|
||||
actorType: 'anonymous' | 'driver' | 'sponsor';
|
||||
sessionId: string;
|
||||
metadata?: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
export interface RecordEngagementOutput {
|
||||
eventId: string;
|
||||
engagementWeight: number;
|
||||
}
|
||||
|
||||
export class RecordEngagementUseCase {
|
||||
constructor(private readonly engagementRepository: IEngagementRepository) {}
|
||||
|
||||
async execute(input: RecordEngagementInput): Promise<RecordEngagementOutput> {
|
||||
const eventId = `eng-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const event = EngagementEvent.create({
|
||||
id: eventId,
|
||||
action: input.action,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
actorId: input.actorId,
|
||||
actorType: input.actorType,
|
||||
sessionId: input.sessionId,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
|
||||
await this.engagementRepository.save(event);
|
||||
|
||||
return {
|
||||
eventId,
|
||||
engagementWeight: event.getEngagementWeight(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Use Case: RecordPageViewUseCase
|
||||
*
|
||||
* Records a page view event when a visitor accesses an entity page.
|
||||
*/
|
||||
|
||||
import { PageView, type EntityType, type VisitorType } from '../../domain/entities/PageView';
|
||||
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
|
||||
|
||||
export interface RecordPageViewInput {
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
visitorId?: string;
|
||||
visitorType: VisitorType;
|
||||
sessionId: string;
|
||||
referrer?: string;
|
||||
userAgent?: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
export interface RecordPageViewOutput {
|
||||
pageViewId: string;
|
||||
}
|
||||
|
||||
export class RecordPageViewUseCase {
|
||||
constructor(private readonly pageViewRepository: IPageViewRepository) {}
|
||||
|
||||
async execute(input: RecordPageViewInput): Promise<RecordPageViewOutput> {
|
||||
const pageViewId = `pv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const pageView = PageView.create({
|
||||
id: pageViewId,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
visitorId: input.visitorId,
|
||||
visitorType: input.visitorType,
|
||||
sessionId: input.sessionId,
|
||||
referrer: input.referrer,
|
||||
userAgent: input.userAgent,
|
||||
country: input.country,
|
||||
});
|
||||
|
||||
await this.pageViewRepository.save(pageView);
|
||||
|
||||
return { pageViewId };
|
||||
}
|
||||
}
|
||||
162
packages/analytics/domain/entities/AnalyticsSnapshot.ts
Normal file
162
packages/analytics/domain/entities/AnalyticsSnapshot.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Domain Entity: AnalyticsSnapshot
|
||||
*
|
||||
* Aggregated analytics data for a specific entity over a time period.
|
||||
* Pre-calculated metrics for sponsor dashboard and entity analytics.
|
||||
*/
|
||||
|
||||
export type SnapshotPeriod = 'daily' | 'weekly' | 'monthly';
|
||||
export type SnapshotEntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor';
|
||||
|
||||
export interface AnalyticsMetrics {
|
||||
pageViews: number;
|
||||
uniqueVisitors: number;
|
||||
avgSessionDuration: number;
|
||||
bounceRate: number;
|
||||
engagementScore: number;
|
||||
sponsorClicks: number;
|
||||
sponsorUrlClicks: number;
|
||||
socialShares: number;
|
||||
leagueJoins: number;
|
||||
raceRegistrations: number;
|
||||
exposureValue: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsSnapshotProps {
|
||||
id: string;
|
||||
entityType: SnapshotEntityType;
|
||||
entityId: string;
|
||||
period: SnapshotPeriod;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
metrics: AnalyticsMetrics;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class AnalyticsSnapshot {
|
||||
readonly id: string;
|
||||
readonly entityType: SnapshotEntityType;
|
||||
readonly entityId: string;
|
||||
readonly period: SnapshotPeriod;
|
||||
readonly startDate: Date;
|
||||
readonly endDate: Date;
|
||||
readonly metrics: AnalyticsMetrics;
|
||||
readonly createdAt: Date;
|
||||
|
||||
private constructor(props: AnalyticsSnapshotProps) {
|
||||
this.id = props.id;
|
||||
this.entityType = props.entityType;
|
||||
this.entityId = props.entityId;
|
||||
this.period = props.period;
|
||||
this.startDate = props.startDate;
|
||||
this.endDate = props.endDate;
|
||||
this.metrics = props.metrics;
|
||||
this.createdAt = props.createdAt;
|
||||
}
|
||||
|
||||
static create(props: Omit<AnalyticsSnapshotProps, 'createdAt'> & { createdAt?: Date }): AnalyticsSnapshot {
|
||||
this.validate(props);
|
||||
|
||||
return new AnalyticsSnapshot({
|
||||
...props,
|
||||
createdAt: props.createdAt ?? new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
static createEmpty(
|
||||
id: string,
|
||||
entityType: SnapshotEntityType,
|
||||
entityId: string,
|
||||
period: SnapshotPeriod,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): AnalyticsSnapshot {
|
||||
return new AnalyticsSnapshot({
|
||||
id,
|
||||
entityType,
|
||||
entityId,
|
||||
period,
|
||||
startDate,
|
||||
endDate,
|
||||
metrics: {
|
||||
pageViews: 0,
|
||||
uniqueVisitors: 0,
|
||||
avgSessionDuration: 0,
|
||||
bounceRate: 0,
|
||||
engagementScore: 0,
|
||||
sponsorClicks: 0,
|
||||
sponsorUrlClicks: 0,
|
||||
socialShares: 0,
|
||||
leagueJoins: 0,
|
||||
raceRegistrations: 0,
|
||||
exposureValue: 0,
|
||||
},
|
||||
createdAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
private static validate(props: Omit<AnalyticsSnapshotProps, 'createdAt'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('AnalyticsSnapshot ID is required');
|
||||
}
|
||||
|
||||
if (!props.entityType) {
|
||||
throw new Error('AnalyticsSnapshot entityType is required');
|
||||
}
|
||||
|
||||
if (!props.entityId || props.entityId.trim().length === 0) {
|
||||
throw new Error('AnalyticsSnapshot entityId is required');
|
||||
}
|
||||
|
||||
if (!props.period) {
|
||||
throw new Error('AnalyticsSnapshot period is required');
|
||||
}
|
||||
|
||||
if (props.endDate < props.startDate) {
|
||||
throw new Error('AnalyticsSnapshot endDate must be after startDate');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate exposure score for sponsors (weighted combination of metrics)
|
||||
*/
|
||||
calculateExposureScore(): number {
|
||||
const { pageViews, uniqueVisitors, sponsorClicks, sponsorUrlClicks, socialShares } = this.metrics;
|
||||
|
||||
return (
|
||||
pageViews * 1 +
|
||||
uniqueVisitors * 2 +
|
||||
sponsorClicks * 10 +
|
||||
sponsorUrlClicks * 25 +
|
||||
socialShares * 5
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate trust indicator based on engagement quality
|
||||
*/
|
||||
getTrustIndicator(): 'high' | 'medium' | 'low' {
|
||||
const { bounceRate, avgSessionDuration, engagementScore } = this.metrics;
|
||||
|
||||
if (bounceRate < 30 && avgSessionDuration > 120000 && engagementScore > 50) {
|
||||
return 'high';
|
||||
}
|
||||
if (bounceRate < 60 && avgSessionDuration > 30000 && engagementScore > 20) {
|
||||
return 'medium';
|
||||
}
|
||||
return 'low';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get period comparison label
|
||||
*/
|
||||
getPeriodLabel(): string {
|
||||
const formatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: this.period === 'monthly' ? 'numeric' : undefined
|
||||
});
|
||||
|
||||
return `${formatter.format(this.startDate)} - ${formatter.format(this.endDate)}`;
|
||||
}
|
||||
}
|
||||
121
packages/analytics/domain/entities/EngagementEvent.ts
Normal file
121
packages/analytics/domain/entities/EngagementEvent.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Domain Entity: EngagementEvent
|
||||
*
|
||||
* Represents user interactions beyond page views.
|
||||
* Tracks clicks, downloads, sign-ups, and other engagement actions.
|
||||
*/
|
||||
|
||||
export type EngagementAction =
|
||||
| 'click_sponsor_logo'
|
||||
| 'click_sponsor_url'
|
||||
| 'download_livery_pack'
|
||||
| 'join_league'
|
||||
| 'register_race'
|
||||
| 'view_standings'
|
||||
| 'view_schedule'
|
||||
| 'share_social'
|
||||
| 'contact_sponsor';
|
||||
|
||||
export type EngagementEntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor' | 'sponsorship';
|
||||
|
||||
export interface EngagementEventProps {
|
||||
id: string;
|
||||
action: EngagementAction;
|
||||
entityType: EngagementEntityType;
|
||||
entityId: string;
|
||||
actorId?: string;
|
||||
actorType: 'anonymous' | 'driver' | 'sponsor';
|
||||
sessionId: string;
|
||||
metadata?: Record<string, string | number | boolean>;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export class EngagementEvent {
|
||||
readonly id: string;
|
||||
readonly action: EngagementAction;
|
||||
readonly entityType: EngagementEntityType;
|
||||
readonly entityId: string;
|
||||
readonly actorId?: string;
|
||||
readonly actorType: 'anonymous' | 'driver' | 'sponsor';
|
||||
readonly sessionId: string;
|
||||
readonly metadata?: Record<string, string | number | boolean>;
|
||||
readonly timestamp: Date;
|
||||
|
||||
private constructor(props: EngagementEventProps) {
|
||||
this.id = props.id;
|
||||
this.action = props.action;
|
||||
this.entityType = props.entityType;
|
||||
this.entityId = props.entityId;
|
||||
this.actorId = props.actorId;
|
||||
this.actorType = props.actorType;
|
||||
this.sessionId = props.sessionId;
|
||||
this.metadata = props.metadata;
|
||||
this.timestamp = props.timestamp;
|
||||
}
|
||||
|
||||
static create(props: Omit<EngagementEventProps, 'timestamp'> & { timestamp?: Date }): EngagementEvent {
|
||||
this.validate(props);
|
||||
|
||||
return new EngagementEvent({
|
||||
...props,
|
||||
timestamp: props.timestamp ?? new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
private static validate(props: Omit<EngagementEventProps, 'timestamp'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('EngagementEvent ID is required');
|
||||
}
|
||||
|
||||
if (!props.action) {
|
||||
throw new Error('EngagementEvent action is required');
|
||||
}
|
||||
|
||||
if (!props.entityType) {
|
||||
throw new Error('EngagementEvent entityType is required');
|
||||
}
|
||||
|
||||
if (!props.entityId || props.entityId.trim().length === 0) {
|
||||
throw new Error('EngagementEvent entityId is required');
|
||||
}
|
||||
|
||||
if (!props.sessionId || props.sessionId.trim().length === 0) {
|
||||
throw new Error('EngagementEvent sessionId is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a sponsor-related engagement
|
||||
*/
|
||||
isSponsorEngagement(): boolean {
|
||||
return this.action.startsWith('click_sponsor') ||
|
||||
this.action === 'contact_sponsor' ||
|
||||
this.entityType === 'sponsor' ||
|
||||
this.entityType === 'sponsorship';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a conversion event (high-value action)
|
||||
*/
|
||||
isConversionEvent(): boolean {
|
||||
return ['join_league', 'register_race', 'click_sponsor_url', 'contact_sponsor'].includes(this.action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engagement weight for analytics calculations
|
||||
*/
|
||||
getEngagementWeight(): number {
|
||||
const weights: Record<EngagementAction, number> = {
|
||||
'click_sponsor_logo': 2,
|
||||
'click_sponsor_url': 5,
|
||||
'download_livery_pack': 3,
|
||||
'join_league': 10,
|
||||
'register_race': 8,
|
||||
'view_standings': 1,
|
||||
'view_schedule': 1,
|
||||
'share_social': 4,
|
||||
'contact_sponsor': 15,
|
||||
};
|
||||
return weights[this.action] || 1;
|
||||
}
|
||||
}
|
||||
107
packages/analytics/domain/entities/PageView.ts
Normal file
107
packages/analytics/domain/entities/PageView.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Domain Entity: PageView
|
||||
*
|
||||
* Represents a single page view event for analytics tracking.
|
||||
* Captures visitor interactions with leagues, drivers, teams, races.
|
||||
*/
|
||||
|
||||
export type EntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor';
|
||||
export type VisitorType = 'anonymous' | 'driver' | 'sponsor';
|
||||
|
||||
export interface PageViewProps {
|
||||
id: string;
|
||||
entityType: EntityType;
|
||||
entityId: string;
|
||||
visitorId?: string;
|
||||
visitorType: VisitorType;
|
||||
sessionId: string;
|
||||
referrer?: string;
|
||||
userAgent?: string;
|
||||
country?: string;
|
||||
timestamp: Date;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export class PageView {
|
||||
readonly id: string;
|
||||
readonly entityType: EntityType;
|
||||
readonly entityId: string;
|
||||
readonly visitorId?: string;
|
||||
readonly visitorType: VisitorType;
|
||||
readonly sessionId: string;
|
||||
readonly referrer?: string;
|
||||
readonly userAgent?: string;
|
||||
readonly country?: string;
|
||||
readonly timestamp: Date;
|
||||
readonly durationMs?: number;
|
||||
|
||||
private constructor(props: PageViewProps) {
|
||||
this.id = props.id;
|
||||
this.entityType = props.entityType;
|
||||
this.entityId = props.entityId;
|
||||
this.visitorId = props.visitorId;
|
||||
this.visitorType = props.visitorType;
|
||||
this.sessionId = props.sessionId;
|
||||
this.referrer = props.referrer;
|
||||
this.userAgent = props.userAgent;
|
||||
this.country = props.country;
|
||||
this.timestamp = props.timestamp;
|
||||
this.durationMs = props.durationMs;
|
||||
}
|
||||
|
||||
static create(props: Omit<PageViewProps, 'timestamp'> & { timestamp?: Date }): PageView {
|
||||
this.validate(props);
|
||||
|
||||
return new PageView({
|
||||
...props,
|
||||
timestamp: props.timestamp ?? new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
private static validate(props: Omit<PageViewProps, 'timestamp'>): void {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new Error('PageView ID is required');
|
||||
}
|
||||
|
||||
if (!props.entityType) {
|
||||
throw new Error('PageView entityType is required');
|
||||
}
|
||||
|
||||
if (!props.entityId || props.entityId.trim().length === 0) {
|
||||
throw new Error('PageView entityId is required');
|
||||
}
|
||||
|
||||
if (!props.sessionId || props.sessionId.trim().length === 0) {
|
||||
throw new Error('PageView sessionId is required');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update duration when visitor leaves page
|
||||
*/
|
||||
withDuration(durationMs: number): PageView {
|
||||
if (durationMs < 0) {
|
||||
throw new Error('Duration must be non-negative');
|
||||
}
|
||||
|
||||
return new PageView({
|
||||
...this,
|
||||
durationMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a meaningful view (not a bounce)
|
||||
*/
|
||||
isMeaningfulView(): boolean {
|
||||
return this.durationMs !== undefined && this.durationMs >= 5000; // 5+ seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if view came from external source
|
||||
*/
|
||||
isExternalReferral(): boolean {
|
||||
if (!this.referrer) return false;
|
||||
return !this.referrer.includes('gridpilot');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Repository Interface: IAnalyticsSnapshotRepository
|
||||
*
|
||||
* Defines persistence operations for AnalyticsSnapshot entities.
|
||||
*/
|
||||
|
||||
import type { AnalyticsSnapshot, SnapshotPeriod, SnapshotEntityType } from '../entities/AnalyticsSnapshot';
|
||||
|
||||
export interface IAnalyticsSnapshotRepository {
|
||||
save(snapshot: AnalyticsSnapshot): Promise<void>;
|
||||
findById(id: string): Promise<AnalyticsSnapshot | null>;
|
||||
findByEntity(entityType: SnapshotEntityType, entityId: string): Promise<AnalyticsSnapshot[]>;
|
||||
findByPeriod(
|
||||
entityType: SnapshotEntityType,
|
||||
entityId: string,
|
||||
period: SnapshotPeriod,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<AnalyticsSnapshot | null>;
|
||||
findLatest(entityType: SnapshotEntityType, entityId: string, period: SnapshotPeriod): Promise<AnalyticsSnapshot | null>;
|
||||
getHistoricalSnapshots(
|
||||
entityType: SnapshotEntityType,
|
||||
entityId: string,
|
||||
period: SnapshotPeriod,
|
||||
limit: number
|
||||
): Promise<AnalyticsSnapshot[]>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Repository Interface: IEngagementRepository
|
||||
*
|
||||
* Defines persistence operations for EngagementEvent entities.
|
||||
*/
|
||||
|
||||
import type { EngagementEvent, EngagementAction, EngagementEntityType } from '../entities/EngagementEvent';
|
||||
|
||||
export interface IEngagementRepository {
|
||||
save(event: EngagementEvent): Promise<void>;
|
||||
findById(id: string): Promise<EngagementEvent | null>;
|
||||
findByEntityId(entityType: EngagementEntityType, entityId: string): Promise<EngagementEvent[]>;
|
||||
findByAction(action: EngagementAction): Promise<EngagementEvent[]>;
|
||||
findByDateRange(startDate: Date, endDate: Date): Promise<EngagementEvent[]>;
|
||||
countByAction(action: EngagementAction, entityId?: string, since?: Date): Promise<number>;
|
||||
getSponsorClicksForEntity(entityId: string, since?: Date): Promise<number>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Repository Interface: IPageViewRepository
|
||||
*
|
||||
* Defines persistence operations for PageView entities.
|
||||
*/
|
||||
|
||||
import type { PageView, EntityType } from '../entities/PageView';
|
||||
|
||||
export interface IPageViewRepository {
|
||||
save(pageView: PageView): Promise<void>;
|
||||
findById(id: string): Promise<PageView | null>;
|
||||
findByEntityId(entityType: EntityType, entityId: string): Promise<PageView[]>;
|
||||
findByDateRange(startDate: Date, endDate: Date): Promise<PageView[]>;
|
||||
findBySession(sessionId: string): Promise<PageView[]>;
|
||||
countByEntityId(entityType: EntityType, entityId: string, since?: Date): Promise<number>;
|
||||
countUniqueVisitors(entityType: EntityType, entityId: string, since?: Date): Promise<number>;
|
||||
}
|
||||
26
packages/analytics/index.ts
Normal file
26
packages/analytics/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @gridpilot/analytics
|
||||
*
|
||||
* Analytics bounded context - tracks page views, engagement events,
|
||||
* and generates analytics snapshots for sponsor exposure metrics.
|
||||
*/
|
||||
|
||||
// Domain entities
|
||||
export * from './domain/entities/PageView';
|
||||
export * from './domain/entities/EngagementEvent';
|
||||
export * from './domain/entities/AnalyticsSnapshot';
|
||||
|
||||
// Domain repositories
|
||||
export * from './domain/repositories/IPageViewRepository';
|
||||
export * from './domain/repositories/IEngagementRepository';
|
||||
export * from './domain/repositories/IAnalyticsSnapshotRepository';
|
||||
|
||||
// Application use cases
|
||||
export * from './application/use-cases/RecordPageViewUseCase';
|
||||
export * from './application/use-cases/RecordEngagementUseCase';
|
||||
export * from './application/use-cases/GetEntityAnalyticsQuery';
|
||||
|
||||
// Infrastructure
|
||||
export * from './infrastructure/repositories/InMemoryPageViewRepository';
|
||||
export * from './infrastructure/repositories/InMemoryEngagementRepository';
|
||||
export * from './infrastructure/repositories/InMemoryAnalyticsSnapshotRepository';
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Infrastructure: InMemoryAnalyticsSnapshotRepository
|
||||
*
|
||||
* In-memory implementation of IAnalyticsSnapshotRepository for development/testing.
|
||||
*/
|
||||
|
||||
import type { IAnalyticsSnapshotRepository } from '../../domain/repositories/IAnalyticsSnapshotRepository';
|
||||
import { AnalyticsSnapshot, type SnapshotPeriod, type SnapshotEntityType } from '../../domain/entities/AnalyticsSnapshot';
|
||||
|
||||
export class InMemoryAnalyticsSnapshotRepository implements IAnalyticsSnapshotRepository {
|
||||
private snapshots: Map<string, AnalyticsSnapshot> = new Map();
|
||||
|
||||
async save(snapshot: AnalyticsSnapshot): Promise<void> {
|
||||
this.snapshots.set(snapshot.id, snapshot);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<AnalyticsSnapshot | null> {
|
||||
return this.snapshots.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findByEntity(entityType: SnapshotEntityType, entityId: string): Promise<AnalyticsSnapshot[]> {
|
||||
return Array.from(this.snapshots.values()).filter(
|
||||
s => s.entityType === entityType && s.entityId === entityId
|
||||
);
|
||||
}
|
||||
|
||||
async findByPeriod(
|
||||
entityType: SnapshotEntityType,
|
||||
entityId: string,
|
||||
period: SnapshotPeriod,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<AnalyticsSnapshot | null> {
|
||||
return Array.from(this.snapshots.values()).find(
|
||||
s => s.entityType === entityType &&
|
||||
s.entityId === entityId &&
|
||||
s.period === period &&
|
||||
s.startDate >= startDate &&
|
||||
s.endDate <= endDate
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
async findLatest(
|
||||
entityType: SnapshotEntityType,
|
||||
entityId: string,
|
||||
period: SnapshotPeriod
|
||||
): Promise<AnalyticsSnapshot | null> {
|
||||
const matching = Array.from(this.snapshots.values())
|
||||
.filter(s => s.entityType === entityType && s.entityId === entityId && s.period === period)
|
||||
.sort((a, b) => b.endDate.getTime() - a.endDate.getTime());
|
||||
|
||||
return matching[0] ?? null;
|
||||
}
|
||||
|
||||
async getHistoricalSnapshots(
|
||||
entityType: SnapshotEntityType,
|
||||
entityId: string,
|
||||
period: SnapshotPeriod,
|
||||
limit: number
|
||||
): Promise<AnalyticsSnapshot[]> {
|
||||
return Array.from(this.snapshots.values())
|
||||
.filter(s => s.entityType === entityType && s.entityId === entityId && s.period === period)
|
||||
.sort((a, b) => b.endDate.getTime() - a.endDate.getTime())
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
// Helper for testing
|
||||
clear(): void {
|
||||
this.snapshots.clear();
|
||||
}
|
||||
|
||||
// Helper for seeding demo data
|
||||
seed(snapshots: AnalyticsSnapshot[]): void {
|
||||
snapshots.forEach(s => this.snapshots.set(s.id, s));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Infrastructure: InMemoryEngagementRepository
|
||||
*
|
||||
* In-memory implementation of IEngagementRepository for development/testing.
|
||||
*/
|
||||
|
||||
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
|
||||
import { EngagementEvent, type EngagementAction, type EngagementEntityType } from '../../domain/entities/EngagementEvent';
|
||||
|
||||
export class InMemoryEngagementRepository implements IEngagementRepository {
|
||||
private events: Map<string, EngagementEvent> = new Map();
|
||||
|
||||
async save(event: EngagementEvent): Promise<void> {
|
||||
this.events.set(event.id, event);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<EngagementEvent | null> {
|
||||
return this.events.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findByEntityId(entityType: EngagementEntityType, entityId: string): Promise<EngagementEvent[]> {
|
||||
return Array.from(this.events.values()).filter(
|
||||
e => e.entityType === entityType && e.entityId === entityId
|
||||
);
|
||||
}
|
||||
|
||||
async findByAction(action: EngagementAction): Promise<EngagementEvent[]> {
|
||||
return Array.from(this.events.values()).filter(
|
||||
e => e.action === action
|
||||
);
|
||||
}
|
||||
|
||||
async findByDateRange(startDate: Date, endDate: Date): Promise<EngagementEvent[]> {
|
||||
return Array.from(this.events.values()).filter(
|
||||
e => e.timestamp >= startDate && e.timestamp <= endDate
|
||||
);
|
||||
}
|
||||
|
||||
async countByAction(action: EngagementAction, entityId?: string, since?: Date): Promise<number> {
|
||||
return Array.from(this.events.values()).filter(
|
||||
e => e.action === action &&
|
||||
(!entityId || e.entityId === entityId) &&
|
||||
(!since || e.timestamp >= since)
|
||||
).length;
|
||||
}
|
||||
|
||||
async getSponsorClicksForEntity(entityId: string, since?: Date): Promise<number> {
|
||||
return Array.from(this.events.values()).filter(
|
||||
e => e.entityId === entityId &&
|
||||
(e.action === 'click_sponsor_logo' || e.action === 'click_sponsor_url') &&
|
||||
(!since || e.timestamp >= since)
|
||||
).length;
|
||||
}
|
||||
|
||||
// Helper for testing
|
||||
clear(): void {
|
||||
this.events.clear();
|
||||
}
|
||||
|
||||
// Helper for seeding demo data
|
||||
seed(events: EngagementEvent[]): void {
|
||||
events.forEach(e => this.events.set(e.id, e));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Infrastructure: InMemoryPageViewRepository
|
||||
*
|
||||
* In-memory implementation of IPageViewRepository for development/testing.
|
||||
*/
|
||||
|
||||
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
|
||||
import { PageView, type EntityType } from '../../domain/entities/PageView';
|
||||
|
||||
export class InMemoryPageViewRepository implements IPageViewRepository {
|
||||
private pageViews: Map<string, PageView> = new Map();
|
||||
|
||||
async save(pageView: PageView): Promise<void> {
|
||||
this.pageViews.set(pageView.id, pageView);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<PageView | null> {
|
||||
return this.pageViews.get(id) ?? null;
|
||||
}
|
||||
|
||||
async findByEntityId(entityType: EntityType, entityId: string): Promise<PageView[]> {
|
||||
return Array.from(this.pageViews.values()).filter(
|
||||
pv => pv.entityType === entityType && pv.entityId === entityId
|
||||
);
|
||||
}
|
||||
|
||||
async findByDateRange(startDate: Date, endDate: Date): Promise<PageView[]> {
|
||||
return Array.from(this.pageViews.values()).filter(
|
||||
pv => pv.timestamp >= startDate && pv.timestamp <= endDate
|
||||
);
|
||||
}
|
||||
|
||||
async findBySession(sessionId: string): Promise<PageView[]> {
|
||||
return Array.from(this.pageViews.values()).filter(
|
||||
pv => pv.sessionId === sessionId
|
||||
);
|
||||
}
|
||||
|
||||
async countByEntityId(entityType: EntityType, entityId: string, since?: Date): Promise<number> {
|
||||
return Array.from(this.pageViews.values()).filter(
|
||||
pv => pv.entityType === entityType &&
|
||||
pv.entityId === entityId &&
|
||||
(!since || pv.timestamp >= since)
|
||||
).length;
|
||||
}
|
||||
|
||||
async countUniqueVisitors(entityType: EntityType, entityId: string, since?: Date): Promise<number> {
|
||||
const visitors = new Set<string>();
|
||||
Array.from(this.pageViews.values())
|
||||
.filter(
|
||||
pv => pv.entityType === entityType &&
|
||||
pv.entityId === entityId &&
|
||||
(!since || pv.timestamp >= since)
|
||||
)
|
||||
.forEach(pv => {
|
||||
visitors.add(pv.visitorId ?? pv.sessionId);
|
||||
});
|
||||
return visitors.size;
|
||||
}
|
||||
|
||||
// Helper for testing
|
||||
clear(): void {
|
||||
this.pageViews.clear();
|
||||
}
|
||||
|
||||
// Helper for seeding demo data
|
||||
seed(pageViews: PageView[]): void {
|
||||
pageViews.forEach(pv => this.pageViews.set(pv.id, pv));
|
||||
}
|
||||
}
|
||||
14
packages/analytics/package.json
Normal file
14
packages/analytics/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@gridpilot/analytics",
|
||||
"version": "0.1.0",
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./domain/*": "./domain/*",
|
||||
"./application/*": "./application/*",
|
||||
"./infrastructure/*": "./infrastructure/*"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
390
packages/identity/domain/entities/Achievement.ts
Normal file
390
packages/identity/domain/entities/Achievement.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
151
packages/identity/domain/entities/SponsorAccount.ts
Normal file
151
packages/identity/domain/entities/SponsorAccount.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
83
packages/identity/domain/entities/UserAchievement.ts
Normal file
83
packages/identity/domain/entities/UserAchievement.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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> }>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
255
packages/identity/domain/value-objects/UserRating.ts
Normal file
255
packages/identity/domain/value-objects/UserRating.ts
Normal 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
184
packages/racing/domain/entities/SponsorshipRequest.ts
Normal file
184
packages/racing/domain/entities/SponsorshipRequest.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}>>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
208
packages/racing/domain/value-objects/SponsorshipPricing.ts
Normal file
208
packages/racing/domain/value-objects/SponsorshipPricing.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user