diff --git a/src/bluesky/atproto-resolve.controller.spec.ts b/src/bluesky/atproto-resolve.controller.spec.ts new file mode 100644 index 00000000..9ce048c8 --- /dev/null +++ b/src/bluesky/atproto-resolve.controller.spec.ts @@ -0,0 +1,181 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AtprotoResolveController } from './atproto-resolve.controller'; +import { EventQueryService } from '../event/services/event-query.service'; +import { EventEntity } from '../event/infrastructure/persistence/relational/entities/event.entity'; + +describe('AtprotoResolveController', () => { + let controller: AtprotoResolveController; + let eventQueryService: { + findByAtprotoUri: jest.Mock; + findBySourceAttributes: jest.Mock; + }; + let configService: { get: jest.Mock }; + + const mockRequest = { tenantId: 'test-tenant' }; + + beforeEach(async () => { + eventQueryService = { + findByAtprotoUri: jest.fn().mockResolvedValue([]), + findBySourceAttributes: jest.fn().mockResolvedValue([]), + }; + + configService = { + get: jest.fn().mockReturnValue('https://platform.openmeet.net'), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AtprotoResolveController], + providers: [ + { + provide: EventQueryService, + useValue: eventQueryService, + }, + { + provide: ConfigService, + useValue: configService, + }, + ], + }).compile(); + + controller = module.get(AtprotoResolveController); + }); + + describe('resolve', () => { + const did = 'did:plc:abc123'; + const collection = 'community.lexicon.calendar.event'; + const rkey = 'abc456'; + const expectedAtUri = `at://${did}/${collection}/${rkey}`; + + it('should resolve an OpenMeet-published event by atprotoUri', async () => { + const mockEvent = { + slug: 'my-test-event', + atprotoUri: expectedAtUri, + } as unknown as EventEntity; + + eventQueryService.findByAtprotoUri.mockResolvedValue([mockEvent]); + + const result = await controller.resolve( + did, + collection, + rkey, + mockRequest, + ); + + expect(eventQueryService.findByAtprotoUri).toHaveBeenCalledWith( + expectedAtUri, + 'test-tenant', + ); + expect(result).toEqual({ + url: 'https://platform.openmeet.net/events/my-test-event', + slug: 'my-test-event', + type: 'event', + }); + }); + + it('should resolve a firehose-ingested event by sourceId when atprotoUri lookup fails', async () => { + const mockEvent = { + slug: 'ingested-event', + sourceType: 'bluesky', + sourceId: expectedAtUri, + } as unknown as EventEntity; + + eventQueryService.findByAtprotoUri.mockResolvedValue([]); + eventQueryService.findBySourceAttributes.mockResolvedValue([mockEvent]); + + const result = await controller.resolve( + did, + collection, + rkey, + mockRequest, + ); + + expect(eventQueryService.findByAtprotoUri).toHaveBeenCalledWith( + expectedAtUri, + 'test-tenant', + ); + expect(eventQueryService.findBySourceAttributes).toHaveBeenCalledWith( + expectedAtUri, + 'bluesky', + 'test-tenant', + ); + expect(result).toEqual({ + url: 'https://platform.openmeet.net/events/ingested-event', + slug: 'ingested-event', + type: 'event', + }); + }); + + it('should throw NotFoundException when no event matches', async () => { + eventQueryService.findByAtprotoUri.mockResolvedValue([]); + eventQueryService.findBySourceAttributes.mockResolvedValue([]); + + await expect( + controller.resolve(did, collection, rkey, mockRequest), + ).rejects.toThrow(NotFoundException); + }); + + it('should use FRONTEND_DOMAIN from config', async () => { + configService.get.mockReturnValue('https://custom.example.com'); + + const mockEvent = { + slug: 'some-event', + atprotoUri: expectedAtUri, + } as unknown as EventEntity; + + eventQueryService.findByAtprotoUri.mockResolvedValue([mockEvent]); + + const result = await controller.resolve( + did, + collection, + rkey, + mockRequest, + ); + + expect(configService.get).toHaveBeenCalledWith('app.frontendDomain', { + infer: true, + }); + expect(result.url).toBe('https://custom.example.com/events/some-event'); + }); + + it('should throw NotFoundException when FRONTEND_DOMAIN is not configured', async () => { + configService.get.mockReturnValue(undefined); + + const mockEvent = { + slug: 'some-event', + atprotoUri: expectedAtUri, + } as unknown as EventEntity; + + eventQueryService.findByAtprotoUri.mockResolvedValue([mockEvent]); + + await expect( + controller.resolve(did, collection, rkey, mockRequest), + ).rejects.toThrow( + 'FRONTEND_DOMAIN environment variable is not configured', + ); + }); + + it('should throw NotFoundException for unsupported collection', async () => { + await expect( + controller.resolve(did, 'app.bsky.feed.post', rkey, mockRequest), + ).rejects.toThrow(NotFoundException); + + expect(eventQueryService.findByAtprotoUri).not.toHaveBeenCalled(); + }); + + it('should prefer atprotoUri match over sourceId match', async () => { + const nativeEvent = { + slug: 'native-event', + atprotoUri: expectedAtUri, + } as unknown as EventEntity; + + eventQueryService.findByAtprotoUri.mockResolvedValue([nativeEvent]); + + await controller.resolve(did, collection, rkey, mockRequest); + + // Should NOT call findBySourceAttributes since atprotoUri found a match + expect(eventQueryService.findBySourceAttributes).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/bluesky/atproto-resolve.controller.ts b/src/bluesky/atproto-resolve.controller.ts new file mode 100644 index 00000000..bcb80172 --- /dev/null +++ b/src/bluesky/atproto-resolve.controller.ts @@ -0,0 +1,103 @@ +import { + Controller, + Get, + Inject, + Param, + Req, + Logger, + NotFoundException, + forwardRef, +} from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { ConfigService } from '@nestjs/config'; +import { Throttle } from '@nestjs/throttler'; +import { Public } from '../auth/decorators/public.decorator'; +import { EventQueryService } from '../event/services/event-query.service'; + +const SUPPORTED_COLLECTIONS = ['community.lexicon.calendar.event']; + +@ApiTags('ATProto') +@Controller('atproto') +export class AtprotoResolveController { + private readonly logger = new Logger(AtprotoResolveController.name); + + constructor( + @Inject(forwardRef(() => EventQueryService)) + private readonly eventQueryService: EventQueryService, + private readonly configService: ConfigService, + ) {} + + private getFrontendDomain(): string { + const frontendDomain = this.configService.get( + 'app.frontendDomain', + { infer: true }, + ); + if (!frontendDomain) { + throw new Error('FRONTEND_DOMAIN environment variable is not configured'); + } + return frontendDomain; + } + + @Public() + @Throttle({ default: { limit: 30, ttl: 60000 } }) + @Get('resolve/:did/:collection/:rkey') + @ApiOperation({ + summary: 'Resolve an AT URI to an OpenMeet resource URL', + description: + 'Given path params that form an AT URI (at://{did}/{collection}/{rkey}), returns the OpenMeet URL for the matching event.', + }) + async resolve( + @Param('did') did: string, + @Param('collection') collection: string, + @Param('rkey') rkey: string, + @Req() req: any, + ) { + const atUri = `at://${did}/${collection}/${rkey}`; + const tenantId = req.tenantId; + + this.logger.debug(`Resolving AT URI: ${atUri} for tenant: ${tenantId}`); + + if (!SUPPORTED_COLLECTIONS.includes(collection)) { + throw new NotFoundException( + `Unsupported collection: ${collection}. Supported: ${SUPPORTED_COLLECTIONS.join(', ')}`, + ); + } + + // First, check for OpenMeet-published events (atprotoUri field) + const nativeEvents = await this.eventQueryService.findByAtprotoUri( + atUri, + tenantId, + ); + + if (nativeEvents.length > 0) { + const event = nativeEvents[0]; + const frontendDomain = this.getFrontendDomain(); + return { + url: `${frontendDomain}/events/${event.slug}`, + slug: event.slug, + type: 'event', + }; + } + + // Second, check for firehose-ingested events (sourceType='bluesky', sourceId=AT URI) + const ingestedEvents = await this.eventQueryService.findBySourceAttributes( + atUri, + 'bluesky', + tenantId, + ); + + if (ingestedEvents.length > 0) { + const event = ingestedEvents[0]; + const frontendDomain = this.getFrontendDomain(); + return { + url: `${frontendDomain}/events/${event.slug}`, + slug: event.slug, + type: 'event', + }; + } + + throw new NotFoundException( + `No OpenMeet resource found for AT URI: ${atUri}`, + ); + } +} diff --git a/src/bluesky/bluesky.module.ts b/src/bluesky/bluesky.module.ts index 56840591..0d43fe6b 100644 --- a/src/bluesky/bluesky.module.ts +++ b/src/bluesky/bluesky.module.ts @@ -1,5 +1,6 @@ import { Module, forwardRef } from '@nestjs/common'; import { BlueskyController } from './bluesky.controller'; +import { AtprotoResolveController } from './atproto-resolve.controller'; import { BlueskyService } from './bluesky.service'; import { BlueskyIdService } from './bluesky-id.service'; import { BlueskyIdentityService } from './bluesky-identity.service'; @@ -26,7 +27,7 @@ import { UserAtprotoIdentityModule } from '../user-atproto-identity/user-atproto UserAtprotoIdentityModule, forwardRef(() => EventModule), ], - controllers: [BlueskyController], + controllers: [BlueskyController, AtprotoResolveController], providers: [ BlueskyService, BlueskyIdService,