Add webhook logging, pagination, middleware, migrations, and prod hardening

- Webhook event logging repo and subscription webhook idempotency
- Pagination helper (echohelpers) with cursor/offset support
- Request ID and structured logging middleware
- Push client improvements (FCM HTTP v1, better error handling)
- Task model version column, business constraint migrations, targeted indexes
- Expanded categorization chain tests
- Email service and config hardening
- CI workflow updates, .gitignore additions, .env.example updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
treyt
2026-02-24 21:32:09 -06:00
parent 806bd07f80
commit e26116e2cf
50 changed files with 1681 additions and 97 deletions

View File

@@ -1,6 +1,7 @@
package repositories
import (
"errors"
"fmt"
"time"
@@ -11,6 +12,9 @@ import (
"github.com/treytartt/casera-api/internal/task/categorization"
)
// ErrVersionConflict indicates a concurrent modification was detected
var ErrVersionConflict = errors.New("version conflict: task was modified by another request")
// TaskRepository handles database operations for tasks
type TaskRepository struct {
db *gorm.DB
@@ -294,10 +298,39 @@ func (r *TaskRepository) Create(task *models.Task) error {
return r.db.Create(task).Error
}
// Update updates a task
// Uses Omit to exclude associations that shouldn't be updated via Save
// Update updates a task with optimistic locking.
// The update only succeeds if the task's version in the database matches the expected version.
// On success, the local task.Version is incremented to reflect the new version.
func (r *TaskRepository) Update(task *models.Task) error {
return r.db.Omit("Residence", "CreatedBy", "AssignedTo", "Category", "Priority", "Frequency", "ParentTask", "Completions").Save(task).Error
result := r.db.Model(task).
Where("id = ? AND version = ?", task.ID, task.Version).
Omit("Residence", "CreatedBy", "AssignedTo", "Category", "Priority", "Frequency", "ParentTask", "Completions").
Updates(map[string]interface{}{
"title": task.Title,
"description": task.Description,
"category_id": task.CategoryID,
"priority_id": task.PriorityID,
"frequency_id": task.FrequencyID,
"custom_interval_days": task.CustomIntervalDays,
"in_progress": task.InProgress,
"assigned_to_id": task.AssignedToID,
"due_date": task.DueDate,
"next_due_date": task.NextDueDate,
"estimated_cost": task.EstimatedCost,
"actual_cost": task.ActualCost,
"contractor_id": task.ContractorID,
"is_cancelled": task.IsCancelled,
"is_archived": task.IsArchived,
"version": gorm.Expr("version + 1"),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrVersionConflict
}
task.Version++ // Update local copy
return nil
}
// Delete hard-deletes a task
@@ -307,39 +340,89 @@ func (r *TaskRepository) Delete(id uint) error {
// === Task State Operations ===
// MarkInProgress marks a task as in progress
func (r *TaskRepository) MarkInProgress(id uint) error {
return r.db.Model(&models.Task{}).
Where("id = ?", id).
Update("in_progress", true).Error
// MarkInProgress marks a task as in progress with optimistic locking.
func (r *TaskRepository) MarkInProgress(id uint, version int) error {
result := r.db.Model(&models.Task{}).
Where("id = ? AND version = ?", id, version).
Updates(map[string]interface{}{
"in_progress": true,
"version": gorm.Expr("version + 1"),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrVersionConflict
}
return nil
}
// Cancel cancels a task
func (r *TaskRepository) Cancel(id uint) error {
return r.db.Model(&models.Task{}).
Where("id = ?", id).
Update("is_cancelled", true).Error
// Cancel cancels a task with optimistic locking.
func (r *TaskRepository) Cancel(id uint, version int) error {
result := r.db.Model(&models.Task{}).
Where("id = ? AND version = ?", id, version).
Updates(map[string]interface{}{
"is_cancelled": true,
"version": gorm.Expr("version + 1"),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrVersionConflict
}
return nil
}
// Uncancel uncancels a task
func (r *TaskRepository) Uncancel(id uint) error {
return r.db.Model(&models.Task{}).
Where("id = ?", id).
Update("is_cancelled", false).Error
// Uncancel uncancels a task with optimistic locking.
func (r *TaskRepository) Uncancel(id uint, version int) error {
result := r.db.Model(&models.Task{}).
Where("id = ? AND version = ?", id, version).
Updates(map[string]interface{}{
"is_cancelled": false,
"version": gorm.Expr("version + 1"),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrVersionConflict
}
return nil
}
// Archive archives a task
func (r *TaskRepository) Archive(id uint) error {
return r.db.Model(&models.Task{}).
Where("id = ?", id).
Update("is_archived", true).Error
// Archive archives a task with optimistic locking.
func (r *TaskRepository) Archive(id uint, version int) error {
result := r.db.Model(&models.Task{}).
Where("id = ? AND version = ?", id, version).
Updates(map[string]interface{}{
"is_archived": true,
"version": gorm.Expr("version + 1"),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrVersionConflict
}
return nil
}
// Unarchive unarchives a task
func (r *TaskRepository) Unarchive(id uint) error {
return r.db.Model(&models.Task{}).
Where("id = ?", id).
Update("is_archived", false).Error
// Unarchive unarchives a task with optimistic locking.
func (r *TaskRepository) Unarchive(id uint, version int) error {
result := r.db.Model(&models.Task{}).
Where("id = ? AND version = ?", id, version).
Updates(map[string]interface{}{
"is_archived": false,
"version": gorm.Expr("version + 1"),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrVersionConflict
}
return nil
}
// === Kanban Board ===