Skip to content

Events

Purpose

The event domain owns the event resource: creation, updates, participants, host transfer, check-in, and the group chat room every event spawns. It lives in backend/internal/services/event/ as a vertical slice (store.go-style event_store.go, event_service.go, event_handler.go, routes.go).

Two services split the work:

  • EventService (event_service.go) handles the request-driven surface: create, join, approve, cancel, transfer, check-in, search, and the ranked feed.
  • EventLifecycleService (event_lifecycle_service.go) drives the time-based state machine, orphan cleanup, and message retention off the scheduler.

Together they keep the events table consistent with both the wall clock and the social graph: an event with no remaining host or participants is removed, an event past its retention window has its chat messages purged.

Mental model

An event is a normal row whether a host created it directly or a plan was promoted into it. Promotion sets promoted_from_plan_id and carries a snapshotted board of event_items (see Event items), but the event is otherwise identical to a hand-created one.

Every event pairs with a chat_rooms row (type='group') reached via event.chat_id. The host joins the room as admin, approved participants as member.

State machine

CreateEvent
upcoming
↓ now > start_time
ongoing
↓ now > end_time
completed
↓ now > end_time + retention_days
archived
PurgeArchivedEvents → hard delete
↓ CancelEvent (from upcoming or ongoing)
cancelled
removed from Redis geo index
Solid arrows are time-driven transitions evaluated by the status_update cron (every 5 min). The dashed branch is the explicit cancel path from either upcoming or ongoing.

EventStatus (upcoming / ongoing / completed / archived / cancelled) lives on models.Event. Time-driven transitions are evaluated by the status_update cron (@every 5m, TaskStatusUpdate in backend/internal/async/scheduler.go), which calls EventLifecycleService.UpdateEventStatuses. cancelled is set explicitly by CancelEvent, which also drops the row from the Redis geo index. Once an event is concluded (Event.IsConcluded) the management handlers refuse edits, deletes, transfer, and attendee churn.

HTTP endpoints

Mounted in backend/internal/services/event/routes.go. Authenticated routes sit under the verified group; the public trio takes optional auth so hosts and approved participants still receive privileged fields.

Method Path Auth Description
POST /events JWT Create event
GET /events JWT List events (filtered + recommendation-ranked)
GET /events/search JWT Taxonomy-aware text search (q, lang)
GET /events/timezone JWT Resolve a venue timezone from coords
GET /events/:id/detail JWT Viewer-projected detail behind the privacy gate; not-visible resolves 404
GET /events/:id/items JWT Read the snapshotted board (see Event items)
POST /events/:id/items JWT (host) Add a board item (title, notes, location, photo_keys)
PATCH /events/:id/items/:itemId JWT (host) Edit a board item (omitting photo_keys preserves its photos)
DELETE /events/:id/items/:itemId JWT (host) Remove a board item
PUT /events/:id/items/order JWT (host) Reorder the board
PATCH /events/:id JWT Update (also syncs chat room name on title change)
DELETE /events/:id JWT Delete
POST /events/:id/join JWT Join (access code + capacity + visibility checks)
GET /events/:id/participants JWT List participants
POST /events/:id/participants/:userId/approve JWT (host) Approve pending participant
DELETE /events/:id/participants/:userId JWT (host) Remove participant
POST /events/:id/start JWT (host) Force-start now
POST /events/:id/cancel JWT (host) Cancel event
POST /events/:id/transfer-ownership JWT (host) Transfer host role
POST /events/:id/messages JWT Post to the event chat room
GET /events/:id/messages JWT List chat messages
GET /events/active/nearby JWT Nearby in-window RSVPs (lat, lng) for the check-in dock
POST /events/:id/checkin JWT Explicit arrival check-in
GET /events/:id optional Shareable detail (privileged fields gated on viewer)
GET /events/:id/public none Sanitized public payload
GET /share/events/:id none OG/share landing page
GET /users/:userId/events JWT Events a given user is in

Data model

Table Model Role
events Event Host, title, times, status, visibility, access_code, max_capacity, waitlist_enabled, auto_accept, required_attestations, chat_id, location_id, PostGIS coords, promoted_from_plan_id, category, tags, retention_days
event_participants EventParticipant Unique (event_id, user_id) with status (pending/approved/rejected/waitlist) and nullable checked_in_at
event_items EventItem Event-owned snapshot of a promoted plan's curated board
chat_rooms (type='group') One per event, joined via event.chat_id

Off-grid events keep location_id nil and render from location_label plus the embedded Spatial address. A residential or raw-address pick the resolver leaves unminted takes the same path (the picker surfaces residential/address kinds). Board items follow the same shape: an event_items row with location_id nil carries its own inline location_label + lat/lng + address. category is a canonical string drawn from backend/data/event_taxonomy.json (stored as <Bucket> or <Bucket>/<Sub>); tags is an open-ended GIN-indexed facet set.

Event items

Promoting a plan snapshots each selected plan Item into an event-owned models.EventItem row (backend/internal/models/event_item.go). The snapshot is self-contained: photos and links are copied as jsonb, so editing or deleting the source plan Item never changes the board. GET /events/:id/items renders that snapshot with no reach into the plan or item domain (event_items.go builds the response; EventItem.Location is the only live join, used to derive open_now / a short hours label).

