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¶
PurgeArchivedEvents → hard delete
removed from Redis geo index
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/checkin → EventService.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)
- Access code matches (if set).
- Capacity not exceeded, overflow goes to
waitlistonly ifwaitlist_enabled. - Required attestations present on the user.
- Visibility,
friendsrequires the joiner to be a friend of the host. - If
auto_acceptthe joiner isapprovedand added to the chat room immediately, otherwisependingand 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 /eventsbindsCreateEventRequest, 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, andexternal_link(http/https, length-capped, rendered as a tappable button, never unfurled). New events persist asupcoming; the display status is computed from the times. - Message retention. Event chat messages are purged by the
purgecron (TaskPurge,@every 24h) onceend_time + retention_daysis 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.
Cross-links¶
- Plans: the promote source and the origin of snapshotted board items
- Discovery: taxonomy, NL intent, and the map/radar surfaces
- Presence: check-in ledger and travel-log dedup
- Chat: the group room every event spawns and its retention worker
- Privacy gate: the visibility rule behind
/events/:id/detail - Code:
backend/internal/services/event/(event_service.go,event_lifecycle_service.go,event_store.go,event_handler.go,event_items.go,recommend.go,routes.go),backend/internal/models/event.go,backend/internal/models/event_item.go