wip
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
44
packages/media/domain/types/AvatarGenerationRequest.ts
Normal file
44
packages/media/domain/types/AvatarGenerationRequest.ts
Normal 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;
|
||||
}
|
||||
36
packages/media/domain/value-objects/MediaUrl.test.ts
Normal file
36
packages/media/domain/value-objects/MediaUrl.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
46
packages/media/domain/value-objects/MediaUrl.ts
Normal file
46
packages/media/domain/value-objects/MediaUrl.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user