Files
honeyDueAPI/internal/repositories/reminder_repo.go
T
Trey t bc3da007db
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Wire OpenTelemetry tracing — HTTP, B2, APNs, FCM, asynq, GORM (partial)
Step 1 — OTel SDK: cmd/api and cmd/worker initialize a tracer provider
that exports OTLP/HTTP to obs.88oakapps.com (Jaeger all-in-one). Sampling
is AlwaysSample in dev (DEBUG=true) and TraceIDRatioBased(0.1) in prod,
overridable via OTEL_TRACES_SAMPLER_ARG. Service names are honeydue-api
and honeydue-worker. otelecho.Middleware opens a span per HTTP request.

Step 2 — Manual spans: storage_service.Upload now takes ctx and emits
storage.upload + b2.PutObject spans (size_bytes, key, mime_type, bucket,
result attrs). APNs Send/SendWithCategory and FCM sendOne emit per-token
spans with topic, status_code, reason. Asynq middleware emits
asynq.handle:<task_type> per job with retry/payload attrs and records
asynq_job_duration_seconds.

Step 3 — Database: otelgorm plugin registered in database.Connect, so
any SQL emitted via db.WithContext(ctx) attaches to the request span.
Every repository now exposes WithContext(ctx) *XRepository as the
migration helper. TaskService.ListTasks and GetTasksByResidence are
migrated end-to-end (ctx threaded through handler → service → repo);
remaining services adopt the same pattern incrementally — pre-migration
methods still emit untraced SQL via the unchanged db field.

OBS_TRACES_URL and OBS_INGEST_TOKEN flow from deploy/prod.env →
honeydue-secrets → api+worker Deployments via secretKeyRef (optional).
02-setup-secrets.sh sources them from prod.env on next run; manifests
mark both env vars optional so the deployment rolls without traces if
the secret is absent.

ch15 observability doc now lists what produces spans today vs the
remaining migration work, with the explicit per-method pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:28:05 -05:00

235 lines
7.1 KiB
Go

