Three verbs to match gogobee's: call off an expedition, turn back out of somebody else's party, send the pet sitter home. Which one this page offers is derived here rather than pushed — leadership is already legible in the party seats and the sitter's standing is already in the babysit offer, so nothing new crosses the wire. Two things running it turned up that no test would have. An applied abandon left "Pull out of the run" sitting under a verdict saying the expedition was over, so an applied verb now also hides the other verbs it just made untrue. And a party member was being offered that same button in the first place, beside the one that actually works — Pete knows from the seat it just read that gogobee would refuse it, so it is withheld. Also: heal the Matrix handle onto push rows stored before the column existed, on its own endpoint rather than through the subscribe upsert, which resets both watermarks and would have silenced the digest for anybody who reads the site regularly. And stack the board row below sm — four flex columns that wrapped to six lines on a phone, pre-existing.
169 lines
6.4 KiB
Go
169 lines
6.4 KiB
Go
package storage
|
|
|
|
import "fmt"
|
|
|
|
// PushSubscription is one browser/device endpoint a signed-in user has opted in
|
|
// for Web Push digests. See the push_subscriptions schema for the field roles.
|
|
type PushSubscription struct {
|
|
Endpoint string
|
|
UserSub string
|
|
Localpart string
|
|
P256dh string
|
|
Auth string
|
|
CreatedAt int64
|
|
LastNotifiedAt int64
|
|
LastAdvNotifiedAt int64
|
|
}
|
|
|
|
// AddPushSubscription records (or refreshes) a push endpoint for a user. The
|
|
// endpoint is the primary key, so a re-subscribe from the same browser updates
|
|
// the keys and resets both watermarks to now — the user shouldn't be paged for
|
|
// everything published before they opted in.
|
|
//
|
|
// localpart is the session's Matrix handle, refreshed on every re-subscribe so a
|
|
// row stored by a build that predated adventure alerts heals itself the first
|
|
// time that browser subscribes again. It may legitimately be empty (a session
|
|
// minted before the game economy existed carries no username); such a row simply
|
|
// never matches an owner-scoped alert.
|
|
func AddPushSubscription(sub, localpart, endpoint, p256dh, auth string) error {
|
|
now := nowUnix()
|
|
_, err := Get().Exec(`
|
|
INSERT INTO push_subscriptions
|
|
(endpoint, user_sub, user_localpart, p256dh, auth, created_at, last_notified_at, last_adv_notified_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(endpoint) DO UPDATE SET
|
|
user_sub = excluded.user_sub,
|
|
user_localpart = excluded.user_localpart,
|
|
p256dh = excluded.p256dh,
|
|
auth = excluded.auth,
|
|
last_notified_at = excluded.last_notified_at,
|
|
last_adv_notified_at = excluded.last_adv_notified_at`,
|
|
endpoint, sub, localpart, p256dh, auth, now, now, now)
|
|
if err != nil {
|
|
return fmt.Errorf("add push subscription: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// HealPushSubscriptionLocalpart fills in the Matrix handle on a row that was
|
|
// stored before push_subscriptions had the column — the rows that can never match
|
|
// an owner-scoped adventure alert, and whose owners have no way to notice.
|
|
//
|
|
// It is deliberately NOT AddPushSubscription with the same arguments. That upsert
|
|
// resets both watermarks to now, which is right when somebody opts in and
|
|
// catastrophic on a heal: the browser would call it on every page load, so a
|
|
// reader who visits daily would silently never receive a digest or an alert
|
|
// again. This touches one column and no clock.
|
|
//
|
|
// Scoped to user_sub so presenting somebody else's endpoint rewrites nothing, and
|
|
// restricted to rows whose localpart is still empty — so it is a no-op after the
|
|
// first success, and it can never overwrite a good handle with a stale one.
|
|
func HealPushSubscriptionLocalpart(sub, endpoint, localpart string) error {
|
|
if localpart == "" {
|
|
return nil // nothing to heal with; see AddPushSubscription on empty handles
|
|
}
|
|
_, err := Get().Exec(
|
|
`UPDATE push_subscriptions SET user_localpart = ?
|
|
WHERE endpoint = ? AND user_sub = ? AND user_localpart = ''`,
|
|
localpart, endpoint, sub)
|
|
if err != nil {
|
|
return fmt.Errorf("heal push subscription: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RemovePushSubscription drops one endpoint regardless of owner. Reserved for
|
|
// the digest sender's prune path, where a push service has reported the endpoint
|
|
// gone (404/410) and there's no caller identity to scope by. User-initiated
|
|
// opt-outs must use RemovePushSubscriptionForUser.
|
|
func RemovePushSubscription(endpoint string) error {
|
|
_, err := Get().Exec(`DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint)
|
|
if err != nil {
|
|
return fmt.Errorf("remove push subscription: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RemovePushSubscriptionForUser drops an endpoint only if it belongs to sub, so
|
|
// a signed-in user can't unsubscribe another account's device by presenting its
|
|
// endpoint string. A no-op (no matching row) is not an error.
|
|
func RemovePushSubscriptionForUser(sub, endpoint string) error {
|
|
_, err := Get().Exec(
|
|
`DELETE FROM push_subscriptions WHERE endpoint = ? AND user_sub = ?`, endpoint, sub)
|
|
if err != nil {
|
|
return fmt.Errorf("remove push subscription: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListPushSubscriptions returns every stored subscription, for the digest sender.
|
|
func ListPushSubscriptions() ([]PushSubscription, error) {
|
|
rows, err := Get().Query(
|
|
`SELECT endpoint, user_sub, user_localpart, p256dh, auth,
|
|
created_at, last_notified_at, last_adv_notified_at
|
|
FROM push_subscriptions`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []PushSubscription
|
|
for rows.Next() {
|
|
var p PushSubscription
|
|
if err := rows.Scan(&p.Endpoint, &p.UserSub, &p.Localpart, &p.P256dh, &p.Auth,
|
|
&p.CreatedAt, &p.LastNotifiedAt, &p.LastAdvNotifiedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// TouchAdvPushSubscription advances an endpoint's *adventure alert* watermark, so
|
|
// the next pass only considers dispatches that occurred after ts. Kept separate
|
|
// from TouchPushSubscription for the reason the schema gives: the digest and the
|
|
// alerts must not be able to consume each other's backlog.
|
|
func TouchAdvPushSubscription(endpoint string, ts int64) error {
|
|
_, err := Get().Exec(
|
|
`UPDATE push_subscriptions SET last_adv_notified_at = ? WHERE endpoint = ?`, ts, endpoint)
|
|
if err != nil {
|
|
return fmt.Errorf("touch adventure push watermark: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TouchPushSubscription advances an endpoint's digest watermark so its next
|
|
// digest only considers stories seen after ts.
|
|
func TouchPushSubscription(endpoint string, ts int64) error {
|
|
_, err := Get().Exec(
|
|
`UPDATE push_subscriptions SET last_notified_at = ? WHERE endpoint = ?`, ts, endpoint)
|
|
if err != nil {
|
|
return fmt.Errorf("touch push subscription: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NewClassifiedSince returns classified stories first seen after sinceUnix,
|
|
// newest first, capped at limit. The digest sender uses it to count and preview
|
|
// what's new for a subscriber; it carries just the fields a digest needs.
|
|
func NewClassifiedSince(sinceUnix int64, limit int) ([]Story, error) {
|
|
rows, err := Get().Query(
|
|
`SELECT id, headline, source, channel, seen_at
|
|
FROM stories
|
|
WHERE classified = 1 AND channel NOT IN ('_discarded', '_duplicate') AND seen_at > ?
|
|
ORDER BY seen_at DESC
|
|
LIMIT ?`, sinceUnix, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []Story
|
|
for rows.Next() {
|
|
var s Story
|
|
if err := rows.Scan(&s.ID, &s.Headline, &s.Source, &s.Channel, &s.SeenAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, rows.Err()
|
|
}
|