This repository has no description
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}