This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

semble / src / modules / atproto / infrastructure / publishers / ATProtoCollectionPublisher.ts
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}