This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

semble / src / modules / cards / domain / Card.ts
10 kB 354 lines
1import { AggregateRoot } from '../../../shared/domain/AggregateRoot'; 2import { UniqueEntityID } from '../../../shared/domain/UniqueEntityID'; 3import { ok, err, Result } from '../../../shared/core/Result'; 4import { CardId } from './value-objects/CardId'; 5import { CardType, CardTypeEnum } from './value-objects/CardType'; 6import { CardContent } from './value-objects/CardContent'; 7import { CuratorId } from './value-objects/CuratorId'; 8import { PublishedRecordId } from './value-objects/PublishedRecordId'; 9import { URL } from './value-objects/URL'; 10import { CardAddedToLibraryEvent } from './events/CardAddedToLibraryEvent'; 11import { CardRemovedFromLibraryEvent } from './events/CardRemovedFromLibraryEvent'; 12 13export const CARD_ERROR_MESSAGES = { 14 CARD_TYPE_CONTENT_MISMATCH: 'Card type must match content type', 15 URL_CARD_CANNOT_HAVE_PARENT: 'URL cards cannot have parent cards', 16 URL_CARD_MUST_HAVE_URL: 'URL cards must have a url property', 17 URL_CARD_SINGLE_LIBRARY_ONLY: 'URL cards can only be in one library', 18 URL_CARD_CREATOR_LIBRARY_ONLY: 19 'URL cards can only be in the library of the creator', 20 LIBRARY_COUNT_MISMATCH: 21 'Library count does not match library memberships length', 22 CANNOT_CHANGE_CONTENT_TYPE: 'Cannot change card content to different type', 23 ALREADY_IN_LIBRARY: "Card is already in user's library", 24 NOT_IN_LIBRARY: "Card is not in user's library", 25} as const; 26 27export class CardValidationError extends Error { 28 constructor(message: string) { 29 super(message); 30 this.name = 'CardValidationError'; 31 } 32} 33 34export interface CardInLibraryLink { 35 curatorId: CuratorId; 36 addedAt: Date; 37 publishedRecordId?: PublishedRecordId; // AT URI of the link record 38} 39 40interface CardProps { 41 curatorId: CuratorId; 42 type: CardType; 43 content: CardContent; 44 url?: URL; 45 parentCardId?: CardId; // For NOTE and HIGHLIGHT cards that reference other cards 46 viaCardId?: CardId; // For cards that were discovered via another card 47 libraryMemberships: CardInLibraryLink[]; // Set of users who have this card in their library 48 libraryCount: number; // Cached count of library memberships 49 publishedRecordId?: PublishedRecordId; // The first published record ID for this card 50 createdAt: Date; 51 updatedAt: Date; 52} 53 54export class Card extends AggregateRoot<CardProps> { 55 get cardId(): CardId { 56 return CardId.create(this._id).unwrap(); 57 } 58 59 get curatorId(): CuratorId { 60 return this.props.curatorId; 61 } 62 63 get type(): CardType { 64 return this.props.type; 65 } 66 67 get content(): CardContent { 68 return this.props.content; 69 } 70 71 get url(): URL | undefined { 72 return this.props.url; 73 } 74 75 get parentCardId(): CardId | undefined { 76 return this.props.parentCardId; 77 } 78 79 get viaCardId(): CardId | undefined { 80 return this.props.viaCardId; 81 } 82 83 get createdAt(): Date { 84 return this.props.createdAt; 85 } 86 87 get updatedAt(): Date { 88 return this.props.updatedAt; 89 } 90 91 get libraryMemberships(): CardInLibraryLink[] { 92 return Array.from(this.props.libraryMemberships); 93 } 94 95 get libraryMembershipCount(): number { 96 return this.props.libraryMemberships.length; 97 } 98 99 get libraryCount(): number { 100 return this.props.libraryCount; 101 } 102 103 get publishedRecordId(): PublishedRecordId | undefined { 104 return this.props.publishedRecordId; 105 } 106 107 // Type-specific convenience getters 108 get isUrlCard(): boolean { 109 return this.props.type.value === CardTypeEnum.URL; 110 } 111 112 get isNoteCard(): boolean { 113 return this.props.type.value === CardTypeEnum.NOTE; 114 } 115 116 private constructor(props: CardProps, id?: UniqueEntityID) { 117 super(props, id); 118 } 119 120 public static create( 121 props: Omit< 122 CardProps, 123 'createdAt' | 'updatedAt' | 'libraryMemberships' | 'libraryCount' 124 > & { 125 libraryMemberships?: CardInLibraryLink[]; 126 libraryCount?: number; 127 createdAt?: Date; 128 updatedAt?: Date; 129 }, 130 id?: UniqueEntityID, 131 ): Result<Card, CardValidationError> { 132 // Validate content type matches card type 133 if (props.type.value !== props.content.type) { 134 return err( 135 new CardValidationError(CARD_ERROR_MESSAGES.CARD_TYPE_CONTENT_MISMATCH), 136 ); 137 } 138 139 // Validate parent/source card relationships 140 const validationResult = Card.validateCardRelationships(props); 141 if (validationResult.isErr()) { 142 return err(validationResult.error); 143 } 144 145 const now = new Date(); 146 const libraryMemberships = props.libraryMemberships || []; 147 const cardProps: CardProps = { 148 ...props, 149 libraryMemberships, 150 libraryCount: props.libraryCount ?? libraryMemberships.length, 151 createdAt: props.createdAt || now, 152 updatedAt: props.updatedAt || now, 153 }; 154 155 const card = new Card(cardProps, id); 156 return ok(card); 157 } 158 159 private static validateCardRelationships( 160 props: Omit< 161 CardProps, 162 'createdAt' | 'updatedAt' | 'libraryMemberships' | 'libraryCount' 163 > & { 164 libraryMemberships?: CardInLibraryLink[]; 165 libraryCount?: number; 166 }, 167 ): Result<void, CardValidationError> { 168 // URL cards should not have parent cards 169 if (props.type.value === CardTypeEnum.URL && props.parentCardId) { 170 return err( 171 new CardValidationError( 172 CARD_ERROR_MESSAGES.URL_CARD_CANNOT_HAVE_PARENT, 173 ), 174 ); 175 } 176 177 // URL cards must have a URL property 178 if (props.type.value === CardTypeEnum.URL && !props.url) { 179 return err( 180 new CardValidationError(CARD_ERROR_MESSAGES.URL_CARD_MUST_HAVE_URL), 181 ); 182 } 183 184 // URL cards can only be in one library 185 const libraryMemberships = props.libraryMemberships || []; 186 if ( 187 props.type.value === CardTypeEnum.URL && 188 libraryMemberships.length > 1 189 ) { 190 return err( 191 new CardValidationError( 192 CARD_ERROR_MESSAGES.URL_CARD_SINGLE_LIBRARY_ONLY, 193 ), 194 ); 195 } 196 197 // URL cards can only have library memberships for the creator 198 if ( 199 props.type.value === CardTypeEnum.URL && 200 libraryMemberships.length > 0 201 ) { 202 const hasNonCreatorMembership = libraryMemberships.some( 203 (membership) => !membership.curatorId.equals(props.curatorId), 204 ); 205 if (hasNonCreatorMembership) { 206 return err( 207 new CardValidationError( 208 CARD_ERROR_MESSAGES.URL_CARD_CREATOR_LIBRARY_ONLY, 209 ), 210 ); 211 } 212 } 213 214 // Validate libraryCount matches libraryMemberships length when both are provided 215 if ( 216 props.libraryCount !== undefined && 217 props.libraryCount !== libraryMemberships.length 218 ) { 219 return err( 220 new CardValidationError( 221 `${CARD_ERROR_MESSAGES.LIBRARY_COUNT_MISMATCH} (${props.libraryCount} vs ${libraryMemberships.length})`, 222 ), 223 ); 224 } 225 226 return ok(undefined); 227 } 228 229 public updateContent( 230 newContent: CardContent, 231 ): Result<void, CardValidationError> { 232 if (this.props.type.value !== newContent.type) { 233 return err( 234 new CardValidationError(CARD_ERROR_MESSAGES.CANNOT_CHANGE_CONTENT_TYPE), 235 ); 236 } 237 238 this.props.content = newContent; 239 this.props.updatedAt = new Date(); 240 241 return ok(undefined); 242 } 243 244 public addToLibrary( 245 userId: CuratorId, 246 addedAt?: Date, 247 ): Result<void, CardValidationError> { 248 if ( 249 this.props.libraryMemberships.find((link) => 250 link.curatorId.equals(userId), 251 ) 252 ) { 253 return err( 254 new CardValidationError(CARD_ERROR_MESSAGES.ALREADY_IN_LIBRARY), 255 ); 256 } 257 258 // URL cards can only be in one library 259 if (this.isUrlCard && this.props.libraryMemberships.length > 0) { 260 return err( 261 new CardValidationError( 262 CARD_ERROR_MESSAGES.URL_CARD_SINGLE_LIBRARY_ONLY, 263 ), 264 ); 265 } 266 267 const membershipAddedAt = addedAt ?? new Date(); 268 this.props.libraryMemberships.push({ 269 curatorId: userId, 270 addedAt: membershipAddedAt, 271 }); 272 this.props.libraryCount = this.props.libraryMemberships.length; 273 this.props.updatedAt = addedAt ?? new Date(); 274 275 // Raise domain event 276 const domainEvent = CardAddedToLibraryEvent.create( 277 this.cardId, 278 userId, 279 membershipAddedAt, 280 ); 281 if (domainEvent.isErr()) { 282 return err(new CardValidationError(domainEvent.error.message)); 283 } 284 this.addDomainEvent(domainEvent.value); 285 286 return ok(undefined); 287 } 288 289 public removeFromLibrary( 290 userId: CuratorId, 291 ): Result<void, CardValidationError> { 292 if ( 293 !this.props.libraryMemberships.find((link) => 294 link.curatorId.equals(userId), 295 ) 296 ) { 297 return err(new CardValidationError(CARD_ERROR_MESSAGES.NOT_IN_LIBRARY)); 298 } 299 300 this.props.libraryMemberships = this.props.libraryMemberships.filter( 301 (link) => !link.curatorId.equals(userId), 302 ); 303 this.props.libraryCount = this.props.libraryMemberships.length; 304 this.props.updatedAt = new Date(); 305 306 // Raise domain event 307 const domainEvent = CardRemovedFromLibraryEvent.create(this.cardId, userId); 308 if (domainEvent.isErr()) { 309 return err(new CardValidationError(domainEvent.error.message)); 310 } 311 this.addDomainEvent(domainEvent.value); 312 313 return ok(undefined); 314 } 315 316 public isInLibrary(userId: CuratorId): boolean { 317 return ( 318 this.props.libraryMemberships.find((link) => 319 link.curatorId.equals(userId), 320 ) !== undefined 321 ); 322 } 323 324 public markAsPublished(publishedRecordId: PublishedRecordId): void { 325 this.props.publishedRecordId = publishedRecordId; 326 this.markCardInLibraryAsPublished(this.props.curatorId, publishedRecordId); 327 } 328 329 public markCardInLibraryAsPublished( 330 userId: CuratorId, 331 publishedRecordId: PublishedRecordId, 332 ): Result<void, CardValidationError> { 333 const membership = this.props.libraryMemberships.find((link) => 334 link.curatorId.equals(userId), 335 ); 336 if (!membership) { 337 return err(new CardValidationError(CARD_ERROR_MESSAGES.NOT_IN_LIBRARY)); 338 } 339 340 membership.publishedRecordId = publishedRecordId; 341 342 if (!this.props.publishedRecordId) { 343 this.props.publishedRecordId = publishedRecordId; 344 } 345 346 return ok(undefined); 347 } 348 349 public getLibraryInfo(userId: CuratorId): CardInLibraryLink | undefined { 350 return this.props.libraryMemberships.find((link) => 351 link.curatorId.equals(userId), 352 ); 353 } 354}