mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-06 08:01:24 +00:00
256 lines
9.4 KiB
Go
256 lines
9.4 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/warmbly/warmbly/internal/config"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
type NotificationRepository interface {
|
|
GetPreferences(ctx context.Context, userID uuid.UUID) (*models.NotificationPreferences, error)
|
|
UpdatePreferences(ctx context.Context, userID uuid.UUID, prefs *models.NotificationPreferences) error
|
|
Create(ctx context.Context, n *models.Notification) (*models.Notification, error)
|
|
List(ctx context.Context, userID uuid.UUID, limit int, unreadOnly bool) ([]models.Notification, error)
|
|
CountUnread(ctx context.Context, userID uuid.UUID) (int, error)
|
|
MarkRead(ctx context.Context, userID, notifID uuid.UUID) error
|
|
MarkAllRead(ctx context.Context, userID uuid.UUID) error
|
|
|
|
// ClaimDueEmails atomically claims every pending email row belonging to a
|
|
// user with at least one due row (so all their pending mail bundles into
|
|
// one digest) or sharing an org group with a due row (so a shared event
|
|
// coalesces into one email even when its recipients digest on different
|
|
// cadences). Safe across replicas: the claim is an UPDATE guarded by
|
|
// FOR UPDATE SKIP LOCKED, and stale claims from crashed flushes recover
|
|
// back to pending first.
|
|
ClaimDueEmails(ctx context.Context) ([]models.Notification, error)
|
|
// MarkEmailed settles claimed rows as sent. On sent rows email_due_at
|
|
// becomes the send time, which is what the daily budget counts.
|
|
MarkEmailed(ctx context.Context, ids []uuid.UUID) error
|
|
// SkipEmails settles claimed rows as skipped (dropped recipient, spent
|
|
// budget); the in-app feed remains the record.
|
|
SkipEmails(ctx context.Context, ids []uuid.UUID) error
|
|
// CountEmailedSince counts notification emails delivered to a user after
|
|
// since — the rolling daily budget check.
|
|
CountEmailedSince(ctx context.Context, userID uuid.UUID, since time.Time) (int, error)
|
|
// RequeueEmails returns claimed rows to pending after a send failure,
|
|
// with a retry delay; rows that already burned their attempts are
|
|
// dropped to skipped instead of retrying forever.
|
|
RequeueEmails(ctx context.Context, ids []uuid.UUID, delay time.Duration, maxAttempts int) error
|
|
}
|
|
|
|
type notificationRepository struct {
|
|
db *pgxpool.Pool
|
|
}
|
|
|
|
func NewNotificationRepository(db *pgxpool.Pool) NotificationRepository {
|
|
return ¬ificationRepository{db: db}
|
|
}
|
|
|
|
// GetPreferences returns the user's prefs merged over the defaults (stored keys
|
|
// override defaults; absent keys keep their default — forward-compatible).
|
|
func (r *notificationRepository) GetPreferences(ctx context.Context, userID uuid.UUID) (*models.NotificationPreferences, error) {
|
|
prefs := models.DefaultNotificationPreferences()
|
|
var raw []byte
|
|
err := r.db.QueryRow(ctx, `SELECT notification_preferences FROM users WHERE id = $1`, userID).Scan(&raw)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return &prefs, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
if len(raw) > 0 {
|
|
_ = json.Unmarshal(raw, &prefs)
|
|
}
|
|
// Absent/zero (older blobs) takes the default; out-of-range clamps.
|
|
if prefs.EmailDigestMinutes == 0 {
|
|
prefs.EmailDigestMinutes = config.NotificationEmailWindowDefaultMinutes
|
|
}
|
|
prefs.EmailDigestMinutes = min(
|
|
max(prefs.EmailDigestMinutes, config.NotificationEmailWindowMinMinutes),
|
|
config.NotificationEmailWindowMaxMinutes)
|
|
return &prefs, nil
|
|
}
|
|
|
|
func (r *notificationRepository) UpdatePreferences(ctx context.Context, userID uuid.UUID, prefs *models.NotificationPreferences) error {
|
|
raw, err := json.Marshal(prefs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = r.db.Exec(ctx, `UPDATE users SET notification_preferences = $2 WHERE id = $1`, userID, raw)
|
|
return err
|
|
}
|
|
|
|
func (r *notificationRepository) Create(ctx context.Context, n *models.Notification) (*models.Notification, error) {
|
|
if n.ID == uuid.Nil {
|
|
n.ID = uuid.New()
|
|
}
|
|
meta, _ := json.Marshal(n.Metadata)
|
|
if len(meta) == 0 {
|
|
meta = []byte("{}")
|
|
}
|
|
if n.EmailState == "" {
|
|
n.EmailState = "none"
|
|
}
|
|
var groupKey *string
|
|
if n.GroupKey != "" {
|
|
groupKey = &n.GroupKey
|
|
}
|
|
err := r.db.QueryRow(ctx, `
|
|
INSERT INTO notifications (id, user_id, organization_id, category, title, body, link, metadata,
|
|
group_key, email_state, email_due_at, read_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, CASE WHEN $12 THEN now() END)
|
|
RETURNING created_at`,
|
|
n.ID, n.UserID, n.OrganizationID, n.Category, n.Title, n.Body, n.Link, meta,
|
|
groupKey, n.EmailState, n.EmailDueAt, n.PreRead).Scan(&n.CreatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func (r *notificationRepository) List(ctx context.Context, userID uuid.UUID, limit int, unreadOnly bool) ([]models.Notification, error) {
|
|
if limit <= 0 || limit > 100 {
|
|
limit = 50
|
|
}
|
|
q := `SELECT id, user_id, organization_id, category, title, body, link, metadata, read_at, created_at
|
|
FROM notifications WHERE user_id = $1`
|
|
if unreadOnly {
|
|
q += ` AND read_at IS NULL`
|
|
}
|
|
q += ` ORDER BY created_at DESC LIMIT $2`
|
|
rows, err := r.db.Query(ctx, q, userID, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []models.Notification{}
|
|
for rows.Next() {
|
|
var n models.Notification
|
|
var meta []byte
|
|
if err := rows.Scan(&n.ID, &n.UserID, &n.OrganizationID, &n.Category, &n.Title, &n.Body, &n.Link, &meta, &n.ReadAt, &n.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(meta) > 0 {
|
|
_ = json.Unmarshal(meta, &n.Metadata)
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (r *notificationRepository) CountUnread(ctx context.Context, userID uuid.UUID) (int, error) {
|
|
var c int
|
|
err := r.db.QueryRow(ctx, `SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL`, userID).Scan(&c)
|
|
return c, err
|
|
}
|
|
|
|
// MarkRead also cancels the row's pending email: a notification the user has
|
|
// already seen in-app must never email them later.
|
|
func (r *notificationRepository) MarkRead(ctx context.Context, userID, notifID uuid.UUID) error {
|
|
_, err := r.db.Exec(ctx, `
|
|
UPDATE notifications
|
|
SET read_at = now(),
|
|
email_state = CASE WHEN email_state = 'pending' THEN 'skipped' ELSE email_state END
|
|
WHERE id = $1 AND user_id = $2 AND read_at IS NULL`, notifID, userID)
|
|
return err
|
|
}
|
|
|
|
func (r *notificationRepository) MarkAllRead(ctx context.Context, userID uuid.UUID) error {
|
|
_, err := r.db.Exec(ctx, `
|
|
UPDATE notifications
|
|
SET read_at = now(),
|
|
email_state = CASE WHEN email_state = 'pending' THEN 'skipped' ELSE email_state END
|
|
WHERE user_id = $1 AND read_at IS NULL`, userID)
|
|
return err
|
|
}
|
|
|
|
// ClaimDueEmails flips claimable rows to 'sending' and returns them. While a
|
|
// row is 'sending', email_due_at doubles as the claim timestamp so crashed
|
|
// claims can be recovered back to pending.
|
|
func (r *notificationRepository) ClaimDueEmails(ctx context.Context) ([]models.Notification, error) {
|
|
_, _ = r.db.Exec(ctx, `
|
|
UPDATE notifications SET email_state = 'pending', email_due_at = now()
|
|
WHERE email_state = 'sending' AND email_due_at < now() - interval '10 minutes'`)
|
|
|
|
rows, err := r.db.Query(ctx, `
|
|
UPDATE notifications SET email_state = 'sending', email_due_at = now()
|
|
WHERE id IN (
|
|
SELECT n.id FROM notifications n
|
|
WHERE n.email_state = 'pending' AND (
|
|
n.user_id IN (
|
|
SELECT user_id FROM notifications
|
|
WHERE email_state = 'pending' AND email_due_at <= now())
|
|
OR (n.group_key IS NOT NULL AND n.organization_id IS NOT NULL AND (n.organization_id, n.group_key) IN (
|
|
SELECT organization_id, group_key FROM notifications
|
|
WHERE email_state = 'pending' AND email_due_at <= now()
|
|
AND group_key IS NOT NULL AND organization_id IS NOT NULL))
|
|
)
|
|
FOR UPDATE SKIP LOCKED
|
|
)
|
|
RETURNING id, user_id, organization_id, category, title, body, link,
|
|
COALESCE(group_key, ''), email_attempts, created_at`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []models.Notification{}
|
|
for rows.Next() {
|
|
var n models.Notification
|
|
if err := rows.Scan(&n.ID, &n.UserID, &n.OrganizationID, &n.Category, &n.Title, &n.Body,
|
|
&n.Link, &n.GroupKey, &n.EmailAttempts, &n.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (r *notificationRepository) MarkEmailed(ctx context.Context, ids []uuid.UUID) error {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
_, err := r.db.Exec(ctx, `
|
|
UPDATE notifications SET email_state = 'sent', email_due_at = now()
|
|
WHERE id = ANY($1) AND email_state = 'sending'`, ids)
|
|
return err
|
|
}
|
|
|
|
func (r *notificationRepository) SkipEmails(ctx context.Context, ids []uuid.UUID) error {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
_, err := r.db.Exec(ctx, `
|
|
UPDATE notifications SET email_state = 'skipped', email_due_at = NULL
|
|
WHERE id = ANY($1) AND email_state = 'sending'`, ids)
|
|
return err
|
|
}
|
|
|
|
func (r *notificationRepository) CountEmailedSince(ctx context.Context, userID uuid.UUID, since time.Time) (int, error) {
|
|
var c int
|
|
err := r.db.QueryRow(ctx, `
|
|
SELECT COUNT(*) FROM notifications
|
|
WHERE user_id = $1 AND email_state = 'sent' AND email_due_at >= $2`, userID, since).Scan(&c)
|
|
return c, err
|
|
}
|
|
|
|
func (r *notificationRepository) RequeueEmails(ctx context.Context, ids []uuid.UUID, delay time.Duration, maxAttempts int) error {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
_, err := r.db.Exec(ctx, `
|
|
UPDATE notifications SET
|
|
email_attempts = email_attempts + 1,
|
|
email_state = CASE WHEN email_attempts + 1 >= $3 THEN 'skipped' ELSE 'pending' END,
|
|
email_due_at = CASE WHEN email_attempts + 1 >= $3 THEN NULL ELSE now() + make_interval(secs => $2) END
|
|
WHERE id = ANY($1) AND email_state = 'sending'`, ids, delay.Seconds(), maxAttempts)
|
|
return err
|
|
}
|