bc3da007db
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>
235 lines
7.1 KiB
Go
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)}
|
|
}
|