package storage import ( "database/sql" ) // The reads behind adventure push alerts. The alert sender needs two things the // rest of the storage layer does not: dispatches ordered by when they *happened* // rather than by subject, and a way to turn a signed-in identity into the // character name a fact would carry. // AdvEventsSince returns dispatches that occurred after sinceUnix, newest first, // capped at limit. // // The clock is occurred_at, not an arrival time, and that choice has a // consequence worth stating: a dispatch that reaches Pete late but describes // something old — a queue row unparked months after the fact, which W0's // inversion made possible — sorts behind the watermark and is never alerted on. // That is the outcome we want. An alert is a claim that something is happening // now, and a phone buzzing about a hire from March would be a lie told urgently. func AdvEventsSince(sinceUnix int64, limit int) ([]AdvEvent, error) { if limit <= 0 { return nil, nil } rows, err := Get().Query(` SELECT guid, event_type, tier, subject, opponent, boss, zone, region, level, tally, outcome, milestone, stakes, run_id, occurred_at FROM adventure_events WHERE occurred_at > ? ORDER BY occurred_at DESC LIMIT ?`, sinceUnix, limit) if err != nil { return nil, err } defer rows.Close() var out []AdvEvent for rows.Next() { var e AdvEvent var tier, subject, opponent, boss, zone, region sql.NullString var outcome, milestone, stakes, runID sql.NullString if err := rows.Scan(&e.GUID, &e.EventType, &tier, &subject, &opponent, &boss, &zone, ®ion, &e.Level, &e.Tally, &outcome, &milestone, &stakes, &runID, &e.OccurredAt); err != nil { return nil, err } e.Tier, e.Subject, e.Opponent = tier.String, subject.String, opponent.String e.Boss, e.Zone, e.Region = boss.String, zone.String, region.String e.Outcome, e.Milestone, e.Stakes = outcome.String, milestone.String, stakes.String e.RunID = runID.String out = append(out, e) } return out, rows.Err() } // AdvCharacterForOwner returns the character name currently on the board for a // signed-in user's localpart, which is the join an owner-scoped alert needs: a // fact carries a character *name*, a session carries a localpart, and the only // thing that connects them is the owner-private (localpart -> token) row gogobee // pushes alongside the public (token -> name) board. // // It fails closed, and every way it can fail is a way it should: // // - no self-detail row: gogobee has stopped pushing for this player, so Pete // has no current basis to claim any name is theirs. // - no roster row for the token: the player is off the board — removed, or // opted out of the news entirely. An opted-out player's facts are anonymised // on the wire anyway, so there is no name left to match even in principle. // // Both cases mean the caller sends nothing, which is the correct answer to "is // this dispatch about you" when Pete cannot honestly tell. func AdvCharacterForOwner(localpart string) (string, bool) { if localpart == "" { return "", false } var name string err := Get().QueryRow(` SELECT r.name FROM player_self_detail d JOIN adventure_roster r ON r.token = d.token WHERE d.localpart = ?`, localpart).Scan(&name) if err != nil || name == "" { return "", false } return name, true }