A social knowledge tool for researchers built on ATProto
1import { AggregateRoot } from '../../../shared/domain/AggregateRoot';
2import { UniqueEntityID } from '../../../shared/domain/UniqueEntityID';
3import { ok, err, Result } from '../../../shared/core/Result';
4import { CollectionId } from './value-objects/CollectionId';
5import { CardId } from './value-objects/CardId';
6import { CuratorId } from './value-objects/CuratorId';
7import { CollectionName } from './value-objects/CollectionName';
8import { CollectionDescription } from './value-objects/CollectionDescription';
9import { PublishedRecordId } from './value-objects/PublishedRecordId';
10import { CardAddedToCollectionEvent } from './events/CardAddedToCollectionEvent';
11import { CollectionCreatedEvent } from './events/CollectionCreatedEvent';
12
13export interface CardLink {
14 cardId: CardId;
15 addedBy: CuratorId;
16 addedAt: Date;
17 publishedRecordId?: PublishedRecordId; // AT URI of the link record
18}
19
20export enum CollectionAccessType {
21 OPEN = 'OPEN',
22 CLOSED = 'CLOSED',
23}
24
25export class CollectionAccessError extends Error {
26 constructor(message: string) {
27 super(message);
28 this.name = 'CollectionAccessError';
29 }
30}
31
32export class CollectionValidationError extends Error {
33 constructor(message: string) {
34 super(message);
35 this.name = 'CollectionValidationError';
36 }
37}
38
39interface CollectionProps {
40 authorId: CuratorId;
41 name: CollectionName;
42 description?: CollectionDescription;
43 accessType: CollectionAccessType;
44 collaboratorIds: CuratorId[];
45 cardLinks: CardLink[]; // Instead of cardIds: CardId[]
46 cardCount: number;
47 publishedRecordId?: PublishedRecordId; // Collection's own published record
48 createdAt: Date;
49 updatedAt: Date;
50}
51
52export class Collection extends AggregateRoot<CollectionProps> {
53 get collectionId(): CollectionId {
54 return CollectionId.create(this._id).unwrap();
55 }
56
57 get authorId(): CuratorId {
58 return this.props.authorId;
59 }
60
61 get name(): CollectionName {
62 return this.props.name;
63 }
64
65 get description(): CollectionDescription | undefined {
66 return this.props.description;
67 }
68
69 get accessType(): CollectionAccessType {
70 return this.props.accessType;
71 }
72
73 get collaboratorIds(): CuratorId[] {
74 return [...this.props.collaboratorIds];
75 }
76
77 get cardIds(): CardId[] {
78 return this.props.cardLinks.map((link) => link.cardId);
79 }
80
81 get cardLinks(): CardLink[] {
82 return [...this.props.cardLinks];
83 }
84
85 get cardCount(): number {
86 return this.props.cardCount;
87 }
88
89 get unpublishedCardLinks(): CardLink[] {
90 return this.props.cardLinks.filter((link) => !link.publishedRecordId);
91 }
92
93 get hasUnpublishedLinks(): boolean {
94 return this.unpublishedCardLinks.length > 0;
95 }
96
97 get createdAt(): Date {
98 return this.props.createdAt;
99 }
100
101 get updatedAt(): Date {
102 return this.props.updatedAt;
103 }
104
105 get isOpen(): boolean {
106 return this.props.accessType === CollectionAccessType.OPEN;
107 }
108
109 get isClosed(): boolean {
110 return this.props.accessType === CollectionAccessType.CLOSED;
111 }
112
113 get publishedRecordId(): PublishedRecordId | undefined {
114 return this.props.publishedRecordId;
115 }
116
117 get isPublished(): boolean {
118 return this.props.publishedRecordId !== undefined;
119 }
120
121 private constructor(props: CollectionProps, id?: UniqueEntityID) {
122 super(props, id);
123 }
124
125 public static create(
126 props: Omit<
127 CollectionProps,
128 'name' | 'description' | 'cardLinks' | 'cardCount'
129 > & {
130 name: string;
131 description?: string;
132 cardLinks?: CardLink[];
133 cardCount?: number;
134 },
135 id?: UniqueEntityID,
136 ): Result<Collection, CollectionValidationError> {
137 // Validate and create CollectionName
138 const nameResult = CollectionName.create(props.name);
139 if (nameResult.isErr()) {
140 return err(new CollectionValidationError(nameResult.error.message));
141 }
142
143 // Validate and create CollectionDescription if provided
144 let description: CollectionDescription | undefined;
145 if (props.description) {
146 const descriptionResult = CollectionDescription.create(props.description);
147 if (descriptionResult.isErr()) {
148 return err(
149 new CollectionValidationError(descriptionResult.error.message),
150 );
151 }
152 description = descriptionResult.value;
153 }
154
155 // Validate access type
156 if (!Object.values(CollectionAccessType).includes(props.accessType)) {
157 return err(new CollectionValidationError('Invalid access type'));
158 }
159
160 const collectionProps: CollectionProps = {
161 ...props,
162 name: nameResult.value,
163 description,
164 cardLinks: props.cardLinks || [],
165 cardCount: props.cardCount ?? (props.cardLinks || []).length,
166 };
167
168 const collection = new Collection(collectionProps, id);
169
170 // Raise domain event for new collections (when no id is provided)
171 if (!id) {
172 collection.addDomainEvent(
173 CollectionCreatedEvent.create(
174 collection.collectionId,
175 collection.authorId,
176 collection.name.value,
177 ).unwrap(),
178 );
179 }
180
181 return ok(collection);
182 }
183
184 public canAddCard(userId: CuratorId): boolean {
185 // Author can always add cards
186 if (this.props.authorId.equals(userId)) {
187 return true;
188 }
189
190 // If collection is open, anyone can add cards
191 if (this.isOpen) {
192 return true;
193 }
194
195 // If collection is closed, only collaborators can add cards
196 return this.props.collaboratorIds.some((collaboratorId) =>
197 collaboratorId.equals(userId),
198 );
199 }
200
201 public addCard(
202 cardId: CardId,
203 userId: CuratorId,
204 ): Result<CardLink, CollectionAccessError> {
205 if (!this.canAddCard(userId)) {
206 return err(
207 new CollectionAccessError(
208 'User does not have permission to add cards to this collection',
209 ),
210 );
211 }
212
213 // Check if card is already in collection
214 const existingLink = this.props.cardLinks.find((link) =>
215 link.cardId.equals(cardId),
216 );
217 if (existingLink) {
218 return ok(existingLink); // Return existing link
219 }
220
221 const newLink: CardLink = {
222 cardId,
223 addedBy: userId,
224 addedAt: new Date(),
225 publishedRecordId: undefined, // Will be set when published
226 };
227
228 this.props.cardLinks.push(newLink);
229 this.props.cardCount = this.props.cardLinks.length;
230 this.props.updatedAt = new Date();
231
232 // Raise domain event
233 this.addDomainEvent(
234 CardAddedToCollectionEvent.create(
235 cardId,
236 this.collectionId,
237 userId,
238 ).unwrap(),
239 );
240
241 return ok(newLink);
242 }
243
244 public markCardLinkAsPublished(
245 cardId: CardId,
246 publishedRecordId: PublishedRecordId,
247 ): void {
248 const link = this.props.cardLinks.find((link) =>
249 link.cardId.equals(cardId),
250 );
251 if (link) {
252 link.publishedRecordId = publishedRecordId;
253 this.props.updatedAt = new Date();
254 }
255 }
256
257 public removeCard(
258 cardId: CardId,
259 userId: CuratorId,
260 ): Result<void, CollectionAccessError> {
261 if (!this.canAddCard(userId)) {
262 return err(
263 new CollectionAccessError(
264 'User does not have permission to remove cards from this collection',
265 ),
266 );
267 }
268
269 this.props.cardLinks = this.props.cardLinks.filter(
270 (link) => !link.cardId.equals(cardId),
271 );
272 this.props.cardCount = this.props.cardLinks.length;
273 this.props.updatedAt = new Date();
274
275 return ok(undefined);
276 }
277
278 public addCollaborator(
279 collaboratorId: CuratorId,
280 userId: CuratorId,
281 ): Result<void, CollectionAccessError> {
282 if (!this.props.authorId.equals(userId)) {
283 return err(
284 new CollectionAccessError('Only the author can add collaborators'),
285 );
286 }
287
288 if (this.props.collaboratorIds.some((id) => id.equals(collaboratorId))) {
289 return ok(undefined); // Already a collaborator
290 }
291
292 this.props.collaboratorIds.push(collaboratorId);
293 this.props.updatedAt = new Date();
294
295 return ok(undefined);
296 }
297
298 public removeCollaborator(
299 collaboratorId: CuratorId,
300 userId: CuratorId,
301 ): Result<void, CollectionAccessError> {
302 if (!this.props.authorId.equals(userId)) {
303 return err(
304 new CollectionAccessError('Only the author can remove collaborators'),
305 );
306 }
307
308 this.props.collaboratorIds = this.props.collaboratorIds.filter(
309 (id) => !id.equals(collaboratorId),
310 );
311 this.props.updatedAt = new Date();
312
313 return ok(undefined);
314 }
315
316 public changeAccessType(
317 accessType: CollectionAccessType,
318 userId: CuratorId,
319 ): Result<void, CollectionAccessError> {
320 if (!this.props.authorId.equals(userId)) {
321 return err(
322 new CollectionAccessError(
323 'Only the author can change collection access type',
324 ),
325 );
326 }
327
328 this.props.accessType = accessType;
329 this.props.updatedAt = new Date();
330
331 return ok(undefined);
332 }
333
334 public markAsPublished(publishedRecordId: PublishedRecordId): void {
335 this.props.publishedRecordId = publishedRecordId;
336 this.props.updatedAt = new Date();
337 }
338
339 public markAsUnpublished(): void {
340 this.props.publishedRecordId = undefined;
341 this.props.updatedAt = new Date();
342 }
343
344 public updateDetails(
345 name: string,
346 description?: string,
347 ): Result<void, CollectionValidationError> {
348 // Validate and create CollectionName
349 const nameResult = CollectionName.create(name);
350 if (nameResult.isErr()) {
351 return err(new CollectionValidationError(nameResult.error.message));
352 }
353
354 // Validate and create CollectionDescription if provided
355 let newDescription: CollectionDescription | undefined;
356 if (description) {
357 const descriptionResult = CollectionDescription.create(description);
358 if (descriptionResult.isErr()) {
359 return err(
360 new CollectionValidationError(descriptionResult.error.message),
361 );
362 }
363 newDescription = descriptionResult.value;
364 }
365
366 // Update properties
367 this.props.name = nameResult.value;
368 this.props.description = newDescription;
369 this.props.updatedAt = new Date();
370
371 return ok(undefined);
372 }
373
374 public getUnpublishedCardLinks(): CardLink[] {
375 return this.props.cardLinks.filter((link) => !link.publishedRecordId);
376 }
377}