feat: unified AttendanceService for all RSVP operations#576
Merged
Conversation
Support RSVP operations for Contrail-only ATProto events that have no tenant DB EventEntity row. These methods accept a raw AT URI instead of an EventEntity, use uri-only subject references (no CID lookup), and derive rkeys deterministically from the event URI.
Routes event references (tenant slugs and did~rkey ATProto slugs) to their concrete types for unified RSVP handling.
Public simple → PDS only. Private → local record only. Public with approval → PDS + local record (pending status).
getUserByUlid doesn't exist — the correct method is findByUlid(ulid, tenantId?).
Public: notgoing to PDS + cancel local overlay if exists. Private: cancel local record only. Emits attendance.changed.
findByUlid returns NullableType — add resolveUser() that throws NotFoundException on null, replacing all direct findByUlid calls.
Supports attendance records for foreign events (no tenant EventEntity). Backfills eventUri from existing events with atprotoUri. Adds partial index on eventUri for AT Protocol URI lookups.
ATProto slug (did~rkey) RSVPs now delegate to AttendanceService. All existing tenant event RSVP logic preserved unchanged. AttendanceModule imported into EventModule.
Remove RsvpIntegrationService, RsvpIntegrationController, and createFromIngestion (Contrail indexes ATProto RSVPs directly). Remove event.rsvp.ingested listener from ActivityFeedListener. Remove associated metrics, tests, and e2e specs.
The EventManagementService now depends on AttendanceService and AtprotoEnrichmentService (from Task 5). Add mocks to all test modules that instantiate EventManagementService.
Foreign events have no event/group context, so use 'sitewide' feed scope instead of invalid 'user' scope.
The event relation on EventAttendeesEntity was made nullable to support foreign events. Add optional chaining to existing code that accesses attendee.event.
EventAttendeeService and UserService are circularly imported at runtime through the module graph. Use @Inject(forwardRef(...)) on the constructor parameters to break the cycle.
The multi-tenant migration runner creates per-schema DataSources but
queryRunner.query() resolves unqualified table names to public schema
when public.eventAttendees also exists. Fix by using
"${schema}"."eventAttendees" pattern.
Also adds e2e tests for attendance flow (10 tests).
Port private event access checks and group membership validation from EventManagementService into AttendanceService. Adds GroupMemberQueryService dependency. Checks: creator access, existing attendee, group membership, guest role denial, requireGroupMembership enforcement.
- Add upsertAttendee() for idempotent create/update of attendee records - Add calculateStatus() with waitlist capacity check and approval gating - Add determineRole() assigning Host to creator/group admin, Participant otherwise - Refactor recordPrivateAttendance and recordPublicWithOverlay to use upsert - Track previousStatus for attendance change events
…rappers Both methods now delegate entirely to AttendanceService instead of containing ~500 lines of inline RSVP logic. The old code handled private event checks, group membership, role determination, waitlist, approval, reactivation, duplicate-retry, and mail sending inline. All of that now lives in AttendanceService (Tasks 1-3). Tests updated from testing internal logic to testing delegation pattern: status mapping, attendee lookup, and minimal shape fallback.
…changed Remove event.rsvp.added emission from EventAttendeeService.create(). Remove old handlers: event.attendee.added, event.attendee.status.changed, event.rsvp.added from event, activity-feed, calendar-invite, and matrix listeners. All functionality now flows through attendance.changed handlers. -1,309 lines of dead code removed.
…le summary For public events where users RSVP via PDS (no local event_attendees record), the dashboard attending tab, dashboard summary, and user profile now also query Contrail's RSVP table. This ensures all events a user is attending appear in read paths regardless of whether the RSVP was stored locally or only on-protocol.
Code review fixes (scored >= 70 confidence): - Fix calendar invite sent to wrong user (missing user filter in findOne) - Restore organizer "guest joined" email via new GuestJoinedListener - Emit attendance.changed from admin approval path (updateEventAttendee) - Remove dead event.attendee.created/deleted listeners (unreachable code) - Fix previousStatus on private cancel (capture before mutation) - Skip attendance.changed emission on no-op re-RSVP (changed flag) - Update new code terminology from "Bluesky" to "AT Protocol" - Align EventListener with MatrixEventListener on 'maybe' status Additional fixes found during e2e testing: - Use PdsSessionService in createRsvpByUri/deleteRsvpByUri for custodial account support (was using OAuth-only resumeSession) - Add on-demand CID fetch in createRsvpByUri for valid StrongRef subjects Tests: 151 suites, 2117 unit tests, 13 e2e tests — all passing.
The AttendanceService was calling blueskyRsvpService.createRsvpByUri() without a providedAgent, causing it to fall through to the OAuth session path (Redis lookup). For custodial PDS users, there is no OAuth session — their credentials are stored in the DB and resolved via PdsSessionService. This caused 500 errors on RSVP for all custodial users. Now uses PdsSessionService.getSessionForUser() which handles both custodial (PDS credentials) and OAuth (Redis session) users, matching the pattern already used by AtprotoPublisherService.
…effort Three fixes to get the attendance service branch passing e2e tests: 1. recordPublicWithOverlay: Move getSessionAgent inside try/catch so PDS publish is best-effort. Users without AT Protocol identity (e.g. quick-rsvp guests) still get local attendee records. 2. quickRsvp: Replace manual attendee creation with attendanceService.recordAttendance(). This ensures attendance.changed events are emitted, so calendar invites, activity feed, and Matrix room updates all fire correctly. 3. CalendarInviteListener: Skip 'pending' status RSVPs (approval-required events) in addition to 'notgoing'. Emit actual attendee status from recordPublicWithOverlay instead of the requested status. Also fixes isPublic check: unlisted events (like public) allow any authenticated user to RSVP — only private events require invitation.
- auth.service.login-link.spec.ts: add AttendanceService provider - attendance.e2e-spec.ts: expect local attendee record (id) instead of rsvpUri for public tenant events, since PDS publish is best-effort
- Guard PDS cancel with try/catch and null URI check, matching recordPublicWithOverlay's best-effort pattern. Previously, cancelling a public tenant event without atprotoUri would crash. - Look up actual previousStatus from local record instead of hardcoding 'going'. Waitlisted/pending users emitted wrong status. - Remove unused resolveUserDid() and UserAtprotoIdentityService dep.
- Add AtprotoEnrichmentModule to ActivityFeedModule so VisibilityGuard can recognize AT Protocol slugs on /events/:slug/feed. - Schema-qualify raw SQL in UserService Contrail RSVP queries.
…ant lookup Extract event resolution logic into EventQueryService. The new resolveForAttendance() method handles both regular slugs and AT Protocol format slugs (did:plc:xxx~rkey), checking the tenant DB first before falling back to Contrail for foreign events.
…pendencies Remove event resolution responsibility from AttendanceService. Method signatures now accept ResolvedEvent directly instead of a slug string. Removed: TenantConnectionService, AtprotoEnrichmentService, EventEntity repository, and initializeRepository/resolveEvent methods. Kept ContrailQueryService for isAttending.
Replace findEventBySlug spies with resolveForAttendance mocks in quick-RSVP date validation tests.
- In showEvent, query Contrail for user's RSVP status on foreign events so the UI reflects attendance after page reload. - In EventActivityFeedController, use resolveForAttendance for AT Protocol slugs; return empty feed for foreign events instead of 404.
Adds ContrailQueryService.findByUris() to fetch multiple records by URI in a single WHERE uri IN (...) query, with early-return for empty input.
…peline Two parallel queries: Contrail RSVPs enriched via enrichRecords() for public/foreign events, plus local eventAttendees for private events (atprotoUri IS NULL). Deduplicates, merges, and sorts by startDate. Supports limit and upcomingOnly options. Gracefully falls back to local-only when user has no DID or Contrail is unavailable.
…endingEvents Replace ~100 lines of duplicate Contrail query logic in UserService.getProfileSummary with a single call to EventQueryService.getAttendingEvents(), which covers local, Contrail, and foreign events in a unified way.
Replace the inline createAttendingQuery logic in getDashboardSummary with a call to the unified getAttendingEvents() method, which correctly handles both Contrail RSVP records and local private events.
Q2 was filtering atprotoUri IS NULL which excluded public local events where the Contrail RSVP hasn't synced yet (PDS write is best-effort). deduplicatePrivateEvents handles overlap with Q1 results. E2e test compares before/after attending count instead of searching the limited preview list.
…range getMyEvents (calendar view) now delegates to getAttendingEvents with startDate/endDate options. Added date range filtering to getAttendingEvents alongside the existing upcomingOnly filter.
…attendeeStatus getMyEvents was regressed to attending-only when wired to getAttendingEvents. Restores combined organized + attending query with dedup, adds ATProto DID-based organizer detection for foreign events, and enriches each result with isOrganizer/attendeeStatus/attendeeRole. Also fixes: - om-50hu: activity feed now looks up eventName from Contrail for foreign RSVPs - om-mb6w: getPastEventsCount includes Contrail-sourced past events
4 tasks
tompscanlan
added a commit
that referenced
this pull request
Apr 17, 2026
The unified AttendanceService refactor (PR #576) is not in this release, so port the past-event guard into EventAttendeeService.create() — where RSVPs still route in prod — instead of cherry-picking the attendance.service.ts change that depends on the refactor. Mirrors the logic from attendance.service.ts:438-441 on main: - Compute event end (endDate || startDate) - Reject with BadRequestException if end is in the past - No-op when dates are absent (fail open) Ingestion path (createFromIngestion) intentionally untouched — firehose RSVPs can arrive slightly after an event ends due to network lag, and historical backfills record past attendance.
3 tasks
tompscanlan
added a commit
that referenced
this pull request
Apr 23, 2026
* revert: remove unreleased features from main, align with prod (v1.5.1) Reverts unreleased features to bring main back in sync with production: - #577 perf(db): query fingerprint + home page optimization - #576 feat: unified AttendanceService - #574 feat(contrail): integrate Contrail appview Hotfix changes (v1.5.1) are retained — they exist in the baseline. All work is preserved on its respective feature branch: - feature/contrail-integration - feature/attendance-service - feature/db-query-performance - feature/contrail-rsvp Direction shift: adopting Contrail/atmo as the new stack rather than integrating Contrail into the OpenMeet API. * fix(test): use future dates in edge-cases spec to avoid past-event guard The hotfix (v1.5.1) added a past-event RSVP guard that rejects RSVPs to events with past start dates. The edge-cases spec used 2024 dates, hitting the guard before reaching the race condition logic under test.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
AttendanceServicethat routes all attendance operations throughresolveEvent()classificationevent_attendeesonly), public with approval (PDS + local record)CalendarInviteListener,ActivityFeedListener,EventListener,MatrixEventListener) now consume a singleattendance.changedevent instead of 5 separate legacy event typesevent_attendees(private events)eventIdnullable +eventUricolumn onevent_attendeesto support foreign event attendance trackingRsvpIntegrationService,RsvpIntegrationController,syncRsvpToAtproto,createFromIngestion, and legacy event emissionsArchitecture
Schema change
Test plan
attendance.service.spec.ts)rsvpUriandstatusrsvpUrievent.rsvpentries viaattendance.changedlistenerattendance.changed, sends on first-time RSVPnpm run test:localbefore merge