// Package event reads and caches quest.atmo.event records. // // Events are records in an organizer's PDS, but the app needs to query them // by time-window and (eventually) location, which is awkward to do over // the network for every page render. So we keep a small local cache in // SQLite — see `internal/db/migrations/004_events_checkins.sql`. // // Writes (record creation) are not implemented here — those live behind the // admin UI in a future PR. This v1 only handles reading + caching what // other code (a checkin handler, an admin import job, etc.) hands us. package event import ( "context" "database/sql" "encoding/json" "errors" "fmt" "time" "github.com/bluesky-social/indigo/atproto/syntax" ) // NSID is the lexicon namespace. const NSID = "quest.atmo.event" // MaxEventLinks caps how many links an event may carry, mirroring // profile.MaxLinks. const MaxEventLinks = 5 // Geofence captures the optional lat/lng/radius from the lexicon. A nil // pointer on Record means the event has no geofence and the soft-check // can't run. type Geofence struct { Lat float64 Lng float64 RadiusMeters int } // Link is a single {label, url} pair attached to an event. Mirrors the // quest.atmo.event#link shape and parallels profile.Link. type Link struct { Label string `json:"label"` URL string `json:"url"` } // Record models a single event for the application. Mirrors the lexicon but // flattens the geofence into a pointer for ergonomic Go use. type Record struct { URI string // at-uri of the record Name string StartTime time.Time EndTime time.Time Location string Geofence *Geofence OrganizerDID syntax.DID ExpectedAttendees int // from the event record; 0 means unset Links []Link // optional links shown on the event page } // encodeLinks serializes links to the JSON stored in the events.links column. // Empty/nil links encode to "" so the column's default round-trips to no links. func encodeLinks(links []Link) (string, error) { if len(links) == 0 { return "", nil } b, err := json.Marshal(links) if err != nil { return "", fmt.Errorf("event: encode links: %w", err) } return string(b), nil } // decodeLinks parses the events.links column back into a slice. An empty // string yields nil (no links). func decodeLinks(s string) ([]Link, error) { if s == "" { return nil, nil } var links []Link if err := json.Unmarshal([]byte(s), &links); err != nil { return nil, fmt.Errorf("event: decode links: %w", err) } return links, nil } // ErrNotFound is returned by Get when no cached event matches. var ErrNotFound = errors.New("event: not found") // Cache upserts an event into the local cache. Idempotent — re-caching the // same URI overwrites the prior row (so an admin updating the canonical // record refreshes our view). func Cache(ctx context.Context, db *sql.DB, r Record) error { if r.URI == "" { return errors.New("event: empty URI") } if r.Name == "" { return errors.New("event: empty name") } if r.EndTime.Before(r.StartTime) { return errors.New("event: end_time before start_time") } var lat, lng *float64 var radius *int if r.Geofence != nil { lat = &r.Geofence.Lat lng = &r.Geofence.Lng radius = &r.Geofence.RadiusMeters } links, err := encodeLinks(r.Links) if err != nil { return err } _, err = db.ExecContext(ctx, ` INSERT INTO events ( uri, name, start_time, end_time, location, geofence_lat, geofence_lng, geofence_radius, organizer_did, cached_at, links ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?) ON CONFLICT(uri) DO UPDATE SET name = excluded.name, start_time = excluded.start_time, end_time = excluded.end_time, location = excluded.location, geofence_lat = excluded.geofence_lat, geofence_lng = excluded.geofence_lng, geofence_radius = excluded.geofence_radius, organizer_did = excluded.organizer_did, links = excluded.links, cached_at = CURRENT_TIMESTAMP `, r.URI, r.Name, r.StartTime.UTC(), r.EndTime.UTC(), r.Location, lat, lng, radius, r.OrganizerDID.String(), links, ) return err } // Get returns the cached event for uri, or ErrNotFound if it isn't cached // (yet). Future-work: fall back to a PDS fetch + cache write here. func Get(ctx context.Context, db *sql.DB, uri string) (Record, error) { row := db.QueryRowContext(ctx, ` SELECT uri, name, start_time, end_time, location, geofence_lat, geofence_lng, geofence_radius, organizer_did, expected_attendees, links FROM events WHERE uri = ? `, uri) return scanRow(row) } // IsOngoing returns true if the event's time window contains `at`. func (r Record) IsOngoing(at time.Time) bool { return !at.Before(r.StartTime) && !at.After(r.EndTime) } // scanRow reads a single events row into a Record. Tolerant of NULL // geofence columns. func scanRow(s scanner) (Record, error) { var r Record var organizer string var lat, lng sql.NullFloat64 var radius sql.NullInt64 var links string err := s.Scan( &r.URI, &r.Name, &r.StartTime, &r.EndTime, &r.Location, &lat, &lng, &radius, &organizer, &r.ExpectedAttendees, &links, ) if err == sql.ErrNoRows { return Record{}, ErrNotFound } if err != nil { return Record{}, err } if r.Links, err = decodeLinks(links); err != nil { return Record{}, err } if organizer != "" { did, parseErr := syntax.ParseDID(organizer) if parseErr == nil { r.OrganizerDID = did } } if lat.Valid && lng.Valid && radius.Valid { r.Geofence = &Geofence{ Lat: lat.Float64, Lng: lng.Float64, RadiusMeters: int(radius.Int64), } } return r, nil } // scanner abstracts *sql.Row and *sql.Rows so scanRow can serve both Get // and any future List helpers. type scanner interface { Scan(dest ...any) error }