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:
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -24,8 +25,8 @@ func (r *HabitEntryRepository) Create(ctx context.Context, entry *entities.Habit
|
||||
entry.ID = uuid.New().String()
|
||||
|
||||
query := `
|
||||
INSERT INTO habit_entries (id, habit_id, scheduled_date, completed_at, value)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO habit_entries (id, habit_id, scheduled_date, completed_at, value, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query,
|
||||
@@ -34,6 +35,7 @@ func (r *HabitEntryRepository) Create(ctx context.Context, entry *entities.Habit
|
||||
entry.ScheduledDate.Format("2006-01-02"),
|
||||
entry.CompletedAt,
|
||||
entry.Value,
|
||||
entry.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -52,11 +54,12 @@ func (r *HabitEntryRepository) FindByHabitIDAndDateRange(
|
||||
from, to time.Time,
|
||||
) ([]*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value, updated_at, deleted_at
|
||||
FROM habit_entries
|
||||
WHERE habit_id = ?
|
||||
AND scheduled_date >= ?
|
||||
AND scheduled_date <= ?
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY scheduled_date ASC
|
||||
`
|
||||
|
||||
@@ -74,13 +77,15 @@ func (r *HabitEntryRepository) FindByHabitIDAndDateRange(
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) Update(ctx context.Context, entry *entities.HabitEntry) error {
|
||||
entry.UpdatedAt = time.Now()
|
||||
|
||||
query := `
|
||||
UPDATE habit_entries
|
||||
SET value = ?, completed_at = ?
|
||||
WHERE id = ?
|
||||
SET value = ?, completed_at = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, entry.Value, entry.CompletedAt, entry.ID)
|
||||
result, err := r.db.ExecContext(ctx, query, entry.Value, entry.CompletedAt, entry.UpdatedAt, entry.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update entry: %w", err)
|
||||
}
|
||||
@@ -100,6 +105,8 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt
|
||||
var (
|
||||
entry entities.HabitEntry
|
||||
scheduledDate string
|
||||
updatedAt sql.NullTime
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
@@ -108,6 +115,8 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt
|
||||
&scheduledDate,
|
||||
&entry.CompletedAt,
|
||||
&entry.Value,
|
||||
&updatedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -123,6 +132,13 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt
|
||||
}
|
||||
entry.ScheduledDate = parsedDate
|
||||
|
||||
if updatedAt.Valid {
|
||||
entry.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
entry.DeletedAt = &deletedAt.Time
|
||||
}
|
||||
|
||||
entries = append(entries, &entry)
|
||||
}
|
||||
|
||||
@@ -131,14 +147,16 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt
|
||||
|
||||
func (r *HabitEntryRepository) FindByID(ctx context.Context, id string) (*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value, updated_at, deleted_at
|
||||
FROM habit_entries
|
||||
WHERE id = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
var (
|
||||
entry entities.HabitEntry
|
||||
scheduledDate string
|
||||
updatedAt sql.NullTime
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||
@@ -147,6 +165,8 @@ func (r *HabitEntryRepository) FindByID(ctx context.Context, id string) (*entiti
|
||||
&scheduledDate,
|
||||
&entry.CompletedAt,
|
||||
&entry.Value,
|
||||
&updatedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -165,14 +185,21 @@ func (r *HabitEntryRepository) FindByID(ctx context.Context, id string) (*entiti
|
||||
}
|
||||
entry.ScheduledDate = parsedDate
|
||||
|
||||
if updatedAt.Valid {
|
||||
entry.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
entry.DeletedAt = &deletedAt.Time
|
||||
}
|
||||
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value, updated_at, deleted_at
|
||||
FROM habit_entries
|
||||
WHERE habit_id = ?
|
||||
WHERE habit_id = ? AND deleted_at IS NULL
|
||||
ORDER BY scheduled_date DESC
|
||||
`
|
||||
|
||||
@@ -187,10 +214,10 @@ func (r *HabitEntryRepository) FindByHabitID(ctx context.Context, habitID string
|
||||
|
||||
func (r *HabitEntryRepository) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT he.id, he.habit_id, he.scheduled_date, he.completed_at, he.value
|
||||
SELECT he.id, he.habit_id, he.scheduled_date, he.completed_at, he.value, he.updated_at, he.deleted_at
|
||||
FROM habit_entries he
|
||||
INNER JOIN habits h ON he.habit_id = h.id
|
||||
WHERE h.user_id = ?
|
||||
WHERE h.user_id = ? AND he.deleted_at IS NULL
|
||||
ORDER BY he.scheduled_date DESC
|
||||
`
|
||||
|
||||
@@ -205,10 +232,11 @@ func (r *HabitEntryRepository) FindByUserID(ctx context.Context, userID string)
|
||||
|
||||
func (r *HabitEntryRepository) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value, updated_at, deleted_at
|
||||
FROM habit_entries
|
||||
WHERE habit_id = ?
|
||||
AND scheduled_date < ?
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY scheduled_date DESC
|
||||
`
|
||||
|
||||
@@ -236,3 +264,128 @@ func (r *HabitEntryRepository) Delete(ctx context.Context, id string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitEntryChanges, error) {
|
||||
changes := &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT he.id, he.habit_id, he.scheduled_date, he.completed_at, he.value, he.updated_at, he.deleted_at
|
||||
FROM habit_entries he
|
||||
INNER JOIN habits h ON he.habit_id = h.id
|
||||
WHERE h.user_id = ?
|
||||
AND he.updated_at > ?
|
||||
AND he.deleted_at IS NULL
|
||||
ORDER BY he.updated_at ASC
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, userID, since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query habit entry changes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
entry entities.HabitEntry
|
||||
scheduledDate string
|
||||
updatedAt sql.NullTime
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
&entry.ID,
|
||||
&entry.HabitID,
|
||||
&scheduledDate,
|
||||
&entry.CompletedAt,
|
||||
&entry.Value,
|
||||
&updatedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan habit entry: %w", err)
|
||||
}
|
||||
|
||||
parsedDate, err := time.Parse("2006-01-02", scheduledDate)
|
||||
if err != nil {
|
||||
parsedDate, err = time.Parse(time.RFC3339, scheduledDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse scheduled_date: %w", err)
|
||||
}
|
||||
}
|
||||
entry.ScheduledDate = parsedDate
|
||||
|
||||
if updatedAt.Valid {
|
||||
entry.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
entry.DeletedAt = &deletedAt.Time
|
||||
}
|
||||
|
||||
if entry.CompletedAt.After(since) {
|
||||
changes.Created = append(changes.Created, &entry)
|
||||
} else {
|
||||
changes.Updated = append(changes.Updated, &entry)
|
||||
}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating habit entries: %w", err)
|
||||
}
|
||||
|
||||
queryDeleted := `
|
||||
SELECT he.id
|
||||
FROM habit_entries he
|
||||
INNER JOIN habits h ON he.habit_id = h.id
|
||||
WHERE h.user_id = ?
|
||||
AND he.deleted_at IS NOT NULL
|
||||
AND he.deleted_at > ?
|
||||
ORDER BY he.deleted_at ASC
|
||||
`
|
||||
|
||||
rowsDeleted, err := r.db.QueryContext(ctx, queryDeleted, userID, since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query deleted habit entries: %w", err)
|
||||
}
|
||||
defer rowsDeleted.Close()
|
||||
|
||||
for rowsDeleted.Next() {
|
||||
var id string
|
||||
if err := rowsDeleted.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan deleted habit entry id: %w", err)
|
||||
}
|
||||
changes.Deleted = append(changes.Deleted, id)
|
||||
}
|
||||
|
||||
if err := rowsDeleted.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating deleted habit entries: %w", err)
|
||||
}
|
||||
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) SoftDelete(ctx context.Context, id string) error {
|
||||
now := time.Now()
|
||||
|
||||
query := `
|
||||
UPDATE habit_entries
|
||||
SET deleted_at = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, now, now, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to soft delete habit entry: %w", err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user