package repositories
import (
"context"
"time"
"gorm.io/gorm"
"github.com/treytartt/honeydue-api/internal/models"
)
// ReminderRepository handles database operations for task reminder logs
type ReminderRepository struct {
db *gorm.DB
}
// NewReminderRepository creates a new reminder repository
func NewReminderRepository(db *gorm.DB) *ReminderRepository {
return &ReminderRepository{db: db}
}
// HasSentReminder checks if a reminder has already been sent for the given
// task, user, due date, and reminder stage.
func (r *ReminderRepository) HasSentReminder(taskID, userID uint, dueDate time.Time, stage models.ReminderStage) (bool, error) {
// Normalize to date only
dueDateOnly := time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), 0, 0, 0, 0, time.UTC)
var count int64
err := r.db.Model(&models.TaskReminderLog{}).
Where("task_id = ? AND user_id = ? AND due_date = ? AND reminder_stage = ?",
taskID, userID, dueDateOnly, stage).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
// ReminderKey uniquely identifies a reminder that may have been sent.
type ReminderKey struct {
TaskID uint
UserID uint
DueDate time.Time
Stage models.ReminderStage
}
// HasSentReminderBatch checks which reminders from the given list have already been sent.
// Returns a set of indices into the input slice that have already been sent.
// This replaces N individual HasSentReminder calls with a single query.
func (r *ReminderRepository) HasSentReminderBatch(keys []ReminderKey) (map[int]bool, error) {
result := make(map[int]bool)
if len(keys) == 0 {
return result, nil
}
// Build a lookup from (task_id, user_id, due_date, stage) -> index
type normalizedKey struct {
TaskID uint
UserID uint
DueDate string
Stage models.ReminderStage
}
keyToIdx := make(map[normalizedKey][]int, len(keys))
// Collect unique task IDs and user IDs for the WHERE clause
taskIDSet := make(map[uint]bool)
userIDSet := make(map[uint]bool)
for i, k := range keys {
taskIDSet[k.TaskID] = true
userIDSet[k.UserID] = true
dueDateOnly := time.Date(k.DueDate.Year(), k.DueDate.Month(), k.DueDate.Day(), 0, 0, 0, 0, time.UTC)
nk := normalizedKey{
TaskID: k.TaskID,
UserID: k.UserID,
DueDate: dueDateOnly.Format("2006-01-02"),
Stage: k.Stage,
}
keyToIdx[nk] = append(keyToIdx[nk], i)
}
taskIDs := make([]uint, 0, len(taskIDSet))
for id := range taskIDSet {
taskIDs = append(taskIDs, id)
}
userIDs := make([]uint, 0, len(userIDSet))
for id := range userIDSet {
userIDs = append(userIDs, id)
}
// Collect unique stages and due dates for tighter SQL filtering
stageSet := make(map[models.ReminderStage]bool)
dueDateSet := make(map[string]bool)
var minDueDate, maxDueDate time.Time
for _, k := range keys {
stageSet[k.Stage] = true
dueDateOnly := time.Date(k.DueDate.Year(), k.DueDate.Month(), k.DueDate.Day(), 0, 0, 0, 0, time.UTC)
dueDateSet[dueDateOnly.Format("2006-01-02")] = true
if minDueDate.IsZero() || dueDateOnly.Before(minDueDate) {
minDueDate = dueDateOnly
}
if maxDueDate.IsZero() || dueDateOnly.After(maxDueDate) {
maxDueDate = dueDateOnly
}
}
stages := make([]models.ReminderStage, 0, len(stageSet))
for s := range stageSet {
stages = append(stages, s)
}
// Query matching reminder logs with tighter filters to reduce result set.
// Filter on reminder_stage and due_date range in addition to task_id/user_id.
var logs []models.TaskReminderLog
err := r.db.Where(
"task_id IN ? AND user_id IN ? AND reminder_stage IN ? AND due_date >= ? AND due_date <= ?",
taskIDs, userIDs, stages, minDueDate, maxDueDate,
).Find(&logs).Error
if err != nil {
return nil, err
}
// Match returned logs against our key set
for _, l := range logs {
dueDateStr := l.DueDate.Format("2006-01-02")
nk := normalizedKey{
TaskID: l.TaskID,
UserID: l.UserID,
DueDate: dueDateStr,
Stage: l.ReminderStage,
}
if indices, ok := keyToIdx[nk]; ok {
for _, idx := range indices {
result[idx] = true
}
}
}
return result, nil
}
// LogReminder records that a reminder was sent.
// Returns the created log entry or an error if the reminder was already sent
// (unique constraint violation).
func (r *ReminderRepository) LogReminder(taskID, userID uint, dueDate time.Time, stage models.ReminderStage, notificationID *uint) (*models.TaskReminderLog, error) {
// Normalize to date only
dueDateOnly := time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), 0, 0, 0, 0, time.UTC)
log := &models.TaskReminderLog{
TaskID: taskID,
UserID: userID,
DueDate: dueDateOnly,
ReminderStage: stage,
SentAt: time.Now().UTC(),
NotificationID: notificationID,
}
err := r.db.Create(log).Error
if err != nil {
return nil, err
}
return log, nil
}
// GetSentRemindersForTask returns all reminder logs for a specific task and user.
func (r *ReminderRepository) GetSentRemindersForTask(taskID, userID uint) ([]models.TaskReminderLog, error) {
var logs []models.TaskReminderLog
err := r.db.Where("task_id = ? AND user_id = ?", taskID, userID).
Order("sent_at DESC").
Find(&logs).Error
return logs, err
}
// GetSentRemindersForDueDate returns all reminder logs for a specific task,
// user, and due date.
func (r *ReminderRepository) GetSentRemindersForDueDate(taskID, userID uint, dueDate time.Time) ([]models.TaskReminderLog, error) {
dueDateOnly := time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), 0, 0, 0, 0, time.UTC)
var logs []models.TaskReminderLog
err := r.db.Where("task_id = ? AND user_id = ? AND due_date = ?",
taskID, userID, dueDateOnly).
Order("sent_at DESC").
Find(&logs).Error
return logs, err
}
// CleanupOldLogs removes reminder logs older than the specified number of days.
// This helps keep the table from growing indefinitely.
func (r *ReminderRepository) CleanupOldLogs(daysOld int) (int64, error) {
cutoff := time.Now().UTC().AddDate(0, 0, -daysOld)
result := r.db.Where("sent_at < ?", cutoff).
Delete(&models.TaskReminderLog{})
return result.RowsAffected, result.Error
}
// GetRecentReminderStats returns statistics about recent reminders sent.
// Useful for admin/monitoring purposes.
func (r *ReminderRepository) GetRecentReminderStats(sinceHours int) (map[string]int64, error) {
since := time.Now().UTC().Add(-time.Duration(sinceHours) * time.Hour)
stats := make(map[string]int64)
// Count by stage
rows, err := r.db.Model(&models.TaskReminderLog{}).
Select("reminder_stage, COUNT(*) as count").
Where("sent_at >= ?", since).
Group("reminder_stage").
Rows()
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var stage string
var count int64
if err := rows.Scan(&stage, &count); err != nil {
return nil, err
}
stats[stage] = count
}
return stats, nil
}
// WithContext returns a copy of the repository whose underlying *gorm.DB carries
// the supplied context. SQL emitted via this copy gets attached to ctx's trace span
// (when otelgorm is registered) and respects ctx cancellation/deadlines.
func (r *ReminderRepository) WithContext(ctx context.Context) *ReminderRepository {
return &ReminderRepository{db: r.db.WithContext(ctx)}
}