Skip to content

feat: unified AttendanceService for all RSVP operations#576

Merged
tompscanlan merged 46 commits into
mainfrom
feature/attendance-service
Apr 13, 2026
Merged

feat: unified AttendanceService for all RSVP operations#576
tompscanlan merged 46 commits into
mainfrom
feature/attendance-service

Conversation

@tompscanlan
Copy link
Copy Markdown
Contributor

@tompscanlan tompscanlan commented Apr 10, 2026

Summary

  • Replaces 6 fragmented RSVP codepaths with a single AttendanceService that routes all attendance operations through resolveEvent() classification
  • Three write paths: public simple (PDS-only via Contrail), private (local event_attendees only), public with approval (PDS + local record)
  • Unified event system: all downstream listeners (CalendarInviteListener, ActivityFeedListener, EventListener, MatrixEventListener) now consume a single attendance.changed event instead of 5 separate legacy event types
  • Dashboard reads query Contrail: the "Attending" tab now unions Contrail RSVP records (public events) with event_attendees (private events)
  • Schema migration: eventId nullable + eventUri column on event_attendees to support foreign event attendance tracking
  • Removes dead code: RsvpIntegrationService, RsvpIntegrationController, syncRsvpToAtproto, createFromIngestion, and legacy event emissions

Architecture

POST /events/:slug/attend
  -> EventManagementService.attendEvent() [access control]
    -> AttendanceService.recordAttendance()
      -> resolveEvent(slug) -> ResolvedEvent { tenantEvent, uri, isPublic, requiresApproval }
      -> route:
        - Public simple     -> PDS publish only (Contrail indexes)
        - Private           -> event_attendees only
        - Public + approval -> PDS publish + event_attendees (pending)
      -> emit('attendance.changed')

Schema change

ALTER TABLE "eventAttendees" ALTER COLUMN "eventId" DROP NOT NULL;
ALTER TABLE "eventAttendees" ADD COLUMN "eventUri" TEXT;
-- Backfills eventUri from existing events with atprotoUri
-- Index on eventUri WHERE NOT NULL

Test plan

  • 38 unit tests passing (attendance.service.spec.ts)
  • Manual API testing: public RSVP, cancel, re-RSVP — correct rsvpUri and status
  • Manual API testing: private RSVP — creates local record, no rsvpUri
  • Manual API testing: private cancel + reactivate
  • Dashboard "Attending" tab shows public RSVPs via Contrail union query
  • Activity feed creates event.rsvp entries via attendance.changed listener
  • Calendar invite listener migrated to attendance.changed, sends on first-time RSVP
  • Contrail indexes RSVP records from Jetstream in real-time
  • Run full npm run test:local before merge
  • Run migration on dev after merge

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
@tompscanlan tompscanlan merged commit 88b057c into main Apr 13, 2026
4 checks passed
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant