wip
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user