Files
apocapoc-api/internal/application/commands/apply_sync_batch.go
T
david aa8f7af55d feat: implement offline sync endpoints with Last-Write-Wins strategy
Add comprehensive offline synchronization support for habits and entries:

## Infrastructure (Phase 1)
- Add UpdatedAt and DeletedAt timestamps to Habit and HabitEntry entities
- Implement soft delete with Delete(), Touch(), and IsDeleted() methods
- Create SQL migration with optimized composite indexes for sync queries
- Add GetChangesSince() and SoftDelete() to both repositories
- Update all Find* methods to exclude soft-deleted records
- 13 comprehensive TDD tests for sync repository methods

## HTTP Endpoints (Phase 2)
- GET /api/v1/sync/changes: retrieve all changes since timestamp
- POST /api/v1/sync/batch: apply client changes with conflict resolution
- Implement Last-Write-Wins strategy using UpdatedAt timestamps
- Add authentication and rate limiting (100 req/min)
- Validate user ownership for all sync operations
- 9 tests for sync handlers (3 queries + 6 commands)

## Technical Details
- Composite indexes: (user_id, updated_at) for optimal query performance
- No pagination: atomic sync operations for data consistency
- Upsert behavior: create resources if not found on server
- DTOs with full entity state including timestamps
- Swagger documentation updated for new endpoints

All 220+ tests passing ✓
2025-12-12 00:11:46 +01:00

141 lines
2.9 KiB
Go

package commands
import (
"context"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/errors"
)
type HabitBatchChanges struct {
Created []*entities.Habit
Updated []*entities.Habit
Deleted []string
}
type EntryBatchChanges struct {
Created []*entities.HabitEntry
Updated []*entities.HabitEntry
Deleted []string
}
type ApplySyncBatchCommand struct {
UserID string
Habits HabitBatchChanges
Entries EntryBatchChanges
}
type ApplySyncBatchHandler struct {
habitRepo repositories.HabitRepository
entryRepo repositories.HabitEntryRepository
}
func NewApplySyncBatchHandler(
habitRepo repositories.HabitRepository,
entryRepo repositories.HabitEntryRepository,
) *ApplySyncBatchHandler {
return &ApplySyncBatchHandler{
habitRepo: habitRepo,
entryRepo: entryRepo,
}
}
func (h *ApplySyncBatchHandler) Handle(ctx context.Context, cmd ApplySyncBatchCommand) error {
if cmd.UserID == "" {
return errors.ErrInvalidInput
}
for _, habit := range cmd.Habits.Created {
if habit.UserID != cmd.UserID {
return errors.ErrUnauthorized
}
if err := h.habitRepo.Create(ctx, habit); err != nil {
return err
}
}
for _, habit := range cmd.Habits.Updated {
if habit.UserID != cmd.UserID {
return errors.ErrUnauthorized
}
existing, err := h.habitRepo.FindByID(ctx, habit.ID)
if err != nil {
if err == errors.ErrNotFound {
if err := h.habitRepo.Create(ctx, habit); err != nil {
return err
}
continue
}
return err
}
if existing.UserID != cmd.UserID {
return errors.ErrUnauthorized
}
if shouldApplyUpdate(existing.UpdatedAt, habit.UpdatedAt) {
if err := h.habitRepo.Update(ctx, habit); err != nil {
return err
}
}
}
for _, id := range cmd.Habits.Deleted {
existing, err := h.habitRepo.FindByID(ctx, id)
if err != nil {
if err == errors.ErrNotFound {
continue
}
return err
}
if existing.UserID != cmd.UserID {
return errors.ErrUnauthorized
}
if err := h.habitRepo.SoftDelete(ctx, id); err != nil && err != errors.ErrNotFound {
return err
}
}
for _, entry := range cmd.Entries.Created {
if err := h.entryRepo.Create(ctx, entry); err != nil {
return err
}
}
for _, entry := range cmd.Entries.Updated {
existing, err := h.entryRepo.FindByID(ctx, entry.ID)
if err != nil {
if err == errors.ErrNotFound {
if err := h.entryRepo.Create(ctx, entry); err != nil {
return err
}
continue
}
return err
}
if shouldApplyUpdate(existing.UpdatedAt, entry.UpdatedAt) {
if err := h.entryRepo.Update(ctx, entry); err != nil {
return err
}
}
}
for _, id := range cmd.Entries.Deleted {
if err := h.entryRepo.SoftDelete(ctx, id); err != nil && err != errors.ErrNotFound {
return err
}
}
return nil
}
func shouldApplyUpdate(serverTime, clientTime time.Time) bool {
return clientTime.After(serverTime)
}