harden media
This commit is contained in:
286
core/domain/media/MediaReference.ts
Normal file
286
core/domain/media/MediaReference.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Domain Value Object: MediaReference
|
||||
*
|
||||
* Discriminated union representing different types of media references:
|
||||
* - system-default: Pre-defined default media (e.g., default avatar/logo)
|
||||
* - generated: Media generated by AI/algorithm (references generation request)
|
||||
* - uploaded: User-uploaded media (references media entity)
|
||||
* - none: Explicit no-media placeholder
|
||||
*
|
||||
* Follows clean architecture and TDD principles.
|
||||
*/
|
||||
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
|
||||
// Variant types for system-default references
|
||||
export type MediaVariant = 'avatar' | 'logo';
|
||||
|
||||
// Specific variants for avatars (deterministic selection)
|
||||
export type AvatarVariant = 'male' | 'female' | 'neutral';
|
||||
|
||||
// Discriminated union types
|
||||
export interface SystemDefaultRef {
|
||||
type: 'system-default';
|
||||
variant: MediaVariant;
|
||||
avatarVariant?: AvatarVariant | undefined; // Only used when variant is 'avatar'
|
||||
}
|
||||
|
||||
export interface GeneratedRef {
|
||||
type: 'generated';
|
||||
generationRequestId: string;
|
||||
}
|
||||
|
||||
export interface UploadedRef {
|
||||
type: 'uploaded';
|
||||
mediaId: string;
|
||||
}
|
||||
|
||||
export interface NoneRef {
|
||||
type: 'none';
|
||||
}
|
||||
|
||||
// Union of all reference types
|
||||
export type MediaReferenceProps = SystemDefaultRef | GeneratedRef | UploadedRef | NoneRef;
|
||||
|
||||
// Type guards
|
||||
function isSystemDefaultRef(props: unknown): props is SystemDefaultRef {
|
||||
const typedProps = props as SystemDefaultRef;
|
||||
if (typedProps?.type !== 'system-default') {
|
||||
return false;
|
||||
}
|
||||
if (typedProps.variant !== 'avatar' && typedProps.variant !== 'logo') {
|
||||
return false;
|
||||
}
|
||||
// avatarVariant is optional, but if present must be valid
|
||||
if (typedProps.avatarVariant !== undefined) {
|
||||
return typedProps.avatarVariant === 'male' || typedProps.avatarVariant === 'female' || typedProps.avatarVariant === 'neutral';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isGeneratedRef(props: unknown): props is GeneratedRef {
|
||||
const typedProps = props as GeneratedRef;
|
||||
return typedProps?.type === 'generated' &&
|
||||
typeof typedProps.generationRequestId === 'string' &&
|
||||
typedProps.generationRequestId.trim().length > 0;
|
||||
}
|
||||
|
||||
function isUploadedRef(props: unknown): props is UploadedRef {
|
||||
const typedProps = props as UploadedRef;
|
||||
return typedProps?.type === 'uploaded' &&
|
||||
typeof typedProps.mediaId === 'string' &&
|
||||
typedProps.mediaId.trim().length > 0;
|
||||
}
|
||||
|
||||
function isNoneRef(props: unknown): props is NoneRef {
|
||||
const typedProps = props as NoneRef;
|
||||
return typedProps?.type === 'none' &&
|
||||
Object.keys(typedProps).length === 1; // Only 'type' property
|
||||
}
|
||||
|
||||
export class MediaReference implements IValueObject<MediaReferenceProps> {
|
||||
public readonly props: MediaReferenceProps;
|
||||
|
||||
private constructor(props: MediaReferenceProps) {
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create system-default reference
|
||||
*/
|
||||
static createSystemDefault(variant: MediaVariant = 'avatar', avatarVariant?: AvatarVariant): MediaReference {
|
||||
const props: SystemDefaultRef = { type: 'system-default', variant };
|
||||
if (variant === 'avatar' && avatarVariant) {
|
||||
props.avatarVariant = avatarVariant;
|
||||
}
|
||||
return new MediaReference(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method for system default (alias for task compatibility)
|
||||
*/
|
||||
static systemDefault(variant: MediaVariant | AvatarVariant = 'avatar'): MediaReference {
|
||||
// If it's an avatar variant, use it
|
||||
if (variant === 'male' || variant === 'female' || variant === 'neutral') {
|
||||
return new MediaReference({ type: 'system-default', variant: 'avatar', avatarVariant: variant });
|
||||
}
|
||||
// Otherwise it's a regular variant
|
||||
return new MediaReference({ type: 'system-default', variant: variant as MediaVariant });
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method for generated references (alias for task compatibility)
|
||||
*/
|
||||
static generated(type: string, id: string): MediaReference {
|
||||
return new MediaReference({ type: 'generated', generationRequestId: `${type}-${id}` });
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create generated reference
|
||||
*/
|
||||
static createGenerated(generationRequestId: string): MediaReference {
|
||||
const trimmed = generationRequestId.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Generation request ID is required');
|
||||
}
|
||||
return new MediaReference({ type: 'generated', generationRequestId: trimmed });
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create uploaded reference
|
||||
*/
|
||||
static createUploaded(mediaId: string): MediaReference {
|
||||
const trimmed = mediaId.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Media ID is required');
|
||||
}
|
||||
return new MediaReference({ type: 'uploaded', mediaId: trimmed });
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create none reference
|
||||
*/
|
||||
static createNone(): MediaReference {
|
||||
return new MediaReference({ type: 'none' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize from JSON
|
||||
*/
|
||||
static fromJSON(json: Record<string, unknown>): MediaReference {
|
||||
if (!json || typeof json !== 'object') {
|
||||
throw new Error('Invalid JSON: must be an object');
|
||||
}
|
||||
|
||||
const type = json.type;
|
||||
if (!type || typeof type !== 'string') {
|
||||
throw new Error('Type is required');
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'system-default':
|
||||
if (!isSystemDefaultRef(json)) {
|
||||
throw new Error('Invalid variant for system-default. Must be "avatar" or "logo"');
|
||||
}
|
||||
return new MediaReference({
|
||||
type: 'system-default',
|
||||
variant: json.variant,
|
||||
avatarVariant: json.avatarVariant
|
||||
});
|
||||
|
||||
case 'generated':
|
||||
if (!isGeneratedRef(json)) {
|
||||
throw new Error('Generation request ID is required');
|
||||
}
|
||||
return new MediaReference({ type: 'generated', generationRequestId: json.generationRequestId });
|
||||
|
||||
case 'uploaded':
|
||||
if (!isUploadedRef(json)) {
|
||||
throw new Error('Media ID is required');
|
||||
}
|
||||
return new MediaReference({ type: 'uploaded', mediaId: json.mediaId });
|
||||
|
||||
case 'none':
|
||||
if (!isNoneRef(json)) {
|
||||
throw new Error('None type should not have additional properties');
|
||||
}
|
||||
return new MediaReference({ type: 'none' });
|
||||
|
||||
default:
|
||||
throw new Error('Invalid type');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize to JSON
|
||||
*/
|
||||
toJSON(): MediaReferenceProps {
|
||||
return { ...this.props };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of this reference
|
||||
*/
|
||||
get type(): MediaReferenceProps['type'] {
|
||||
return this.props.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get variant (only for system-default)
|
||||
*/
|
||||
get variant(): MediaVariant | undefined {
|
||||
return this.props.type === 'system-default' ? this.props.variant : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get avatar variant (only for system-default with avatar variant)
|
||||
*/
|
||||
get avatarVariant(): AvatarVariant | undefined {
|
||||
return this.props.type === 'system-default' ? this.props.avatarVariant : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get generation request ID (only for generated)
|
||||
*/
|
||||
get generationRequestId(): string | undefined {
|
||||
return this.props.type === 'generated' ? this.props.generationRequestId : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media ID (only for uploaded)
|
||||
*/
|
||||
get mediaId(): string | undefined {
|
||||
return this.props.type === 'uploaded' ? this.props.mediaId : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Equality comparison
|
||||
*/
|
||||
equals(other: IValueObject<MediaReferenceProps>): boolean {
|
||||
if (!(other instanceof MediaReference)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
|
||||
if (a.type !== b.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (a.type) {
|
||||
case 'system-default':
|
||||
return b.type === 'system-default' &&
|
||||
a.variant === b.variant &&
|
||||
a.avatarVariant === b.avatarVariant;
|
||||
case 'generated':
|
||||
return b.type === 'generated' && a.generationRequestId === b.generationRequestId;
|
||||
case 'uploaded':
|
||||
return b.type === 'uploaded' && a.mediaId === b.mediaId;
|
||||
case 'none':
|
||||
return b.type === 'none';
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate hash for this reference
|
||||
* Used for caching and comparison
|
||||
*/
|
||||
hash(): string {
|
||||
switch (this.props.type) {
|
||||
case 'system-default':
|
||||
return `system-default:${this.props.variant}${this.props.avatarVariant ? `:${this.props.avatarVariant}` : ''}`;
|
||||
case 'generated':
|
||||
return `generated:${this.props.generationRequestId}`;
|
||||
case 'uploaded':
|
||||
return `uploaded:${this.props.mediaId}`;
|
||||
case 'none':
|
||||
return 'none';
|
||||
default:
|
||||
// Exhaustive check - should never reach here
|
||||
throw new Error('Unknown type');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user