package store import ( "context" "database/sql" "time" ) // Web Push: подписки браузеров на уведомления (AGENT.md 7.16, Фаза 7). // // Эндпоинт выдаёт push-сервис (FCM, Mozilla, Apple), ключи p256dh/auth нужны // для шифрования payload по RFC 8291 и приходят из PushSubscription.toJSON(). // Всё, что приходит от клиента, ограничено по длине, а ключи дополнительно // проверяются в HTTP-слое: в базе не должно оказаться произвольного текста. // PushSubscription — подписка одного устройства. type PushSubscription struct { ID uint64 UserID uint64 Endpoint string P256dh string Auth string UserAgent string CreatedAt time.Time LastSuccessAt *time.Time LastFailureAt *time.Time FailureCount int } // SavePushSubscriptionParams — параметры подписки от клиента. type SavePushSubscriptionParams struct { UserID uint64 Endpoint string P256dh string Auth string UserAgent string } const pushSubscriptionColumns = `id, user_id, endpoint, p256dh, auth, user_agent, created_at, last_success_at, last_failure_at, failure_count` // SavePushSubscription сохраняет подписку устройства: повторная подписка с тем // же эндпоинтом обновляет ключи и сбрасывает счётчик неудач. Эндпоинт // уникален глобально, поэтому подписка «переезжает» к последнему владельцу — // так бывает, когда на одном устройстве сменился аккаунт. func (s *Store) SavePushSubscription(ctx context.Context, params SavePushSubscriptionParams) (*PushSubscription, error) { _, err := s.writer.ExecContext(ctx, ` INSERT INTO push_subscriptions (id, user_id, endpoint, p256dh, auth, user_agent, created_at, failure_count) VALUES (?, ?, ?, ?, ?, ?, ?, 0) ON CONFLICT (endpoint) DO UPDATE SET user_id = excluded.user_id, p256dh = excluded.p256dh, auth = excluded.auth, user_agent = excluded.user_agent, failure_count = 0, last_failure_at = NULL`, int64(s.NextID()), int64(params.UserID), params.Endpoint, params.P256dh, params.Auth, params.UserAgent, s.Now(), ) if err != nil { return nil, mapError(err) } return s.GetPushSubscriptionByEndpoint(ctx, params.Endpoint) } // GetPushSubscriptionByEndpoint возвращает подписку по эндпоинту. func (s *Store) GetPushSubscriptionByEndpoint(ctx context.Context, endpoint string) (*PushSubscription, error) { row := s.reader.QueryRowContext(ctx, `SELECT `+pushSubscriptionColumns+` FROM push_subscriptions WHERE endpoint = ?`, endpoint) return scanPushSubscription(row) } // ListPushSubscriptions перечисляет подписки пользователя (свежие — первыми). func (s *Store) ListPushSubscriptions(ctx context.Context, userID uint64) ([]PushSubscription, error) { rows, err := s.reader.QueryContext(ctx, `SELECT `+pushSubscriptionColumns+` FROM push_subscriptions WHERE user_id = ? ORDER BY created_at DESC`, int64(userID)) if err != nil { return nil, err } defer rows.Close() subscriptions := make([]PushSubscription, 0, 4) for rows.Next() { subscription, err := scanPushSubscription(rows) if err != nil { return nil, err } subscriptions = append(subscriptions, *subscription) } return subscriptions, rows.Err() } // CountPushSubscriptions считает подписки пользователя: лимит устройств. func (s *Store) CountPushSubscriptions(ctx context.Context, userID uint64) (int, error) { var count int if err := s.reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM push_subscriptions WHERE user_id = ?`, int64(userID)).Scan(&count); err != nil { return 0, mapError(err) } return count, nil } // DeletePushSubscription удаляет подписку по эндпоинту. Если эндпоинт пуст — // удаляются все подписки пользователя (отписка «со всех устройств»). func (s *Store) DeletePushSubscription(ctx context.Context, userID uint64, endpoint string) (int64, error) { query := `DELETE FROM push_subscriptions WHERE user_id = ?` args := []any{int64(userID)} if endpoint != "" { query += ` AND endpoint = ?` args = append(args, endpoint) } result, err := s.writer.ExecContext(ctx, query, args...) if err != nil { return 0, mapError(err) } return result.RowsAffected() } // DeletePushSubscriptionByID удаляет подписку по идентификатору: так убираются // записи, признанные мёртвыми (push-сервис ответил 404/410). func (s *Store) DeletePushSubscriptionByID(ctx context.Context, id uint64) error { _, err := s.writer.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE id = ?`, int64(id)) return mapError(err) } // TouchPushSubscription отмечает успешную доставку. func (s *Store) TouchPushSubscription(ctx context.Context, id uint64) error { _, err := s.writer.ExecContext(ctx, `UPDATE push_subscriptions SET last_success_at = ?, failure_count = 0 WHERE id = ?`, s.Now(), int64(id)) return mapError(err) } // FailPushSubscription отмечает неудачную доставку и возвращает новое число // подряд идущих неудач: по нему HTTP-слой решает, удалять ли подписку. func (s *Store) FailPushSubscription(ctx context.Context, id uint64) (int, error) { if _, err := s.writer.ExecContext(ctx, `UPDATE push_subscriptions SET failure_count = failure_count + 1, last_failure_at = ? WHERE id = ?`, s.Now(), int64(id)); err != nil { return 0, mapError(err) } var count int if err := s.reader.QueryRowContext(ctx, `SELECT failure_count FROM push_subscriptions WHERE id = ?`, int64(id)).Scan(&count); err != nil { return 0, mapError(err) } return count, nil } // DeleteStalePushSubscriptions убирает подписки, которые давно не доставлялись: // молчащие эндпоинты не должны копиться вечно (AGENT.md 6.4). func (s *Store) DeleteStalePushSubscriptions(ctx context.Context, before time.Time, maxFailures int) (int64, error) { result, err := s.writer.ExecContext(ctx, ` DELETE FROM push_subscriptions WHERE failure_count >= ? AND COALESCE(last_failure_at, created_at) <= ?`, maxFailures, s.Timestamp(before)) if err != nil { return 0, mapError(err) } return result.RowsAffected() } // DeletePushSubscriptionsForUser убирает все подписки пользователя: вызывается // при удалении аккаунта (каскад в БД страхует, но так понятнее по коду). func (s *Store) DeletePushSubscriptionsForUser(ctx context.Context, userID uint64) (int64, error) { result, err := s.writer.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE user_id = ?`, int64(userID)) if err != nil { return 0, mapError(err) } return result.RowsAffected() } func scanPushSubscription(scanner interface{ Scan(...any) error }) (*PushSubscription, error) { var ( subscription PushSubscription createdAt string lastSuccessAt sql.NullString lastFailureAt sql.NullString ) err := scanner.Scan( &subscription.ID, &subscription.UserID, &subscription.Endpoint, &subscription.P256dh, &subscription.Auth, &subscription.UserAgent, &createdAt, &lastSuccessAt, &lastFailureAt, &subscription.FailureCount, ) if err != nil { return nil, mapError(err) } subscription.CreatedAt = parseTimestamp(createdAt) if lastSuccessAt.Valid { value := parseTimestamp(lastSuccessAt.String) subscription.LastSuccessAt = &value } if lastFailureAt.Valid { value := parseTimestamp(lastFailureAt.String) subscription.LastFailureAt = &value } return &subscription, nil }