init
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Subscription is one alarm.subscriptions row: a device that receives the alarm.
|
||||
type Subscription struct {
|
||||
ID uuid.UUID
|
||||
AlertID uuid.UUID
|
||||
SubscriberUserID uuid.UUID
|
||||
DeviceID string
|
||||
DeviceLabel *string
|
||||
Platform *string
|
||||
Enabled bool
|
||||
LastSeenAt *time.Time
|
||||
Created time.Time
|
||||
}
|
||||
|
||||
const subscriptionColumns = `id, alert_id, subscriber_user_id, device_id, device_label,
|
||||
platform, enabled, last_seen_at, created`
|
||||
|
||||
func scanSubscription(row pgx.Row) (*Subscription, error) {
|
||||
var s Subscription
|
||||
err := row.Scan(&s.ID, &s.AlertID, &s.SubscriberUserID, &s.DeviceID, &s.DeviceLabel,
|
||||
&s.Platform, &s.Enabled, &s.LastSeenAt, &s.Created)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// CreateSubscription inserts a subscription. A duplicate
|
||||
// (alert_id, subscriber_user_id, device_id) is reported as ErrConflict, which
|
||||
// the join action turns into the ERR_ALERT_ALREADY_JOINED race fallback — the
|
||||
// sequential re-scan path is handled by the caller's lookup and never reaches
|
||||
// this error.
|
||||
func CreateSubscription(ctx context.Context, s *Subscription) (*Subscription, error) {
|
||||
const q = `INSERT INTO alarm.subscriptions
|
||||
(alert_id, subscriber_user_id, device_id, device_label, platform)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING ` + subscriptionColumns
|
||||
row, err := scanSubscription(Pool().QueryRow(ctx, q,
|
||||
s.AlertID, s.SubscriberUserID, s.DeviceID, s.DeviceLabel, s.Platform))
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
// GetSubscriptionByDevice is the idempotency lookup for joinAlert.
|
||||
func GetSubscriptionByDevice(ctx context.Context, alertID, userID uuid.UUID, deviceID string) (*Subscription, error) {
|
||||
const q = `SELECT ` + subscriptionColumns + `
|
||||
FROM alarm.subscriptions
|
||||
WHERE alert_id = $1 AND subscriber_user_id = $2 AND device_id = $3`
|
||||
return scanSubscription(Pool().QueryRow(ctx, q, alertID, userID, deviceID))
|
||||
}
|
||||
|
||||
// GetSubscription fetches one subscription scoped to its alert.
|
||||
func GetSubscription(ctx context.Context, alertID, subscriptionID uuid.UUID) (*Subscription, error) {
|
||||
const q = `SELECT ` + subscriptionColumns + `
|
||||
FROM alarm.subscriptions WHERE alert_id = $1 AND id = $2`
|
||||
return scanSubscription(Pool().QueryRow(ctx, q, alertID, subscriptionID))
|
||||
}
|
||||
|
||||
// ListSubscriptions returns every device subscribed to an alert, oldest first.
|
||||
func ListSubscriptions(ctx context.Context, alertID uuid.UUID) ([]Subscription, error) {
|
||||
const q = `SELECT ` + subscriptionColumns + `
|
||||
FROM alarm.subscriptions WHERE alert_id = $1 ORDER BY created, id`
|
||||
rows, err := Pool().Query(ctx, q, alertID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]Subscription, 0, 4)
|
||||
for rows.Next() {
|
||||
var s Subscription
|
||||
if err := rows.Scan(&s.ID, &s.AlertID, &s.SubscriberUserID, &s.DeviceID, &s.DeviceLabel,
|
||||
&s.Platform, &s.Enabled, &s.LastSeenAt, &s.Created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteSubscription removes one subscription. ErrNotFound when it was already
|
||||
// gone, so a repeated removal is visible to the caller.
|
||||
func DeleteSubscription(ctx context.Context, alertID, subscriptionID uuid.UUID) error {
|
||||
tag, err := Pool().Exec(ctx,
|
||||
`DELETE FROM alarm.subscriptions WHERE alert_id = $1 AND id = $2`, alertID, subscriptionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsSubscriber reports whether the user has at least one subscription row on
|
||||
// the alert. It is the authorisation check behind getAlert/alarmStatus.
|
||||
func IsSubscriber(ctx context.Context, alertID, userID uuid.UUID) (bool, error) {
|
||||
var exists bool
|
||||
err := Pool().QueryRow(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM alarm.subscriptions WHERE alert_id = $1 AND subscriber_user_id = $2)`,
|
||||
alertID, userID).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// UserSubscriptionIDs lists the user's subscription ids on an alert, so a WS
|
||||
// connection can be tied to the right device rows.
|
||||
func UserSubscriptionIDs(ctx context.Context, alertID, userID uuid.UUID) ([]uuid.UUID, error) {
|
||||
rows, err := Pool().Query(ctx,
|
||||
`SELECT id FROM alarm.subscriptions WHERE alert_id = $1 AND subscriber_user_id = $2 ORDER BY created`,
|
||||
alertID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []uuid.UUID
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// TouchSubscription stamps last_seen_at. It is called on WS connect and on
|
||||
// every heartbeat; a no-op update (the row vanished mid-connection) is not an
|
||||
// error worth propagating.
|
||||
func TouchSubscription(ctx context.Context, subscriptionID uuid.UUID) error {
|
||||
_, err := Pool().Exec(ctx,
|
||||
`UPDATE alarm.subscriptions SET last_seen_at = NOW() WHERE id = $1`, subscriptionID)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user