feat: notification enhancement
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Notification delivery channels (notification_prefs.channel values).
|
||||
const (
|
||||
ChannelInApp = "in_app"
|
||||
ChannelEmail = "email"
|
||||
ChannelPush = "push"
|
||||
)
|
||||
|
||||
// PrefKey is the lookup key for a (kind, channel) cell in a DisabledSet.
|
||||
func PrefKey(kind, channel string) string { return kind + "/" + channel }
|
||||
|
||||
// NotificationPrefsRepo stores per-user notification opt-outs: one row per
|
||||
// (kind, channel) the user has turned OFF. Absence of a row means enabled,
|
||||
// so the table only ever holds overrides and new kinds/channels default on.
|
||||
type NotificationPrefsRepo struct{ pg *Postgres }
|
||||
|
||||
func NewNotificationPrefsRepo(pg *Postgres) *NotificationPrefsRepo {
|
||||
return &NotificationPrefsRepo{pg: pg}
|
||||
}
|
||||
|
||||
// DisabledSet returns the user's opt-outs keyed by PrefKey(kind, channel).
|
||||
func (r *NotificationPrefsRepo) DisabledSet(ctx context.Context, userID string) (map[string]struct{}, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT kind, channel FROM notification_prefs
|
||||
WHERE user_id = $1 AND enabled = FALSE`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list notification prefs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]struct{}{}
|
||||
for rows.Next() {
|
||||
var kind, channel string
|
||||
if err := rows.Scan(&kind, &channel); err != nil {
|
||||
return nil, fmt.Errorf("scan notification pref: %w", err)
|
||||
}
|
||||
out[PrefKey(kind, channel)] = struct{}{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Disable records opt-out rows for every kind × channel pair.
|
||||
func (r *NotificationPrefsRepo) Disable(ctx context.Context, userID string, kinds, channels []string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `
|
||||
INSERT INTO notification_prefs (user_id, kind, channel, enabled)
|
||||
SELECT $1, k, c, FALSE
|
||||
FROM unnest($2::text[]) AS k, unnest($3::text[]) AS c
|
||||
ON CONFLICT (user_id, kind, channel)
|
||||
DO UPDATE SET enabled = FALSE, updated_at = now()`,
|
||||
userID, kinds, channels)
|
||||
if err != nil {
|
||||
return fmt.Errorf("disable notification prefs: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enable removes opt-out rows (absence = enabled) for every kind × channel
|
||||
// pair.
|
||||
func (r *NotificationPrefsRepo) Enable(ctx context.Context, userID string, kinds, channels []string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `
|
||||
DELETE FROM notification_prefs
|
||||
WHERE user_id = $1 AND kind = ANY($2::text[]) AND channel = ANY($3::text[])`,
|
||||
userID, kinds, channels)
|
||||
if err != nil {
|
||||
return fmt.Errorf("enable notification prefs: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SyncLegacyColumns recomputes users.like_emails_enabled and
|
||||
// users.sadhna_reminders_enabled from the prefs table, so an old frontend
|
||||
// (or a rollback) reading the legacy columns stays correct. Call after any
|
||||
// prefs write; drop together with the columns once fully rolled out.
|
||||
func (r *NotificationPrefsRepo) SyncLegacyColumns(ctx context.Context, userID string) error {
|
||||
_, err := r.pg.Pool.Exec(ctx, `
|
||||
UPDATE users SET
|
||||
like_emails_enabled = NOT EXISTS (
|
||||
SELECT 1 FROM notification_prefs np
|
||||
WHERE np.user_id = users.id
|
||||
AND np.kind = 'post_like' AND np.channel = 'email'
|
||||
AND np.enabled = FALSE),
|
||||
-- The legacy reminder toggle was all-or-nothing: it reads as OFF
|
||||
-- only when every reminder kind × channel cell is opted out.
|
||||
sadhna_reminders_enabled = (
|
||||
SELECT count(*) FROM notification_prefs np
|
||||
WHERE np.user_id = users.id
|
||||
AND np.kind IN ('chant_reminder', 'quote_reminder', 'chant_incomplete_reminder')
|
||||
AND np.enabled = FALSE) < 9
|
||||
WHERE id = $1`, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sync legacy notification prefs: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -74,8 +74,9 @@ func (r *PushRepo) PruneToMostRecent(ctx context.Context, userID string, keep in
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteByEndpoint removes a subscription (on unsubscribe or when the push
|
||||
// service reports it gone).
|
||||
// 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)
|
||||
@@ -84,3 +85,15 @@ func (r *PushRepo) DeleteByEndpoint(ctx context.Context, endpoint string) error
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -44,30 +44,14 @@ func (r *ReminderRepo) SetTimezone(ctx context.Context, userID, tz string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEnabled returns whether the user wants sādhana reminders.
|
||||
func (r *ReminderRepo) GetEnabled(ctx context.Context, userID string) (bool, error) {
|
||||
var enabled bool
|
||||
err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT sadhna_reminders_enabled FROM users WHERE id = $1`, userID).Scan(&enabled)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("get reminder pref: %w", err)
|
||||
}
|
||||
return enabled, nil
|
||||
}
|
||||
|
||||
// SetEnabled flips the user's reminder opt-in.
|
||||
func (r *ReminderRepo) SetEnabled(ctx context.Context, userID string, enabled bool) error {
|
||||
_, err := r.pg.Pool.Exec(ctx,
|
||||
`UPDATE users SET sadhna_reminders_enabled = $1 WHERE id = $2`, enabled, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set reminder pref: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DueCandidates returns reminder-enabled users who haven't already had BOTH
|
||||
// reminders today, with the data the worker needs to decide. Defaults are
|
||||
// folded in (06:00 chant, 21:30 quote, UTC tz).
|
||||
//
|
||||
// "Reminder-enabled" now reads notification_prefs: a user drops out of the
|
||||
// scan only when every reminder kind × channel cell (3 × 3) is opted out.
|
||||
// Finer-grained opt-outs (e.g. email off, push on) still match here —
|
||||
// SendReminder enforces the per-channel prefs on delivery.
|
||||
func (r *ReminderRepo) DueCandidates(ctx context.Context) ([]ReminderCandidate, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT
|
||||
@@ -92,7 +76,10 @@ func (r *ReminderRepo) DueCandidates(ctx context.Context) ([]ReminderCandidate,
|
||||
COALESCE(u.daily_chanting_rounds, 16),
|
||||
u.last_chant_incomplete_reminder_on
|
||||
FROM users u
|
||||
WHERE u.sadhna_reminders_enabled = TRUE
|
||||
WHERE (SELECT count(*) FROM notification_prefs np
|
||||
WHERE np.user_id = u.id
|
||||
AND np.kind IN ('chant_reminder', 'quote_reminder', 'chant_incomplete_reminder')
|
||||
AND np.enabled = FALSE) < 9
|
||||
AND (
|
||||
u.last_chant_reminder_on IS NULL OR u.last_chant_reminder_on < (now() AT TIME ZONE 'UTC')::date
|
||||
OR u.last_quote_reminder_on IS NULL OR u.last_quote_reminder_on < (now() AT TIME ZONE 'UTC')::date
|
||||
|
||||
@@ -317,7 +317,9 @@ func (r *NotificationRepo) Create(ctx context.Context, userID string, actorID *s
|
||||
|
||||
// ListByUser returns a recipient's notifications, newest first, with the
|
||||
// actor's display fields joined in for rendering (nil for system
|
||||
// notifications or since-deleted actors).
|
||||
// notifications or since-deleted actors). Kinds the user has turned off for
|
||||
// the in-app channel are filtered at read time — the rows still persist (they
|
||||
// back like-aggregation and reappear if the pref is re-enabled).
|
||||
func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit, offset int) ([]model.Notification, error) {
|
||||
rows, err := r.pg.Pool.Query(ctx, `
|
||||
SELECT n.id, n.user_id, n.actor_id, n.kind, n.post_id, n.actor_count,
|
||||
@@ -326,6 +328,10 @@ func (r *NotificationRepo) ListByUser(ctx context.Context, userID string, limit,
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON u.id = n.actor_id
|
||||
WHERE n.user_id = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM notification_prefs np
|
||||
WHERE np.user_id = n.user_id AND np.kind = n.kind
|
||||
AND np.channel = 'in_app' AND np.enabled = FALSE)
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT $2 OFFSET $3`, userID, limit, offset,
|
||||
)
|
||||
@@ -394,32 +400,18 @@ func (r *NotificationRepo) CreateMention(ctx context.Context, userID, actorID, p
|
||||
return nil
|
||||
}
|
||||
|
||||
// LikeEmailsEnabled reports whether the user wants aggregated like emails.
|
||||
func (r *NotificationRepo) LikeEmailsEnabled(ctx context.Context, userID string) (bool, error) {
|
||||
var enabled bool
|
||||
if err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT like_emails_enabled FROM users WHERE id = $1`, userID,
|
||||
).Scan(&enabled); err != nil {
|
||||
return false, fmt.Errorf("get like-email pref: %w", err)
|
||||
}
|
||||
return enabled, nil
|
||||
}
|
||||
|
||||
// SetLikeEmailsEnabled flips the user's like-email opt-in.
|
||||
func (r *NotificationRepo) SetLikeEmailsEnabled(ctx context.Context, userID string, enabled bool) error {
|
||||
if _, err := r.pg.Pool.Exec(ctx,
|
||||
`UPDATE users SET like_emails_enabled = $1 WHERE id = $2`, enabled, userID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("set like-email pref: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountUnread returns how many of a recipient's notifications are unread.
|
||||
// CountUnread returns how many of a recipient's notifications are unread,
|
||||
// excluding kinds the user has turned off for the in-app channel (matching
|
||||
// ListByUser, so the badge never counts rows the feed won't show).
|
||||
func (r *NotificationRepo) CountUnread(ctx context.Context, userID string) (int, error) {
|
||||
var n int
|
||||
err := r.pg.Pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL`, userID,
|
||||
err := r.pg.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM notifications n
|
||||
WHERE n.user_id = $1 AND n.read_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM notification_prefs np
|
||||
WHERE np.user_id = n.user_id AND np.kind = n.kind
|
||||
AND np.channel = 'in_app' AND np.enabled = FALSE)`, userID,
|
||||
).Scan(&n)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count unread notifications: %w", err)
|
||||
|
||||
Reference in New Issue
Block a user