diff --git a/docs/card-subdomain.md b/docs/card-subdomain.md index 753a3f92..fbb16b60 100644 --- a/docs/card-subdomain.md +++ b/docs/card-subdomain.md @@ -50,6 +50,12 @@ The Card context represents a system where users can save various types of conte 5. **CuratorId** - References the user who owns/created the card or collection +6. **URL** + - Value object representing a valid URL + +7. **UrlMetadata** + - Value object containing metadata about a URL (title, description, author, etc.) + ## Domain Services 1. **LibraryService** @@ -57,6 +63,16 @@ The Card context represents a system where users can save various types of conte - Handles adding cards to a user's library and collections - Provides access to a user's entire content (their "library") +## Infrastructure Services + +1. **IMetadataService** + - Interface for external metadata retrieval services + - Abstracts the details of specific metadata providers (Citoid, etc.) + +2. **IUrlMetadataRepository** + - Repository interface for storing and retrieving URL metadata + - Provides caching layer for metadata to avoid repeated API calls + ## Relationships and Boundaries - **Card-Collection Relationship**: This is a many-to-many relationship. A card can be in multiple collections, and a collection can contain multiple cards. @@ -83,6 +99,12 @@ The Card context represents a system where users can save various types of conte 3. **AnnotateCard** - Creates annotation cards (notes, highlights) linked to an existing card +4. **GetUrlMetadataUseCase** + - Retrieves metadata for a given URL + - First checks if metadata already exists in the repository + - If not found, fetches from external metadata service (e.g., Citoid API) + - Stores retrieved metadata for future use + ## Implementation Considerations - When a card is added to multiple collections, this should be handled as separate operations on each Collection aggregate. @@ -90,3 +112,11 @@ The Card context represents a system where users can save various types of conte - Card-to-card relationships (like highlights of a URL) are maintained within the Card aggregate. - "Library" is a conceptual grouping of a user's content rather than an aggregate with its own identity and lifecycle. - A user's library is accessed through repository methods (e.g., `findByCuratorId()`) and coordinated through the LibraryService. + +### External API Integration Patterns + +- **Repository Pattern**: Use `IUrlMetadataRepository` to abstract metadata storage and provide caching +- **Service Interface**: Use `IMetadataService` to abstract external API calls (Citoid, etc.) +- **Dependency Inversion**: Domain layer depends on interfaces, infrastructure layer implements them +- **Caching Strategy**: Check repository first before making external API calls to reduce latency and API usage +- **Error Handling**: External API failures should be handled gracefully with fallback strategies diff --git a/src/modules/cards/application/dtos/GetUrlMetadataDTO.ts b/src/modules/cards/application/dtos/GetUrlMetadataDTO.ts new file mode 100644 index 00000000..553d89e7 --- /dev/null +++ b/src/modules/cards/application/dtos/GetUrlMetadataDTO.ts @@ -0,0 +1,11 @@ +export interface GetUrlMetadataDTO { + url: string; + title?: string; + description?: string; + author?: string; + publishedDate?: string; // ISO string + siteName?: string; + imageUrl?: string; + type?: string; + retrievedAt: string; // ISO string +} diff --git a/src/modules/cards/application/useCases/GetUrlMetadataUseCase.ts b/src/modules/cards/application/useCases/GetUrlMetadataUseCase.ts new file mode 100644 index 00000000..c8ae7261 --- /dev/null +++ b/src/modules/cards/application/useCases/GetUrlMetadataUseCase.ts @@ -0,0 +1,68 @@ +import { UseCase } from '../../../../shared/core/UseCase'; +import { Result } from '../../../../shared/core/Result'; +import { AppError } from '../../../../shared/core/AppError'; +import { URL } from '../../domain/value-objects/URL'; +import { UrlMetadata } from '../../domain/value-objects/UrlMetadata'; +import { IUrlMetadataRepository } from '../../domain/repositories/IUrlMetadataRepository'; +import { IMetadataService } from '../../domain/services/IMetadataService'; + +export interface GetUrlMetadataRequest { + url: string; + forceRefresh?: boolean; + maxAgeHours?: number; +} + +export type GetUrlMetadataResponse = Result; + +export class GetUrlMetadataUseCase implements UseCase { + constructor( + private urlMetadataRepository: IUrlMetadataRepository, + private metadataService: IMetadataService + ) {} + + public async execute(request: GetUrlMetadataRequest): Promise { + try { + // Validate URL + const urlResult = URL.create(request.url); + if (urlResult.isFailure) { + return Result.fail(urlResult.getErrorValue()); + } + + const url = urlResult.getValue(); + const maxAgeHours = request.maxAgeHours ?? 24; + + // Check if we should use cached metadata + if (!request.forceRefresh) { + const existingMetadata = await this.urlMetadataRepository.findByUrl(url); + if (existingMetadata && !existingMetadata.isStale(maxAgeHours)) { + return Result.ok(existingMetadata); + } + } + + // Fetch fresh metadata from external service + const metadataResult = await this.metadataService.fetchMetadata(url); + if (metadataResult.isFailure) { + // If we have stale metadata, return it as fallback + const existingMetadata = await this.urlMetadataRepository.findByUrl(url); + if (existingMetadata) { + return Result.ok(existingMetadata); + } + return Result.fail(metadataResult.getErrorValue()); + } + + const metadata = metadataResult.getValue(); + + // Save the fresh metadata + const saveResult = await this.urlMetadataRepository.save(metadata); + if (saveResult.isFailure) { + // Log the error but still return the metadata + console.warn('Failed to save metadata:', saveResult.getErrorValue()); + } + + return Result.ok(metadata); + + } catch (error) { + return Result.fail(AppError.UnexpectedError.create(error).message); + } + } +} diff --git a/src/modules/cards/domain/repositories/IUrlMetadataRepository.ts b/src/modules/cards/domain/repositories/IUrlMetadataRepository.ts new file mode 100644 index 00000000..42bb514d --- /dev/null +++ b/src/modules/cards/domain/repositories/IUrlMetadataRepository.ts @@ -0,0 +1,25 @@ +import { UrlMetadata } from '../value-objects/UrlMetadata'; +import { URL } from '../value-objects/URL'; +import { Result } from '../../../../shared/core/Result'; + +export interface IUrlMetadataRepository { + /** + * Find metadata for a specific URL + */ + findByUrl(url: URL): Promise; + + /** + * Save metadata for a URL + */ + save(metadata: UrlMetadata): Promise>; + + /** + * Check if metadata exists for a URL + */ + exists(url: URL): Promise; + + /** + * Remove stale metadata entries + */ + removeStale(maxAgeHours: number): Promise>; +} diff --git a/src/modules/cards/domain/services/IMetadataService.ts b/src/modules/cards/domain/services/IMetadataService.ts new file mode 100644 index 00000000..8fa17b8b --- /dev/null +++ b/src/modules/cards/domain/services/IMetadataService.ts @@ -0,0 +1,15 @@ +import { UrlMetadata } from '../value-objects/UrlMetadata'; +import { URL } from '../value-objects/URL'; +import { Result } from '../../../../shared/core/Result'; + +export interface IMetadataService { + /** + * Fetch metadata for a URL from external service + */ + fetchMetadata(url: URL): Promise>; + + /** + * Check if the service is available + */ + isAvailable(): Promise; +} diff --git a/src/modules/cards/domain/value-objects/URL.ts b/src/modules/cards/domain/value-objects/URL.ts new file mode 100644 index 00000000..f188c6f9 --- /dev/null +++ b/src/modules/cards/domain/value-objects/URL.ts @@ -0,0 +1,34 @@ +import { ValueObject } from '../../../../shared/domain/ValueObject'; +import { Result } from '../../../../shared/core/Result'; + +interface URLProps { + value: string; +} + +export class URL extends ValueObject { + get value(): string { + return this.props.value; + } + + private constructor(props: URLProps) { + super(props); + } + + public static create(url: string): Result { + if (!url || url.trim().length === 0) { + return Result.fail('URL cannot be empty'); + } + + try { + // Validate URL format + new globalThis.URL(url); + return Result.ok(new URL({ value: url.trim() })); + } catch (error) { + return Result.fail('Invalid URL format'); + } + } + + public toString(): string { + return this.value; + } +} diff --git a/src/modules/cards/domain/value-objects/UrlMetadata.ts b/src/modules/cards/domain/value-objects/UrlMetadata.ts new file mode 100644 index 00000000..0aeec4f6 --- /dev/null +++ b/src/modules/cards/domain/value-objects/UrlMetadata.ts @@ -0,0 +1,72 @@ +import { ValueObject } from '../../../../shared/domain/ValueObject'; +import { Result } from '../../../../shared/core/Result'; + +interface UrlMetadataProps { + url: string; + title?: string; + description?: string; + author?: string; + publishedDate?: Date; + siteName?: string; + imageUrl?: string; + type?: string; + retrievedAt: Date; +} + +export class UrlMetadata extends ValueObject { + get url(): string { + return this.props.url; + } + + get title(): string | undefined { + return this.props.title; + } + + get description(): string | undefined { + return this.props.description; + } + + get author(): string | undefined { + return this.props.author; + } + + get publishedDate(): Date | undefined { + return this.props.publishedDate; + } + + get siteName(): string | undefined { + return this.props.siteName; + } + + get imageUrl(): string | undefined { + return this.props.imageUrl; + } + + get type(): string | undefined { + return this.props.type; + } + + get retrievedAt(): Date { + return this.props.retrievedAt; + } + + private constructor(props: UrlMetadataProps) { + super(props); + } + + public static create(props: Omit): Result { + if (!props.url || props.url.trim().length === 0) { + return Result.fail('URL is required for metadata'); + } + + return Result.ok(new UrlMetadata({ + ...props, + retrievedAt: new Date() + })); + } + + public isStale(maxAgeHours: number = 24): boolean { + const ageInHours = (Date.now() - this.retrievedAt.getTime()) / (1000 * 60 * 60); + return ageInHours > maxAgeHours; + } +}