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