feat: notification enhancement

This commit is contained in:
Krispire Admin
2026-06-11 00:31:35 +05:30
parent cb62adf2de
commit 187bcd1442
11 changed files with 441 additions and 79 deletions
@@ -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
}
+15 -2
View File
@@ -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
}
+9 -22
View File
@@ -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
+17 -25
View File
@@ -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)