358 lines
12 KiB
Go
358 lines
12 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"rmm-openwrt/server/internal/model"
|
|
)
|
|
|
|
func DefaultNotificationSettings(userID string) model.NotificationSettings {
|
|
return model.NotificationSettings{
|
|
UserID: userID,
|
|
NotifyWarning: true,
|
|
NotifyCritical: true,
|
|
NotifyResolved: true,
|
|
MemoryThresholdPercent: 85,
|
|
DiskThresholdPercent: 85,
|
|
PacketLossPercent: 20,
|
|
LatencyThresholdMS: 200,
|
|
}
|
|
}
|
|
|
|
func (s *Store) GetNotificationSettings(ctx context.Context, userID string) (model.NotificationSettings, bool, error) {
|
|
settings := DefaultNotificationSettings(userID)
|
|
var emailEnabled, telegramEnabled, notifyWarning, notifyCritical, notifyResolved int
|
|
var createdAt, updatedAt string
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT email_enabled, telegram_enabled, telegram_chat_id, notify_warning, notify_critical, notify_resolved,
|
|
memory_threshold_percent, disk_threshold_percent, packet_loss_percent, latency_threshold_ms,
|
|
repeat_minutes, created_at, updated_at
|
|
FROM notification_settings WHERE user_id = ?
|
|
`, userID).Scan(
|
|
&emailEnabled, &telegramEnabled, &settings.TelegramChatID, ¬ifyWarning, ¬ifyCritical, ¬ifyResolved,
|
|
&settings.MemoryThresholdPercent, &settings.DiskThresholdPercent, &settings.PacketLossPercent,
|
|
&settings.LatencyThresholdMS, &settings.RepeatMinutes, &createdAt, &updatedAt,
|
|
)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return settings, false, nil
|
|
}
|
|
if err != nil {
|
|
return model.NotificationSettings{}, false, err
|
|
}
|
|
settings.Configured = true
|
|
settings.EmailEnabled = emailEnabled != 0
|
|
settings.TelegramEnabled = telegramEnabled != 0
|
|
settings.NotifyWarning = notifyWarning != 0
|
|
settings.NotifyCritical = notifyCritical != 0
|
|
settings.NotifyResolved = notifyResolved != 0
|
|
settings.CreatedAt = parseTime(createdAt)
|
|
settings.UpdatedAt = parseTime(updatedAt)
|
|
return settings, true, nil
|
|
}
|
|
|
|
func (s *Store) UpsertNotificationSettings(ctx context.Context, settings model.NotificationSettings) (model.NotificationSettings, error) {
|
|
now := nowText()
|
|
_, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO notification_settings (
|
|
user_id, email_enabled, telegram_enabled, telegram_chat_id, notify_warning, notify_critical,
|
|
notify_resolved, memory_threshold_percent, disk_threshold_percent, packet_loss_percent,
|
|
latency_threshold_ms, repeat_minutes, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(user_id) DO UPDATE SET
|
|
email_enabled = excluded.email_enabled,
|
|
telegram_enabled = excluded.telegram_enabled,
|
|
telegram_chat_id = excluded.telegram_chat_id,
|
|
notify_warning = excluded.notify_warning,
|
|
notify_critical = excluded.notify_critical,
|
|
notify_resolved = excluded.notify_resolved,
|
|
memory_threshold_percent = excluded.memory_threshold_percent,
|
|
disk_threshold_percent = excluded.disk_threshold_percent,
|
|
packet_loss_percent = excluded.packet_loss_percent,
|
|
latency_threshold_ms = excluded.latency_threshold_ms,
|
|
repeat_minutes = excluded.repeat_minutes,
|
|
updated_at = excluded.updated_at
|
|
`, settings.UserID, boolInt(settings.EmailEnabled), boolInt(settings.TelegramEnabled), strings.TrimSpace(settings.TelegramChatID),
|
|
boolInt(settings.NotifyWarning), boolInt(settings.NotifyCritical), boolInt(settings.NotifyResolved),
|
|
settings.MemoryThresholdPercent, settings.DiskThresholdPercent, settings.PacketLossPercent,
|
|
settings.LatencyThresholdMS, settings.RepeatMinutes, now, now)
|
|
if err != nil {
|
|
return model.NotificationSettings{}, err
|
|
}
|
|
stored, _, err := s.GetNotificationSettings(ctx, settings.UserID)
|
|
return stored, err
|
|
}
|
|
|
|
func (s *Store) CreateNotificationDelivery(ctx context.Context, delivery model.NotificationDelivery, dedupeKey string) (model.NotificationDelivery, bool, error) {
|
|
id, err := randomID("ntf")
|
|
if err != nil {
|
|
return model.NotificationDelivery{}, false, err
|
|
}
|
|
now := nowText()
|
|
maxAttempts := delivery.MaxAttempts
|
|
if maxAttempts <= 0 {
|
|
maxAttempts = 5
|
|
}
|
|
var deviceID any
|
|
if strings.TrimSpace(delivery.DeviceID) != "" {
|
|
deviceID = delivery.DeviceID
|
|
}
|
|
res, err := s.db.ExecContext(ctx, `
|
|
INSERT OR IGNORE INTO notification_deliveries (
|
|
id, user_id, device_id, alert_id, dedupe_key, event, channel, status, title, body,
|
|
destination, destination_masked, error, attempt_count, max_attempts, created_at,
|
|
next_attempt_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?, ?, '', 0, ?, ?, ?, ?)
|
|
`, id, delivery.UserID, deviceID, delivery.AlertID, dedupeKey, delivery.Event, delivery.Channel,
|
|
delivery.Title, delivery.Body, delivery.Destination, delivery.DestinationMasked, maxAttempts, now, now, now)
|
|
if err != nil {
|
|
return model.NotificationDelivery{}, false, err
|
|
}
|
|
inserted, _ := res.RowsAffected()
|
|
if inserted == 0 {
|
|
return model.NotificationDelivery{}, false, nil
|
|
}
|
|
delivery.ID = id
|
|
delivery.Status = "queued"
|
|
delivery.MaxAttempts = maxAttempts
|
|
delivery.CreatedAt = parseTime(now)
|
|
nextAttemptAt := parseTime(now)
|
|
delivery.NextAttemptAt = &nextAttemptAt
|
|
return delivery, true, nil
|
|
}
|
|
|
|
func (s *Store) CompleteNotificationDelivery(ctx context.Context, id, status, errorMessage string, nextAttemptAt *time.Time) error {
|
|
status = strings.TrimSpace(status)
|
|
if status != "sent" && status != "retry" && status != "dead_letter" {
|
|
return errors.New("invalid notification delivery status")
|
|
}
|
|
if status == "retry" && nextAttemptAt == nil {
|
|
return errors.New("retry notification delivery requires next attempt time")
|
|
}
|
|
now := nowText()
|
|
var sentAt any
|
|
var nextAttempt any
|
|
if status == "sent" {
|
|
sentAt = now
|
|
} else if nextAttemptAt != nil {
|
|
nextAttempt = notificationTimeText(nextAttemptAt.UTC())
|
|
}
|
|
res, err := s.db.ExecContext(ctx, `
|
|
UPDATE notification_deliveries
|
|
SET status = ?, error = ?, sent_at = ?, next_attempt_at = COALESCE(?, ''),
|
|
lease_expires_at = NULL, updated_at = ?
|
|
WHERE id = ? AND status = 'sending'
|
|
`, status, strings.TrimSpace(errorMessage), sentAt, nextAttempt, now, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
updated, err := res.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if updated == 0 {
|
|
return errors.New("notification delivery is not claimed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) ClaimNotificationDelivery(ctx context.Context, id string, now time.Time, leaseDuration time.Duration) (model.NotificationDelivery, bool, error) {
|
|
deliveries, err := s.claimNotificationDeliveries(ctx, strings.TrimSpace(id), now, leaseDuration, 1)
|
|
if err != nil || len(deliveries) == 0 {
|
|
return model.NotificationDelivery{}, false, err
|
|
}
|
|
return deliveries[0], true, nil
|
|
}
|
|
|
|
func (s *Store) ClaimNotificationDeliveries(ctx context.Context, now time.Time, leaseDuration time.Duration, limit int) ([]model.NotificationDelivery, error) {
|
|
return s.claimNotificationDeliveries(ctx, "", now, leaseDuration, limit)
|
|
}
|
|
|
|
func (s *Store) claimNotificationDeliveries(ctx context.Context, onlyID string, now time.Time, leaseDuration time.Duration, limit int) ([]model.NotificationDelivery, error) {
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 10
|
|
}
|
|
if leaseDuration <= 0 {
|
|
leaseDuration = 3 * time.Minute
|
|
}
|
|
now = now.UTC()
|
|
nowValue := notificationTimeText(now)
|
|
leaseValue := notificationTimeText(now.Add(leaseDuration))
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err := tx.ExecContext(ctx, `
|
|
UPDATE notification_deliveries
|
|
SET status = 'dead_letter', error = CASE WHEN error = '' THEN 'Попытки доставки исчерпаны. Проверьте настройки канала.' ELSE error END,
|
|
next_attempt_at = '', lease_expires_at = NULL, updated_at = ?
|
|
WHERE status IN ('retry', 'sending') AND attempt_count >= max_attempts
|
|
AND (status = 'retry' OR lease_expires_at IS NULL OR lease_expires_at <= ?)
|
|
`, nowValue, nowValue); err != nil {
|
|
return nil, err
|
|
}
|
|
query := `
|
|
SELECT id
|
|
FROM notification_deliveries
|
|
WHERE attempt_count < max_attempts
|
|
AND ((status IN ('queued', 'retry') AND (next_attempt_at = '' OR next_attempt_at <= ?))
|
|
OR (status = 'sending' AND lease_expires_at IS NOT NULL AND lease_expires_at <= ?))`
|
|
args := []any{nowValue, nowValue}
|
|
if onlyID != "" {
|
|
query += ` AND id = ?`
|
|
args = append(args, onlyID)
|
|
}
|
|
query += ` ORDER BY created_at ASC LIMIT ?`
|
|
args = append(args, limit)
|
|
rows, err := tx.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ids := make([]string, 0, limit)
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
rows.Close()
|
|
return nil, err
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
claimed := make([]model.NotificationDelivery, 0, len(ids))
|
|
for _, id := range ids {
|
|
res, err := tx.ExecContext(ctx, `
|
|
UPDATE notification_deliveries
|
|
SET status = 'sending', attempt_count = attempt_count + 1, last_attempt_at = ?,
|
|
lease_expires_at = ?, next_attempt_at = '', updated_at = ?
|
|
WHERE id = ? AND attempt_count < max_attempts
|
|
AND ((status IN ('queued', 'retry') AND (next_attempt_at = '' OR next_attempt_at <= ?))
|
|
OR (status = 'sending' AND lease_expires_at IS NOT NULL AND lease_expires_at <= ?))
|
|
`, nowValue, leaseValue, nowValue, id, nowValue, nowValue)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
updated, err := res.RowsAffected()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if updated == 0 {
|
|
continue
|
|
}
|
|
delivery, err := scanNotificationDelivery(tx.QueryRowContext(ctx, notificationDeliverySelect+` WHERE id = ?`, id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
claimed = append(claimed, delivery)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, err
|
|
}
|
|
return claimed, nil
|
|
}
|
|
|
|
func (s *Store) PurgeNotificationDeliveriesBefore(ctx context.Context, cutoff time.Time) (int64, error) {
|
|
res, err := s.db.ExecContext(ctx, `
|
|
DELETE FROM notification_deliveries
|
|
WHERE status IN ('sent', 'dead_letter', 'failed') AND updated_at < ?
|
|
`, notificationTimeText(cutoff.UTC()))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return res.RowsAffected()
|
|
}
|
|
|
|
const notificationDeliverySelect = `
|
|
SELECT id, user_id, COALESCE(device_id, ''), alert_id, event, channel, status, title, body,
|
|
destination, destination_masked, error, attempt_count, max_attempts, created_at,
|
|
last_attempt_at, next_attempt_at, sent_at
|
|
FROM notification_deliveries`
|
|
|
|
func (s *Store) LatestNotificationDelivery(ctx context.Context, userID, alertID, channel string) (model.NotificationDelivery, bool, error) {
|
|
row := s.db.QueryRowContext(ctx, notificationDeliverySelect+`
|
|
WHERE user_id = ? AND alert_id = ? AND channel = ? AND event IN ('active', 'repeat')
|
|
ORDER BY created_at DESC LIMIT 1
|
|
`, userID, alertID, channel)
|
|
delivery, err := scanNotificationDelivery(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return model.NotificationDelivery{}, false, nil
|
|
}
|
|
return delivery, err == nil, err
|
|
}
|
|
|
|
func (s *Store) ListNotificationDeliveries(ctx context.Context, opts NotificationListOptions) ([]model.NotificationDelivery, error) {
|
|
limit := opts.Limit
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
rows, err := s.db.QueryContext(ctx, notificationDeliverySelect+`
|
|
WHERE user_id = ?
|
|
ORDER BY created_at DESC LIMIT ?
|
|
`, opts.UserID, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
deliveries := make([]model.NotificationDelivery, 0)
|
|
for rows.Next() {
|
|
delivery, err := scanNotificationDelivery(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
deliveries = append(deliveries, delivery)
|
|
}
|
|
return deliveries, rows.Err()
|
|
}
|
|
|
|
type notificationScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanNotificationDelivery(scanner notificationScanner) (model.NotificationDelivery, error) {
|
|
var delivery model.NotificationDelivery
|
|
var createdAt string
|
|
var lastAttemptAt, nextAttemptAt, sentAt sql.NullString
|
|
err := scanner.Scan(
|
|
&delivery.ID, &delivery.UserID, &delivery.DeviceID, &delivery.AlertID, &delivery.Event,
|
|
&delivery.Channel, &delivery.Status, &delivery.Title, &delivery.Body, &delivery.Destination,
|
|
&delivery.DestinationMasked, &delivery.Error, &delivery.AttemptCount, &delivery.MaxAttempts,
|
|
&createdAt, &lastAttemptAt, &nextAttemptAt, &sentAt,
|
|
)
|
|
if err != nil {
|
|
return model.NotificationDelivery{}, err
|
|
}
|
|
delivery.CreatedAt = parseTime(createdAt)
|
|
if lastAttemptAt.Valid && lastAttemptAt.String != "" {
|
|
value := parseTime(lastAttemptAt.String)
|
|
delivery.LastAttemptAt = &value
|
|
}
|
|
if nextAttemptAt.Valid && nextAttemptAt.String != "" {
|
|
value := parseTime(nextAttemptAt.String)
|
|
delivery.NextAttemptAt = &value
|
|
}
|
|
if sentAt.Valid {
|
|
value := parseTime(sentAt.String)
|
|
delivery.SentAt = &value
|
|
}
|
|
return delivery, nil
|
|
}
|
|
|
|
func boolInt(value bool) int {
|
|
if value {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func notificationTimeText(value time.Time) string {
|
|
return value.UTC().Format(time.RFC3339Nano)
|
|
}
|