Files
bot-pontos/backend/internal/adapters/postgres/notifications.go
2026-10-23 22:31:02 -03:00

180 lines
7.7 KiB
Go

package postgres
import (
"context"
"time"
"botpontos/internal/domain"
)
func notificationCols(p string) string {
return cols(p, "id", "user_id") + ", to_char(" + p + "reference_date, 'YYYY-MM-DD'), " +
cols(p, "type", "scheduled_at", "predicted_at", "status", "attempts", "locked_at", "sent_at",
"telegram_message_id", "error_message", "created_at", "updated_at")
}
func readNotification(row scanner) (domain.Notification, error) {
var n domain.Notification
var typ, status string
if err := row.Scan(&n.ID, &n.UserID, &n.ReferenceDate, &typ, &n.ScheduledAt, &n.PredictedAt, &status,
&n.Attempts, &n.LockedAt, &n.SentAt, &n.TelegramMessageID, &n.ErrorMessage, &n.CreatedAt, &n.UpdatedAt); err != nil {
return n, mapErr(err)
}
n.Type = domain.NotificationType(typ)
n.Status = domain.NotificationStatus(status)
return n, nil
}
func (s *Store) GetNotification(ctx context.Context, id int64) (domain.Notification, error) {
return readNotification(s.pool.QueryRow(ctx, `SELECT `+notificationCols("")+` FROM notifications WHERE id = $1`, id))
}
// FindNotification devolve o lembrete do usuário para o dia e tipo; found=false se não existir.
func (s *Store) FindNotification(ctx context.Context, userID int64, day string, t domain.NotificationType) (domain.Notification, bool, error) {
n, err := readNotification(s.pool.QueryRow(ctx, `
SELECT `+notificationCols("")+` FROM notifications
WHERE user_id = $1 AND reference_date = $2::text::date AND type::text = $3`, userID, day, string(t)))
if err == domain.ErrNotFound {
return n, false, nil
}
return n, err == nil, err
}
// InsertNotification cria o lembrete se ainda não existir um para (usuário, dia, tipo).
// inserted=false devolve o lembrete que já existia, sem alterá-lo.
func (s *Store) InsertNotification(ctx context.Context, n domain.Notification) (domain.Notification, bool, error) {
created, err := readNotification(s.pool.QueryRow(ctx, `
INSERT INTO notifications (user_id, reference_date, type, scheduled_at, predicted_at, status, error_message)
VALUES ($1, $2::text::date, $3::text::notification_type, $4, $5, $6::text::notification_status, $7)
ON CONFLICT (user_id, reference_date, type) DO NOTHING
RETURNING `+notificationCols(""),
n.UserID, n.ReferenceDate, string(n.Type), n.ScheduledAt, n.PredictedAt, string(n.Status), n.ErrorMessage))
if err == domain.ErrNotFound {
existing, _, err := s.FindNotification(ctx, n.UserID, n.ReferenceDate, n.Type)
return existing, false, err
}
return created, err == nil, err
}
// CancelScheduled cancela o lembrete agendado de um tipo no dia.
func (s *Store) CancelScheduled(ctx context.Context, userID int64, day string, t domain.NotificationType) (bool, error) {
tag, err := s.pool.Exec(ctx, `
UPDATE notifications SET status = 'cancelled'
WHERE user_id = $1 AND reference_date = $2::text::date AND type::text = $3 AND status = 'scheduled'`,
userID, day, string(t))
return tag.RowsAffected() > 0, err
}
// CancelScheduledFrom cancela todos os lembretes agendados do usuário a partir do dia.
func (s *Store) CancelScheduledFrom(ctx context.Context, userID int64, day string) (int64, error) {
tag, err := s.pool.Exec(ctx, `
UPDATE notifications SET status = 'cancelled'
WHERE user_id = $1 AND reference_date >= $2::text::date AND status = 'scheduled'`, userID, day)
return tag.RowsAffected(), err
}
// CancelNotification cancela um lembrete específico, só se ainda estiver agendado.
func (s *Store) CancelNotification(ctx context.Context, id int64) (domain.Notification, error) {
return readNotification(s.pool.QueryRow(ctx, `
UPDATE notifications SET status = 'cancelled', error_message = 'cancelado pelo painel'
WHERE id = $1 AND status = 'scheduled'
RETURNING `+notificationCols(""), id))
}
// ExpireOverdue marca como expired o que passou da tolerância de atraso (9.3, etapa 1).
func (s *Store) ExpireOverdue(ctx context.Context, before time.Time) ([]domain.Notification, error) {
rows, err := s.pool.Query(ctx, `
UPDATE notifications SET status = 'expired', error_message = 'atraso acima da tolerância'
WHERE status = 'scheduled' AND scheduled_at < $1
RETURNING `+notificationCols(""), before)
return collect(rows, err, readNotification)
}
// ClaimDue reserva os lembretes vencidos (scheduled → sending) sem duplicidade (6.3, etapa 2).
func (s *Store) ClaimDue(ctx context.Context, now time.Time, limit int) ([]domain.Notification, error) {
rows, err := s.pool.Query(ctx, `
WITH due AS (
SELECT id FROM notifications
WHERE status = 'scheduled' AND scheduled_at <= $1
ORDER BY scheduled_at
LIMIT $2
FOR UPDATE SKIP LOCKED
)
UPDATE notifications n
SET status = 'sending', locked_at = now(), attempts = n.attempts + 1
FROM due WHERE n.id = due.id
RETURNING `+notificationCols("n."), now, limit)
return collect(rows, err, readNotification)
}
// ClaimFailed reserva um lembrete failed para o reenvio manual (9.5). A troca
// de status é atômica: duplo clique ou dois admins resultam num envio só.
func (s *Store) ClaimFailed(ctx context.Context, id int64) (domain.Notification, error) {
return readNotification(s.pool.QueryRow(ctx, `
UPDATE notifications
SET status = 'sending', locked_at = now(), attempts = attempts + 1, error_message = NULL
WHERE id = $1 AND status = 'failed'
RETURNING `+notificationCols(""), id))
}
func (s *Store) MarkSent(ctx context.Context, id, messageID int64, at time.Time) error {
return expectOne(s.pool.Exec(ctx, `
UPDATE notifications SET status = 'sent', sent_at = $3, telegram_message_id = $2, error_message = NULL
WHERE id = $1 AND status = 'sending'`, id, messageID, at))
}
// MarkFinished encerra um lembrete em envio com failed, expired ou cancelled.
func (s *Store) MarkFinished(ctx context.Context, id int64, status domain.NotificationStatus, message string) error {
return expectOne(s.pool.Exec(ctx, `
UPDATE notifications SET status = $2::text::notification_status, error_message = $3
WHERE id = $1 AND status = 'sending'`, id, string(status), message))
}
// FailStuckSending marca como failed o que está em sending há tempo demais (crash no envio).
func (s *Store) FailStuckSending(ctx context.Context, before time.Time) ([]domain.Notification, error) {
rows, err := s.pool.Query(ctx, `
UPDATE notifications SET status = 'failed', error_message = 'envio interrompido (reinício ou travamento)'
WHERE status = 'sending' AND locked_at < $1
RETURNING `+notificationCols(""), before)
return collect(rows, err, readNotification)
}
type NotificationView struct {
Notification domain.Notification
UserName string
}
type NotificationFilter struct {
Day *string
Status *string
Type *string
UserID *int64
Limit int
}
func (s *Store) ListNotifications(ctx context.Context, f NotificationFilter) ([]NotificationView, error) {
if f.Limit <= 0 || f.Limit > 500 {
f.Limit = 200
}
rows, err := s.pool.Query(ctx, `
SELECT `+notificationCols("n.")+`, u.display_name
FROM notifications n JOIN users u ON u.id = n.user_id
WHERE ($1::text IS NULL OR n.reference_date = $1::text::date)
AND ($2::text IS NULL OR n.status::text = $2)
AND ($3::text IS NULL OR n.type::text = $3)
AND ($4::bigint IS NULL OR n.user_id = $4)
ORDER BY n.reference_date DESC, n.scheduled_at DESC
LIMIT $5`, f.Day, f.Status, f.Type, f.UserID, f.Limit)
return collect(rows, err, func(row scanner) (NotificationView, error) {
var v NotificationView
var typ, status string
n := &v.Notification
err := row.Scan(&n.ID, &n.UserID, &n.ReferenceDate, &typ, &n.ScheduledAt, &n.PredictedAt, &status,
&n.Attempts, &n.LockedAt, &n.SentAt, &n.TelegramMessageID, &n.ErrorMessage, &n.CreatedAt, &n.UpdatedAt,
&v.UserName)
n.Type = domain.NotificationType(typ)
n.Status = domain.NotificationStatus(status)
return v, err
})
}