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:
@@ -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 ===
|
||||
|
||||
Reference in New Issue
Block a user