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';
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}