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 ✓
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/application/commands"
|
||||
"apocapoc-api/internal/application/queries"
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/i18n"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type SyncHandlers struct {
|
||||
getSyncChangesHandler *queries.GetSyncChangesHandler
|
||||
applySyncBatchHandler *commands.ApplySyncBatchHandler
|
||||
translator *i18n.Translator
|
||||
}
|
||||
|
||||
func NewSyncHandlers(
|
||||
getSyncChangesHandler *queries.GetSyncChangesHandler,
|
||||
applySyncBatchHandler *commands.ApplySyncBatchHandler,
|
||||
translator *i18n.Translator,
|
||||
) *SyncHandlers {
|
||||
return &SyncHandlers{
|
||||
getSyncChangesHandler: getSyncChangesHandler,
|
||||
applySyncBatchHandler: applySyncBatchHandler,
|
||||
translator: translator,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSyncChanges godoc
|
||||
// @Summary Get sync changes
|
||||
// @Description Get all changes (habits and entries) since a given timestamp for offline sync
|
||||
// @Tags sync
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param since query string true "ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)"
|
||||
// @Success 200 {object} SyncChangesResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /sync/changes [get]
|
||||
func (h *SyncHandlers) GetSyncChanges(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
sinceStr := r.URL.Query().Get("since")
|
||||
if sinceStr == "" {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "missing_since_parameter")
|
||||
return
|
||||
}
|
||||
|
||||
since, err := time.Parse(time.RFC3339, sinceStr)
|
||||
if err != nil {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_since_format")
|
||||
return
|
||||
}
|
||||
|
||||
query := queries.GetSyncChangesQuery{
|
||||
UserID: userID,
|
||||
Since: since,
|
||||
}
|
||||
|
||||
result, err := h.getSyncChangesHandler.Handle(r.Context(), query)
|
||||
if err != nil {
|
||||
if err == errors.ErrInvalidInput {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_input")
|
||||
return
|
||||
}
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "internal_server_error")
|
||||
return
|
||||
}
|
||||
|
||||
response := SyncChangesResponse{
|
||||
Habits: HabitChangesDTO{
|
||||
Created: toHabitDTOs(result.Habits.Created),
|
||||
Updated: toHabitDTOs(result.Habits.Updated),
|
||||
Deleted: result.Habits.Deleted,
|
||||
},
|
||||
Entries: EntryChangesDTO{
|
||||
Created: toHabitEntryDTOs(result.Entries.Created),
|
||||
Updated: toHabitEntryDTOs(result.Entries.Updated),
|
||||
Deleted: result.Entries.Deleted,
|
||||
},
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// ApplySyncBatch godoc
|
||||
// @Summary Apply sync batch
|
||||
// @Description Apply a batch of changes from the client for offline sync (Last-Write-Wins)
|
||||
// @Tags sync
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body SyncBatchRequest true "Sync batch data"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /sync/batch [post]
|
||||
func (h *SyncHandlers) ApplySyncBatch(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req SyncBatchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
|
||||
return
|
||||
}
|
||||
|
||||
habitChanges := commands.HabitBatchChanges{
|
||||
Created: fromHabitDTOs(req.Habits.Created),
|
||||
Updated: fromHabitDTOs(req.Habits.Updated),
|
||||
Deleted: req.Habits.Deleted,
|
||||
}
|
||||
|
||||
entryChanges := commands.EntryBatchChanges{
|
||||
Created: fromHabitEntryDTOs(req.Entries.Created),
|
||||
Updated: fromHabitEntryDTOs(req.Entries.Updated),
|
||||
Deleted: req.Entries.Deleted,
|
||||
}
|
||||
|
||||
cmd := commands.ApplySyncBatchCommand{
|
||||
UserID: userID,
|
||||
Habits: habitChanges,
|
||||
Entries: entryChanges,
|
||||
}
|
||||
|
||||
err := h.applySyncBatchHandler.Handle(r.Context(), cmd)
|
||||
if err != nil {
|
||||
if err == errors.ErrInvalidInput {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_input")
|
||||
return
|
||||
}
|
||||
if err == errors.ErrUnauthorized {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "internal_server_error")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]string{"message": "sync_batch_applied"})
|
||||
}
|
||||
|
||||
func toHabitDTOs(habits []*entities.Habit) []SyncHabitDTO {
|
||||
dtos := make([]SyncHabitDTO, len(habits))
|
||||
for i, h := range habits {
|
||||
dtos[i] = SyncHabitDTO{
|
||||
ID: h.ID,
|
||||
UserID: h.UserID,
|
||||
Name: h.Name,
|
||||
Description: h.Description,
|
||||
Type: h.Type,
|
||||
Frequency: h.Frequency,
|
||||
SpecificDays: h.SpecificDays,
|
||||
SpecificDates: h.SpecificDates,
|
||||
CarryOver: h.CarryOver,
|
||||
IsNegative: h.IsNegative,
|
||||
TargetValue: h.TargetValue,
|
||||
CreatedAt: h.CreatedAt,
|
||||
UpdatedAt: h.UpdatedAt,
|
||||
ArchivedAt: h.ArchivedAt,
|
||||
}
|
||||
}
|
||||
return dtos
|
||||
}
|
||||
|
||||
func fromHabitDTOs(dtos []SyncHabitDTO) []*entities.Habit {
|
||||
habits := make([]*entities.Habit, len(dtos))
|
||||
for i, dto := range dtos {
|
||||
habits[i] = &entities.Habit{
|
||||
ID: dto.ID,
|
||||
UserID: dto.UserID,
|
||||
Name: dto.Name,
|
||||
Description: dto.Description,
|
||||
Type: dto.Type,
|
||||
Frequency: dto.Frequency,
|
||||
SpecificDays: dto.SpecificDays,
|
||||
SpecificDates: dto.SpecificDates,
|
||||
CarryOver: dto.CarryOver,
|
||||
IsNegative: dto.IsNegative,
|
||||
TargetValue: dto.TargetValue,
|
||||
CreatedAt: dto.CreatedAt,
|
||||
UpdatedAt: dto.UpdatedAt,
|
||||
ArchivedAt: dto.ArchivedAt,
|
||||
}
|
||||
}
|
||||
return habits
|
||||
}
|
||||
|
||||
func toHabitEntryDTOs(entries []*entities.HabitEntry) []SyncHabitEntryDTO {
|
||||
dtos := make([]SyncHabitEntryDTO, len(entries))
|
||||
for i, e := range entries {
|
||||
dtos[i] = SyncHabitEntryDTO{
|
||||
ID: e.ID,
|
||||
HabitID: e.HabitID,
|
||||
ScheduledDate: e.ScheduledDate,
|
||||
CompletedAt: e.CompletedAt,
|
||||
Value: e.Value,
|
||||
UpdatedAt: e.UpdatedAt,
|
||||
}
|
||||
}
|
||||
return dtos
|
||||
}
|
||||
|
||||
func fromHabitEntryDTOs(dtos []SyncHabitEntryDTO) []*entities.HabitEntry {
|
||||
entries := make([]*entities.HabitEntry, len(dtos))
|
||||
for i, dto := range dtos {
|
||||
entries[i] = &entities.HabitEntry{
|
||||
ID: dto.ID,
|
||||
HabitID: dto.HabitID,
|
||||
ScheduledDate: dto.ScheduledDate,
|
||||
CompletedAt: dto.CompletedAt,
|
||||
Value: dto.Value,
|
||||
UpdatedAt: dto.UpdatedAt,
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
Reference in New Issue
Block a user