This repository has no description
14 kB
438 lines
1import { ICollectionPublisher } from 'src/modules/cards/application/ports/ICollectionPublisher';
2import { Collection } from 'src/modules/cards/domain/Collection';
3import { Card } from 'src/modules/cards/domain/Card';
4import { Result, ok, err } from 'src/shared/core/Result';
5import { UseCaseError } from 'src/shared/core/UseCaseError';
6import {
7 PublishedRecordId,
8 PublishedRecordIdProps,
9} from 'src/modules/cards/domain/value-objects/PublishedRecordId';
10import { CuratorId } from 'src/modules/cards/domain/value-objects/CuratorId';
11import { CollectionMapper } from '../mappers/CollectionMapper';
12import { MarginCollectionMapper } from '../mappers/MarginCollectionMapper';
13import { CollectionLinkMapper } from '../mappers/CollectionLinkMapper';
14import { CollectionLinkRemovalMapper } from '../mappers/CollectionLinkRemovalMapper';
15import { StrongRef } from '../../domain';
16import { IAgentService } from '../../application/IAgentService';
17import { DID } from '../../domain/DID';
18import { AuthenticationError } from 'src/shared/core/AuthenticationError';
19import {
20 NamespaceDetector,
21 RecordNamespace,
22} from '../../domain/NamespaceDetector';
23
24export class ATProtoCollectionPublisher implements ICollectionPublisher {
25 constructor(
26 private readonly agentService: IAgentService,
27 private readonly collectionCollection: string,
28 private readonly collectionLinkCollection: string,
29 private readonly collectionLinkRemovalCollection: string,
30 ) {}
31
32 /**
33 * Publishes a Collection record only (not the card links)
34 * Supports both Cosmik and Margin namespaces by detecting the collection from the existing URI when updating
35 */
36 async publish(
37 collection: Collection,
38 ): Promise<Result<PublishedRecordId, UseCaseError>> {
39 try {
40 const curatorDidResult = DID.create(collection.authorId.value);
41
42 if (curatorDidResult.isErr()) {
43 return err(
44 new Error(`Invalid curator DID: ${curatorDidResult.error.message}`),
45 );
46 }
47
48 const curatorDid = curatorDidResult.value;
49
50 // Get an authenticated agent for this curator
51 const agentResult =
52 await this.agentService.getAuthenticatedAgent(curatorDid);
53
54 if (agentResult.isErr()) {
55 // Propagate authentication errors as-is
56 if (agentResult.error instanceof AuthenticationError) {
57 return err(agentResult.error);
58 }
59 return err(
60 new Error(
61 `Authentication error for ATProtoCollectionPublisher: ${agentResult.error.message}`,
62 ),
63 );
64 }
65
66 const agent = agentResult.value;
67
68 if (!agent) {
69 return err(new Error('No authenticated session found for curator'));
70 }
71
72 if (collection.publishedRecordId) {
73 // Update existing collection record
74 // Detect the collection namespace from the existing published record
75 const existingCollection =
76 NamespaceDetector.extractCollectionFromPublishedRecordId(
77 collection.publishedRecordId,
78 ) || this.collectionCollection;
79
80 const namespace = NamespaceDetector.detectFromPublishedRecordId(
81 collection.publishedRecordId,
82 );
83
84 // Use the appropriate mapper based on the namespace
85 const collectionRecordDTO =
86 namespace === RecordNamespace.MARGIN
87 ? MarginCollectionMapper.toCreateRecordDTO(collection)
88 : CollectionMapper.toCreateRecordDTO(collection);
89
90 collectionRecordDTO.$type = existingCollection as any;
91
92 const publishedRecordId = collection.publishedRecordId.getValue();
93 const strongRef = new StrongRef(publishedRecordId);
94 const atUri = strongRef.atUri;
95 const rkey = atUri.rkey;
96
97 const updateResult = await agent.com.atproto.repo.putRecord({
98 repo: curatorDid.value,
99 collection: existingCollection, // Use the original namespace
100 rkey: rkey,
101 record: collectionRecordDTO,
102 });
103
104 return ok(
105 PublishedRecordId.create({
106 uri: updateResult.data.uri,
107 cid: updateResult.data.cid,
108 }),
109 );
110 } else {
111 // Create new collection record (always use Cosmik for new records)
112 const collectionRecordDTO =
113 CollectionMapper.toCreateRecordDTO(collection);
114 collectionRecordDTO.$type = this.collectionCollection as any;
115
116 const createResult = await agent.com.atproto.repo.createRecord({
117 repo: curatorDid.value,
118 collection: this.collectionCollection,
119 record: collectionRecordDTO,
120 });
121
122 return ok(
123 PublishedRecordId.create({
124 uri: createResult.data.uri,
125 cid: createResult.data.cid,
126 }),
127 );
128 }
129 } catch (error) {
130 return err(
131 new Error(error instanceof Error ? error.message : String(error)),
132 );
133 }
134 }
135
136 /**
137 * Publishes a card-in-collection link when a card is added to a collection
138 */
139 async publishCardAddedToCollection(
140 card: Card,
141 collection: Collection,
142 curatorId: CuratorId,
143 viaCardPublishedRecordId?: PublishedRecordIdProps,
144 ): Promise<Result<PublishedRecordId, UseCaseError>> {
145 try {
146 const curatorDidResult = DID.create(curatorId.value);
147
148 if (curatorDidResult.isErr()) {
149 return err(
150 new Error(`Invalid curator DID: ${curatorDidResult.error.message}`),
151 );
152 }
153
154 const curatorDid = curatorDidResult.value;
155
156 // Get an authenticated agent for this curator
157 const agentResult =
158 await this.agentService.getAuthenticatedAgent(curatorDid);
159
160 if (agentResult.isErr()) {
161 // Propagate authentication errors as-is
162 if (agentResult.error instanceof AuthenticationError) {
163 return err(agentResult.error);
164 }
165 return err(
166 new Error(
167 `Authentication error for ATProtoCollectionPublisher: ${agentResult.error.message}`,
168 ),
169 );
170 }
171
172 const agent = agentResult.value;
173
174 if (!agent) {
175 return err(new Error('No authenticated session found for curator'));
176 }
177
178 // Ensure collection is published
179 if (!collection.publishedRecordId) {
180 return err(
181 new Error('Collection must be published before adding cards'),
182 );
183 }
184
185 // Get the card's library membership for this curator
186 const libraryMembership = card.libraryMemberships.find((membership) =>
187 membership.curatorId.equals(curatorId),
188 );
189
190 if (!libraryMembership?.publishedRecordId) {
191 return err(
192 new Error(
193 "Card must be published in curator's library before adding to collection",
194 ),
195 );
196 }
197
198 // Get the original published record ID
199 if (!card.publishedRecordId) {
200 return err(new Error('Card must have a published record ID'));
201 }
202
203 // Find the card link in the collection
204 const cardLink = collection.cardLinks.find((link) =>
205 link.cardId.equals(card.cardId),
206 );
207
208 if (!cardLink) {
209 return err(new Error('Card is not linked to this collection'));
210 }
211
212 let originalCardRecordId: PublishedRecordIdProps | undefined;
213 if (
214 libraryMembership.publishedRecordId.uri !== card.publishedRecordId.uri
215 ) {
216 originalCardRecordId = card.publishedRecordId.getValue();
217 }
218
219 const linkRecordDTO = CollectionLinkMapper.toCreateRecordDTO(
220 cardLink,
221 collection.publishedRecordId.getValue(),
222 libraryMembership.publishedRecordId.getValue(),
223 originalCardRecordId,
224 viaCardPublishedRecordId,
225 );
226 linkRecordDTO.$type = this.collectionLinkCollection as any;
227
228 const createResult = await agent.com.atproto.repo.createRecord({
229 repo: curatorDid.value,
230 collection: this.collectionLinkCollection,
231 record: linkRecordDTO,
232 });
233
234 return ok(
235 PublishedRecordId.create({
236 uri: createResult.data.uri,
237 cid: createResult.data.cid,
238 }),
239 );
240 } catch (error) {
241 return err(
242 new Error(error instanceof Error ? error.message : String(error)),
243 );
244 }
245 }
246
247 /**
248 * Unpublishes (deletes) a card-in-collection link
249 * Supports both Cosmik and Margin namespaces by detecting the collection from the URI
250 */
251 async unpublishCardAddedToCollection(
252 recordId: PublishedRecordId,
253 ): Promise<Result<void, UseCaseError>> {
254 try {
255 const publishedRecordId = recordId.getValue();
256 const strongRef = new StrongRef(publishedRecordId);
257 const atUri = strongRef.atUri;
258 const curatorDid = atUri.did;
259 const repo = atUri.did.toString();
260 const rkey = atUri.rkey;
261
262 // Extract the actual collection from the URI to support both Margin and Cosmik
263 const collection =
264 NamespaceDetector.extractCollectionFromPublishedRecordId(recordId) ||
265 this.collectionLinkCollection;
266
267 // Get an authenticated agent for this curator
268 const agentResult =
269 await this.agentService.getAuthenticatedAgent(curatorDid);
270
271 if (agentResult.isErr()) {
272 // Propagate authentication errors as-is
273 if (agentResult.error instanceof AuthenticationError) {
274 return err(agentResult.error);
275 }
276 return err(
277 new Error(
278 `Authentication error for ATProtoCollectionPublisher: ${agentResult.error.message}`,
279 ),
280 );
281 }
282
283 const agent = agentResult.value;
284
285 if (!agent) {
286 return err(new Error('No authenticated session found for curator'));
287 }
288
289 await agent.com.atproto.repo.deleteRecord({
290 repo,
291 collection, // Now uses the actual collection from the URI
292 rkey,
293 });
294
295 return ok(undefined);
296 } catch (error) {
297 return err(
298 new Error(error instanceof Error ? error.message : String(error)),
299 );
300 }
301 }
302
303 /**
304 * Publishes a collectionLinkRemoval record when a collection owner removes a card added by someone else
305 */
306 async publishCollectionLinkRemoval(
307 card: Card,
308 collection: Collection,
309 curatorId: CuratorId,
310 removedLinkRef: PublishedRecordId,
311 ): Promise<Result<PublishedRecordId, UseCaseError>> {
312 try {
313 const curatorDidResult = DID.create(curatorId.value);
314
315 if (curatorDidResult.isErr()) {
316 return err(
317 new Error(`Invalid curator DID: ${curatorDidResult.error.message}`),
318 );
319 }
320
321 const curatorDid = curatorDidResult.value;
322
323 // Get an authenticated agent for this curator (collection owner)
324 const agentResult =
325 await this.agentService.getAuthenticatedAgent(curatorDid);
326
327 if (agentResult.isErr()) {
328 // Propagate authentication errors as-is
329 if (agentResult.error instanceof AuthenticationError) {
330 return err(agentResult.error);
331 }
332 return err(
333 new Error(
334 `Authentication error for ATProtoCollectionPublisher: ${agentResult.error.message}`,
335 ),
336 );
337 }
338
339 const agent = agentResult.value;
340
341 if (!agent) {
342 return err(new Error('No authenticated session found for curator'));
343 }
344
345 // Ensure collection is published
346 if (!collection.publishedRecordId) {
347 return err(
348 new Error('Collection must be published before removing cards'),
349 );
350 }
351
352 // Create the collectionLinkRemoval record DTO
353 const removalRecordDTO = CollectionLinkRemovalMapper.toCreateRecordDTO(
354 collection.publishedRecordId.getValue(),
355 removedLinkRef.getValue(),
356 );
357 removalRecordDTO.$type = this.collectionLinkRemovalCollection as any;
358
359 const createResult = await agent.com.atproto.repo.createRecord({
360 repo: curatorDid.value,
361 collection: this.collectionLinkRemovalCollection,
362 record: removalRecordDTO,
363 });
364
365 return ok(
366 PublishedRecordId.create({
367 uri: createResult.data.uri,
368 cid: createResult.data.cid,
369 }),
370 );
371 } catch (error) {
372 return err(
373 new Error(error instanceof Error ? error.message : String(error)),
374 );
375 }
376 }
377
378 /**
379 * Unpublishes (deletes) a Collection record and all its links
380 * Supports both Cosmik and Margin namespaces by detecting the collection from the URI
381 */
382 async unpublish(
383 recordId: PublishedRecordId,
384 ): Promise<Result<void, UseCaseError>> {
385 try {
386 const publishedRecordId = recordId.getValue();
387 const strongRef = new StrongRef(publishedRecordId);
388 const atUri = strongRef.atUri;
389 const curatorDid = atUri.did;
390 const repo = atUri.did.toString();
391 const rkey = atUri.rkey;
392
393 // Extract the actual collection from the URI to support both Margin and Cosmik
394 const collection =
395 NamespaceDetector.extractCollectionFromPublishedRecordId(recordId) ||
396 this.collectionCollection;
397
398 // Get an authenticated agent for this curator
399 const agentResult =
400 await this.agentService.getAuthenticatedAgent(curatorDid);
401
402 if (agentResult.isErr()) {
403 // Propagate authentication errors as-is
404 if (agentResult.error instanceof AuthenticationError) {
405 return err(agentResult.error);
406 }
407 return err(
408 new Error(
409 `Authentication error for ATProtoCollectionPublisher: ${agentResult.error.message}`,
410 ),
411 );
412 }
413
414 const agent = agentResult.value;
415
416 if (!agent) {
417 return err(new Error('No authenticated session found for curator'));
418 }
419
420 // Delete the collection record using the detected namespace
421 await agent.com.atproto.repo.deleteRecord({
422 repo,
423 collection, // Now uses the actual collection from the URI
424 rkey,
425 });
426
427 // TODO: Also delete all collection link records that reference this collection
428 // This would require querying for all links that reference this collection
429 // and deleting them individually
430
431 return ok(undefined);
432 } catch (error) {
433 return err(
434 new Error(error instanceof Error ? error.message : String(error)),
435 );
436 }
437 }
438}