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 P256dh string Auth string CreatedAt int64 LastNotifiedAt 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 the digest watermark to now — the user shouldn't be // paged for everything published before they opted in. func AddPushSubscription(sub, endpoint, p256dh, auth string) error { now := nowUnix() _, err := Get().Exec(` INSERT INTO push_subscriptions (endpoint, user_sub, p256dh, auth, created_at, last_notified_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(endpoint) DO UPDATE SET user_sub = excluded.user_sub, p256dh = excluded.p256dh, auth = excluded.auth, last_notified_at = excluded.last_notified_at`, endpoint, sub, p256dh, auth, now, now) if err != nil { return fmt.Errorf("add 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, p256dh, auth, created_at, last_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.P256dh, &p.Auth, &p.CreatedAt, &p.LastNotifiedAt); err != nil { return nil, err } out = append(out, p) } return out, rows.Err() } // 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() }