This repository has no description
1import { Result, ok, err } from '../../../../shared/core/Result';
2import { Card } from '../Card';
3import { Collection } from '../Collection';
4import { CuratorId } from '../value-objects/CuratorId';
5import { CollectionId } from '../value-objects/CollectionId';
6import { CardId } from '../value-objects/CardId';
7import { ICollectionRepository } from '../ICollectionRepository';
8import { ICollectionPublisher } from '../../application/ports/ICollectionPublisher';
9import { ICardRepository } from '../ICardRepository';
10import { AppError } from '../../../../shared/core/AppError';
11import { DomainService } from '../../../../shared/domain/DomainService';
12import {
13 PublishedRecordId,
14 PublishedRecordIdProps,
15} from '../value-objects/PublishedRecordId';
16import { AuthenticationError } from '../../../../shared/core/AuthenticationError';
17
18export interface CardCollectionServiceOptions {
19 skipPublishing?: boolean;
20 publishedRecordIds?: Map<string, PublishedRecordId>; // collectionId -> publishedRecordId
21 timestamp?: Date;
22}
23
24export class CardCollectionValidationError extends Error {
25 constructor(message: string) {
26 super(message);
27 this.name = 'CardCollectionValidationError';
28 }
29}
30
31export class CardCollectionService implements DomainService {
32 constructor(
33 private collectionRepository: ICollectionRepository,
34 private collectionPublisher: ICollectionPublisher,
35 private cardRepository: ICardRepository,
36 ) {}
37
38 async addCardToCollection(
39 card: Card,
40 collectionId: CollectionId,
41 curatorId: CuratorId,
42 viaCardId?: CardId,
43 options?: CardCollectionServiceOptions,
44 ): Promise<
45 Result<
46 Collection,
47 | CardCollectionValidationError
48 | AuthenticationError
49 | AppError.UnexpectedError
50 >
51 > {
52 try {
53 // Find the collection
54 const collectionResult =
55 await this.collectionRepository.findById(collectionId);
56 if (collectionResult.isErr()) {
57 return err(AppError.UnexpectedError.create(collectionResult.error));
58 }
59
60 const collection = collectionResult.value;
61 if (!collection) {
62 return err(
63 new CardCollectionValidationError(
64 `Collection not found: ${collectionId.getStringValue()}`,
65 ),
66 );
67 }
68
69 // Add card to collection
70 const addCardResult = collection.addCard(
71 card.cardId,
72 curatorId,
73 viaCardId,
74 options?.timestamp,
75 );
76 if (addCardResult.isErr()) {
77 return err(
78 new CardCollectionValidationError(
79 `Failed to add card to collection: ${addCardResult.error.message}`,
80 ),
81 );
82 }
83
84 // Handle publishing based on options
85 if (options?.skipPublishing && options?.publishedRecordIds) {
86 const publishedRecordId = options.publishedRecordIds.get(
87 collectionId.getStringValue(),
88 );
89 if (publishedRecordId) {
90 // Skip publishing and use provided record ID
91 collection.markCardLinkAsPublished(card.cardId, publishedRecordId);
92 }
93 } else {
94 // Resolve via card published record ID if needed
95 let viaCardPublishedRecordId: PublishedRecordIdProps | undefined;
96 if (viaCardId) {
97 const viaCardResult = await this.cardRepository.findById(viaCardId);
98 if (viaCardResult.isOk() && viaCardResult.value?.publishedRecordId) {
99 viaCardPublishedRecordId =
100 viaCardResult.value.publishedRecordId.getValue();
101 }
102 }
103
104 // Publish the collection link normally
105 const publishLinkResult =
106 await this.collectionPublisher.publishCardAddedToCollection(
107 card,
108 collection,
109 curatorId,
110 viaCardPublishedRecordId,
111 );
112 if (publishLinkResult.isErr()) {
113 // Propagate authentication errors
114 if (publishLinkResult.error instanceof AuthenticationError) {
115 return err(publishLinkResult.error);
116 }
117 return err(
118 new CardCollectionValidationError(
119 `Failed to publish collection link: ${publishLinkResult.error.message}`,
120 ),
121 );
122 }
123
124 // Mark the card link as published in the collection
125 collection.markCardLinkAsPublished(
126 card.cardId,
127 publishLinkResult.value,
128 );
129 }
130
131 // Save the updated collection
132 const saveCollectionResult =
133 await this.collectionRepository.save(collection);
134 if (saveCollectionResult.isErr()) {
135 return err(AppError.UnexpectedError.create(saveCollectionResult.error));
136 }
137
138 return ok(collection);
139 } catch (error) {
140 return err(AppError.UnexpectedError.create(error));
141 }
142 }
143
144 async addCardToCollections(
145 card: Card,
146 collectionIds: CollectionId[],
147 curatorId: CuratorId,
148 viaCardId?: CardId,
149 options?: CardCollectionServiceOptions,
150 ): Promise<
151 Result<
152 Collection[],
153 | CardCollectionValidationError
154 | AuthenticationError
155 | AppError.UnexpectedError
156 >
157 > {
158 const updatedCollections: Collection[] = [];
159
160 for (const collectionId of collectionIds) {
161 const result = await this.addCardToCollection(
162 card,
163 collectionId,
164 curatorId,
165 viaCardId,
166 options,
167 );
168 if (result.isErr()) {
169 return err(result.error);
170 }
171 updatedCollections.push(result.value);
172 }
173 return ok(updatedCollections);
174 }
175
176 async removeCardFromCollection(
177 card: Card,
178 collectionId: CollectionId,
179 curatorId: CuratorId,
180 options?: CardCollectionServiceOptions,
181 ): Promise<
182 Result<
183 Collection | null,
184 | CardCollectionValidationError
185 | AuthenticationError
186 | AppError.UnexpectedError
187 >
188 > {
189 try {
190 // Find the collection
191 const collectionResult =
192 await this.collectionRepository.findById(collectionId);
193 if (collectionResult.isErr()) {
194 return err(AppError.UnexpectedError.create(collectionResult.error));
195 }
196
197 const collection = collectionResult.value;
198 if (!collection) {
199 return err(
200 new CardCollectionValidationError(
201 `Collection not found: ${collectionId.getStringValue()}`,
202 ),
203 );
204 }
205
206 // Check if card is in collection
207 const cardLink = collection.cardLinks.find((link) =>
208 link.cardId.equals(card.cardId),
209 );
210 if (!cardLink) {
211 // Card is not in collection, nothing to do
212 return ok(null);
213 }
214
215 // Check permissions FIRST before attempting any publishing operations
216 if (!collection.canRemoveCard(card.cardId, curatorId)) {
217 return err(
218 new CardCollectionValidationError(
219 'User does not have permission to remove cards from this collection',
220 ),
221 );
222 }
223
224 // Handle unpublishing/removal based on options
225 if (!options?.skipPublishing && cardLink.publishedRecordId) {
226 // Determine if this is a user removing their own card or collection author removing someone else's card
227 const isUserRemovingOwnCard = cardLink.addedBy.equals(curatorId);
228 const isCollectionAuthor = collection.authorId.equals(curatorId);
229
230 if (isUserRemovingOwnCard) {
231 // User is removing their own card - unpublish the CollectionLink (delete from their repo)
232 const unpublishLinkResult =
233 await this.collectionPublisher.unpublishCardAddedToCollection(
234 cardLink.publishedRecordId,
235 );
236 if (unpublishLinkResult.isErr()) {
237 // Propagate authentication errors
238 if (unpublishLinkResult.error instanceof AuthenticationError) {
239 return err(unpublishLinkResult.error);
240 }
241 return err(
242 new CardCollectionValidationError(
243 `Failed to unpublish collection link: ${unpublishLinkResult.error.message}`,
244 ),
245 );
246 }
247 } else if (isCollectionAuthor) {
248 // Collection author is removing someone else's card - publish a CollectionLinkRemoval record
249 // This is the ONLY case where removal records are published
250 const publishRemovalResult =
251 await this.collectionPublisher.publishCollectionLinkRemoval(
252 card,
253 collection,
254 curatorId,
255 cardLink.publishedRecordId,
256 );
257 if (publishRemovalResult.isErr()) {
258 // Propagate authentication errors
259 if (publishRemovalResult.error instanceof AuthenticationError) {
260 return err(publishRemovalResult.error);
261 }
262 return err(
263 new CardCollectionValidationError(
264 `Failed to publish collection link removal: ${publishRemovalResult.error.message}`,
265 ),
266 );
267 }
268 } else {
269 // This should never happen because permissions are checked above
270 // If someone is neither the card adder nor the collection author, they shouldn't have permission
271 return err(
272 new CardCollectionValidationError(
273 'User does not have permission to remove this card from the collection',
274 ),
275 );
276 }
277 }
278
279 // Remove card from collection
280 const removeCardResult = collection.removeCard(card.cardId, curatorId);
281 if (removeCardResult.isErr()) {
282 return err(
283 new CardCollectionValidationError(
284 `Failed to remove card from collection: ${removeCardResult.error.message}`,
285 ),
286 );
287 }
288
289 // Save the updated collection
290 const saveCollectionResult =
291 await this.collectionRepository.save(collection);
292 if (saveCollectionResult.isErr()) {
293 return err(AppError.UnexpectedError.create(saveCollectionResult.error));
294 }
295
296 return ok(collection);
297 } catch (error) {
298 return err(AppError.UnexpectedError.create(error));
299 }
300 }
301
302 async removeCardFromCollections(
303 card: Card,
304 collectionIds: CollectionId[],
305 curatorId: CuratorId,
306 options?: CardCollectionServiceOptions,
307 ): Promise<
308 Result<
309 Collection[],
310 | CardCollectionValidationError
311 | AuthenticationError
312 | AppError.UnexpectedError
313 >
314 > {
315 const updatedCollections: Collection[] = [];
316
317 for (const collectionId of collectionIds) {
318 const result = await this.removeCardFromCollection(
319 card,
320 collectionId,
321 curatorId,
322 options,
323 );
324 if (result.isErr()) {
325 return err(result.error);
326 }
327 if (result.value !== null) {
328 updatedCollections.push(result.value);
329 }
330 }
331 return ok(updatedCollections);
332 }
333}