This commit is contained in:
2025-12-11 13:50:38 +01:00
parent e4c1be628d
commit c7e5de40d6
212 changed files with 2965 additions and 763 deletions

View File

@@ -5,10 +5,12 @@
* and creating a generation request.
*/
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository';
import type { FaceValidationPort } from '../ports/FaceValidationPort';
import type { AvatarGenerationPort } from '../ports/AvatarGenerationPort';
import { AvatarGenerationRequest, type RacingSuitColor, type AvatarStyle } from '../../domain/entities/AvatarGenerationRequest';
import { AvatarGenerationRequest } from '../../domain/entities/AvatarGenerationRequest';
import type { RacingSuitColor, AvatarStyle } from '../../domain/types/AvatarGenerationRequest';
export interface RequestAvatarGenerationCommand {
userId: string;
@@ -24,7 +26,8 @@ export interface RequestAvatarGenerationResult {
errorMessage?: string;
}
export class RequestAvatarGenerationUseCase {
export class RequestAvatarGenerationUseCase
implements AsyncUseCase<RequestAvatarGenerationCommand, RequestAvatarGenerationResult> {
constructor(
private readonly avatarRepository: IAvatarGenerationRepository,
private readonly faceValidation: FaceValidationPort,
@@ -85,7 +88,7 @@ export class RequestAvatarGenerationUseCase {
// Generate avatars
const generationResult = await this.avatarGeneration.generateAvatars({
facePhotoUrl: request.facePhotoUrl,
facePhotoUrl: request.facePhotoUrl.value,
prompt: request.buildPrompt(),
suitColor: request.suitColor,
style: request.style,

View File

@@ -4,6 +4,7 @@
* Allows a user to select one of the generated avatars as their profile avatar.
*/
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository';
export interface SelectAvatarCommand {
@@ -18,7 +19,8 @@ export interface SelectAvatarResult {
errorMessage?: string;
}
export class SelectAvatarUseCase {
export class SelectAvatarUseCase
implements AsyncUseCase<SelectAvatarCommand, SelectAvatarResult> {
constructor(
private readonly avatarRepository: IAvatarGenerationRepository,
) {}

View File

@@ -1,55 +1,26 @@
/**
* Domain Entity: AvatarGenerationRequest
*
*
* Represents a request to generate a racing avatar from a face photo.
*/
export type RacingSuitColor =
| 'red'
| 'blue'
| 'green'
| 'yellow'
| 'orange'
| 'purple'
| 'black'
| 'white'
| 'pink'
| 'cyan';
export type AvatarStyle =
| 'realistic'
| 'cartoon'
| 'pixel-art';
export type AvatarGenerationStatus =
| 'pending'
| 'validating'
| 'generating'
| 'completed'
| 'failed';
export interface AvatarGenerationRequestProps {
id: string;
userId: string;
facePhotoUrl: string;
suitColor: RacingSuitColor;
style: AvatarStyle;
status: AvatarGenerationStatus;
generatedAvatarUrls: string[];
selectedAvatarIndex?: number;
errorMessage?: string;
createdAt: Date;
updatedAt: Date;
}
export class AvatarGenerationRequest {
import type { IEntity } from '@gridpilot/shared/domain';
import type {
AvatarGenerationRequestProps,
AvatarGenerationStatus,
AvatarStyle,
RacingSuitColor,
} from '../types/AvatarGenerationRequest';
import { MediaUrl } from '../value-objects/MediaUrl';
export class AvatarGenerationRequest implements IEntity<string> {
readonly id: string;
readonly userId: string;
readonly facePhotoUrl: string;
readonly facePhotoUrl: MediaUrl;
readonly suitColor: RacingSuitColor;
readonly style: AvatarStyle;
private _status: AvatarGenerationStatus;
private _generatedAvatarUrls: string[];
private _generatedAvatarUrls: MediaUrl[];
private _selectedAvatarIndex?: number;
private _errorMessage?: string;
readonly createdAt: Date;
@@ -58,11 +29,11 @@ export class AvatarGenerationRequest {
private constructor(props: AvatarGenerationRequestProps) {
this.id = props.id;
this.userId = props.userId;
this.facePhotoUrl = props.facePhotoUrl;
this.facePhotoUrl = MediaUrl.create(props.facePhotoUrl);
this.suitColor = props.suitColor;
this.style = props.style;
this._status = props.status;
this._generatedAvatarUrls = [...props.generatedAvatarUrls];
this._generatedAvatarUrls = props.generatedAvatarUrls.map(url => MediaUrl.create(url));
this._selectedAvatarIndex = props.selectedAvatarIndex;
this._errorMessage = props.errorMessage;
this.createdAt = props.createdAt;
@@ -106,7 +77,7 @@ export class AvatarGenerationRequest {
}
get generatedAvatarUrls(): string[] {
return [...this._generatedAvatarUrls];
return this._generatedAvatarUrls.map(url => url.value);
}
get selectedAvatarIndex(): number | undefined {
@@ -115,7 +86,7 @@ export class AvatarGenerationRequest {
get selectedAvatarUrl(): string | undefined {
if (this._selectedAvatarIndex !== undefined && this._generatedAvatarUrls[this._selectedAvatarIndex]) {
return this._generatedAvatarUrls[this._selectedAvatarIndex];
return this._generatedAvatarUrls[this._selectedAvatarIndex].value;
}
return undefined;
}
@@ -149,7 +120,7 @@ export class AvatarGenerationRequest {
throw new Error('At least one avatar URL is required');
}
this._status = 'completed';
this._generatedAvatarUrls = [...avatarUrls];
this._generatedAvatarUrls = avatarUrls.map(url => MediaUrl.create(url));
this._updatedAt = new Date();
}
@@ -204,11 +175,11 @@ export class AvatarGenerationRequest {
return {
id: this.id,
userId: this.userId,
facePhotoUrl: this.facePhotoUrl,
facePhotoUrl: this.facePhotoUrl.value,
suitColor: this.suitColor,
style: this.style,
status: this._status,
generatedAvatarUrls: [...this._generatedAvatarUrls],
generatedAvatarUrls: this._generatedAvatarUrls.map(url => url.value),
selectedAvatarIndex: this._selectedAvatarIndex,
errorMessage: this._errorMessage,
createdAt: this.createdAt,

View File

@@ -0,0 +1,44 @@
/**
* Domain Types: AvatarGenerationRequest
*
* Pure type/config definitions used by the AvatarGenerationRequest entity.
* Kept in domain/types so domain/entities contains only entity classes.
*/
export type RacingSuitColor =
| 'red'
| 'blue'
| 'green'
| 'yellow'
| 'orange'
| 'purple'
| 'black'
| 'white'
| 'pink'
| 'cyan';
export type AvatarStyle =
| 'realistic'
| 'cartoon'
| 'pixel-art';
export type AvatarGenerationStatus =
| 'pending'
| 'validating'
| 'generating'
| 'completed'
| 'failed';
export interface AvatarGenerationRequestProps {
id: string;
userId: string;
facePhotoUrl: string;
suitColor: RacingSuitColor;
style: AvatarStyle;
status: AvatarGenerationStatus;
generatedAvatarUrls: string[];
selectedAvatarIndex?: number;
errorMessage?: string;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -0,0 +1,36 @@
import { MediaUrl } from './MediaUrl';
describe('MediaUrl', () => {
it('creates from valid http/https URLs', () => {
expect(MediaUrl.create('http://example.com').value).toBe('http://example.com');
expect(MediaUrl.create('https://example.com/path').value).toBe('https://example.com/path');
});
it('creates from data URIs', () => {
const url = 'data:image/jpeg;base64,AAA';
expect(MediaUrl.create(url).value).toBe(url);
});
it('creates from root-relative paths', () => {
expect(MediaUrl.create('/images/avatar.png').value).toBe('/images/avatar.png');
});
it('rejects empty or whitespace URLs', () => {
expect(() => MediaUrl.create('')).toThrow();
expect(() => MediaUrl.create(' ')).toThrow();
});
it('rejects unsupported schemes', () => {
expect(() => MediaUrl.create('ftp://example.com/file')).toThrow();
expect(() => MediaUrl.create('mailto:user@example.com')).toThrow();
});
it('implements value-based equality', () => {
const a = MediaUrl.create('https://example.com/a.png');
const b = MediaUrl.create('https://example.com/a.png');
const c = MediaUrl.create('https://example.com/b.png');
expect(a.equals(b)).toBe(true);
expect(a.equals(c)).toBe(false);
});
});

View File

@@ -0,0 +1,46 @@
import type { IValueObject } from '@gridpilot/shared/domain';
/**
* Value Object: MediaUrl
*
* Represents a validated media URL used for user-uploaded or generated assets.
* For now this is a conservative wrapper around strings with basic invariants:
* - non-empty
* - must start with "http", "https", "data:", or "/"
*/
export interface MediaUrlProps {
value: string;
}
export class MediaUrl implements IValueObject<MediaUrlProps> {
public readonly props: MediaUrlProps;
private constructor(value: string) {
this.props = { value };
}
static create(raw: string): MediaUrl {
const value = raw?.trim();
if (!value) {
throw new Error('Media URL cannot be empty');
}
const allowedPrefixes = ['http://', 'https://', 'data:', '/'];
const isAllowed = allowedPrefixes.some((prefix) => value.startsWith(prefix));
if (!isAllowed) {
throw new Error('Media URL must be http(s), data URI, or root-relative path');
}
return new MediaUrl(value);
}
get value(): string {
return this.props.value;
}
equals(other: IValueObject<MediaUrlProps>): boolean {
return this.props.value === other.props.value;
}
}