package repository import ( "context" "fmt" "github.com/prabhupadasays/spsays-bff/internal/model" ) // PushRepo stores Web Push subscriptions (one per browser/device endpoint). type PushRepo struct{ pg *Postgres } func NewPushRepo(pg *Postgres) *PushRepo { return &PushRepo{pg: pg} } // Upsert records (or refreshes) a subscription. The endpoint is unique, so a // browser re-subscribing — possibly under a different user — rebinds cleanly. func (r *PushRepo) Upsert(ctx context.Context, userID, endpoint, p256dh, auth, userAgent string) error { _, err := r.pg.Pool.Exec(ctx, ` INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, user_agent) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (endpoint) DO UPDATE SET user_id = EXCLUDED.user_id, p256dh = EXCLUDED.p256dh, auth = EXCLUDED.auth, user_agent = EXCLUDED.user_agent, last_used_at = now()`, userID, endpoint, p256dh, auth, userAgent, ) if err != nil { return fmt.Errorf("upsert push subscription: %w", err) } return nil } // ListByUser returns the user's active subscriptions. func (r *PushRepo) ListByUser(ctx context.Context, userID string) ([]model.PushSubscription, error) { rows, err := r.pg.Pool.Query(ctx, ` SELECT id, user_id, endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = $1`, userID) if err != nil { return nil, fmt.Errorf("list push subscriptions: %w", err) } defer rows.Close() var out []model.PushSubscription for rows.Next() { var s model.PushSubscription if err := rows.Scan(&s.ID, &s.UserID, &s.Endpoint, &s.P256dh, &s.Auth); err != nil { return nil, fmt.Errorf("scan push subscription: %w", err) } out = append(out, s) } return out, rows.Err() } // PruneToMostRecent keeps only the `keep` most-recently-used subscriptions for // a user, deleting older ones — a soft per-user device cap. // SubscribedUserIDs lists everyone with at least one live push subscription. // // Used by broadcasts, which fan out to the whole user base: iterating users // and asking each for subscriptions would query for the (large) majority who // have never enabled push. func (r *PushRepo) SubscribedUserIDs(ctx context.Context) ([]string, error) { rows, err := r.pg.Pool.Query(ctx, `SELECT DISTINCT user_id FROM push_subscriptions`) if err != nil { return nil, fmt.Errorf("subscribed users: %w", err) } defer rows.Close() out := []string{} for rows.Next() { var id string if err := rows.Scan(&id); err != nil { return nil, fmt.Errorf("scan subscribed user: %w", err) } out = append(out, id) } return out, rows.Err() } func (r *PushRepo) PruneToMostRecent(ctx context.Context, userID string, keep int) error { if keep < 1 { keep = 1 } _, err := r.pg.Pool.Exec(ctx, ` DELETE FROM push_subscriptions WHERE user_id = $1 AND id NOT IN ( SELECT id FROM push_subscriptions WHERE user_id = $1 ORDER BY last_used_at DESC NULLS LAST LIMIT $2 )`, userID, keep, ) if err != nil { return fmt.Errorf("prune push subscriptions: %w", err) } return nil } // DeleteByEndpoint removes a subscription (when the push service reports it // gone). Server-internal — user-initiated unsubscribes must go through // DeleteByEndpointForUser so one user can't remove another's subscription. func (r *PushRepo) DeleteByEndpoint(ctx context.Context, endpoint string) error { _, err := r.pg.Pool.Exec(ctx, `DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint) if err != nil { return fmt.Errorf("delete push subscription: %w", err) } return nil } // DeleteByEndpointForUser removes a subscription only if it belongs to the // given user — the ownership-checked variant for the unsubscribe endpoint. func (r *PushRepo) DeleteByEndpointForUser(ctx context.Context, userID, endpoint string) error { _, err := r.pg.Pool.Exec(ctx, `DELETE FROM push_subscriptions WHERE endpoint = $1 AND user_id = $2`, endpoint, userID) if err != nil { return fmt.Errorf("delete push subscription: %w", err) } return nil }