The host owns the board after promotion and can also build it directly: the event-items POST / PATCH / DELETE / order routes create, edit, remove, and reorder items (the create-event sheet uses these for its itinerary). Item photos are host-editable via photo_keys on the write payload (EventItemRequest): the keys are uploaded asset keys, cover first (index 0). On update, omitting photo_keys preserves the existing photos (a COALESCE in UpdateEventItem), while a present list replaces them (an empty list clears them). Links stay promotion-time snapshots. The read DTO exposes each photo's asset_key alongside its CDN url so a client can round-trip the set on edit.

EventService.ListEventItems returns items in position order. Gated items (EventItem.Gated) are hidden from viewers who are neither the host nor an approved participant; the gate check is skipped entirely when no item is gated (the common case). Events that were never promoted return an empty board.

Categorization

Events share the place vocabulary plumbing through platform/taxonomy (backend/internal/platform/taxonomy/taxonomy.go, backed by backend/data/event_taxonomy.json). The create handler validates category via IsEventCategory, merges EventCategoryDefaultTags(category) with any supplemental tags, and rejects tags that fail IsTag.

GET /events/search?q=&lang= parses q through taxonomy.Service.ParseQuery into (category, tags, freetext) and filters the event list on category equality, tags @> …, and a title ILIKE. The lang param picks the taxonomy locale, so a query in any supported locale resolves to the same canonical category with no client-side translation. See Discovery for the full taxonomy and NL-intent story.

Recommendation ranking lives in-domain (recommend.go): GetEvents scores the result set by category affinity and distance. Tags are used purely for filtering, not affinity.

Check-in

Two paths flip event_participants.checked_in_at. Both preserve Status (the RSVP decision); a read that wants "did they actually show?" checks checked_in_at IS NOT NULL. Both are idempotent.

Path A: silent location-id match. When a capture publishes with a location_id matching an active RSVPed event, the publish flow calls MarkEventCheckinFromLocation(userID, locationID, capturedAt). The store joins event_participants × events on event.location_id = locationID, an in-window time bound, status ∈ {approved, pending}, and checked_in_at IS NULL, stamping matches. There is no proximity check: the user explicitly picked the place, so the location pick is the consent signal.

Path B: explicit arrival. The Discover dock's check-in button (lit by the active/nearby poll) opens a sheet of the user's in-window RSVPs within EventCheckinProximityMeters of their coords. Confirming calls POST /events/:id/checkinEventService.CheckIn, which stamps checked_in_at and dispatches an "X has arrived" system message into the event chat via the ChatSystemSender port. The endpoint returns { event_id, already_checked_in }.

Constant Value Rationale
EventCheckinProximityMeters 150 Radius for the active/nearby poll. Covers GPS jitter and venue footprints.
EventCheckinTimeWindow 30 min Grace either side of start_time / end_time for both paths.

Off-grid events excluded from Path B

Events with no coords (latitude == 0 && longitude == 0) are filtered out of the active/nearby payload, so they never appear in the sheet. Path A still works since it joins on location_id, not coords.

Store surface: MarkEventCheckinFromLocation, NearbyActiveForUser, RecordExplicitEventCheckin in event_store.go. The service wrapper supplies the constants and runs the chat fan-out. Travel-log dedup (a user who checks in then captures a moment at the same place gets one ledger row) is handled by the presence domain's per-location window.

Invariants & gotchas

Privileged response fields

Every response shape is assembled by buildEventResponse and sanitized by default. access_code is returned to the host only; chat_id and metadata require the host or an approved participant (addPrivilegedEventFields). This holds across create, list, search, detail, the public share payload (GET /events/:id/public), and the optional-auth GET /events/:id. Detail and public both return EventDetailResponse (the canonical body plus host).

Join eligibility (in order)

  1. Access code matches (if set).
  2. Capacity not exceeded, overflow goes to waitlist only if waitlist_enabled.
  3. Required attestations present on the user.
  4. Visibility, friends requires the joiner to be a friend of the host.
  5. If auto_accept the joiner is approved and added to the chat room immediately, otherwise pending and the host must approve.

Transfer of ownership

TransferOwnership removes the new host from event_participants and inserts the old host as an approved participant; chat room roles flip accordingly. The handler is host-gated, so the outgoing host hands the room off unilaterally with no consent step in code.

Orphan detection on leave

LeaveEvent calls EventLifecycleService.CheckAndCleanupEvent: the event is deleted if the host is gone and no other active participants remain. If the host is gone but participants remain, the event moves to cancelled so the conversation history survives.

  • Create contract. POST /events binds CreateEventRequest, the client-settable surface only; server-owned fields (host, status, type, chat room, promote back-link, partner, retention) are never bound. Validation covers time ordering, non-negative capacities, the recurrence rule, visibility enum membership, and external_link (http/https, length-capped, rendered as a tappable button, never unfurled). New events persist as upcoming; the display status is computed from the times.
  • Message retention. Event chat messages are purged by the purge cron (TaskPurge, @every 24h) once end_time + retention_days is past. See Chat. Friend DMs are never purged by that worker.

Notifications

Trigger Recipient Notification
JoinEvent (pending) host event.join_requested
ApproveParticipant joiner event.join_approved
UpdateEvent on an allowlisted field approved participants event.details_changed + event:updated WS push
CancelEvent / flip to cancelled approved participants event.host_cancelled

Cosmetic edits do not notify. See Notifications for the channel matrix.