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:
2025-12-12 00:11:46 +01:00
parent 1aedc2b69a
commit aa8f7af55d
25 changed files with 4554 additions and 87 deletions
+557 -12
View File
@@ -34,11 +34,55 @@ definitions:
type:
$ref: '#/definitions/value_objects.HabitType'
type: object
http.EntryChangesDTO:
properties:
created:
items:
$ref: '#/definitions/http.SyncHabitEntryDTO'
type: array
deleted:
items:
type: string
type: array
updated:
items:
$ref: '#/definitions/http.SyncHabitEntryDTO'
type: array
type: object
http.ErrorResponse:
properties:
error:
type: string
type: object
http.ForgotPasswordRequest:
properties:
email:
type: string
type: object
http.GetUserHabitsResponse:
properties:
data:
items:
$ref: '#/definitions/http.UserHabitResponse'
type: array
pagination:
$ref: '#/definitions/pagination.Response'
type: object
http.HabitChangesDTO:
properties:
created:
items:
$ref: '#/definitions/http.SyncHabitDTO'
type: array
deleted:
items:
type: string
type: array
updated:
items:
$ref: '#/definitions/http.SyncHabitDTO'
type: array
type: object
http.HabitEntriesResponse:
properties:
entries:
@@ -69,6 +113,8 @@ definitions:
properties:
database:
type: string
smtp:
type: string
status:
type: string
uptime:
@@ -104,11 +150,103 @@ definitions:
type: string
password:
type: string
timezone:
type: object
http.RegisterResponse:
properties:
message:
type: string
user_id:
type: string
type: object
http.ResendVerificationRequest:
properties:
email:
type: string
type: object
http.ResetPasswordRequest:
properties:
new_password:
type: string
token:
type: string
type: object
http.SyncBatchRequest:
properties:
entries:
$ref: '#/definitions/http.EntryChangesDTO'
habits:
$ref: '#/definitions/http.HabitChangesDTO'
type: object
http.SyncChangesResponse:
properties:
entries:
$ref: '#/definitions/http.EntryChangesDTO'
habits:
$ref: '#/definitions/http.HabitChangesDTO'
type: object
http.SyncHabitDTO:
properties:
archived_at:
type: string
carry_over:
type: boolean
created_at:
type: string
description:
type: string
frequency:
$ref: '#/definitions/value_objects.Frequency'
id:
type: string
is_negative:
type: boolean
name:
type: string
specific_dates:
items:
type: integer
type: array
specific_days:
items:
type: integer
type: array
target_value:
type: number
type:
$ref: '#/definitions/value_objects.HabitType'
updated_at:
type: string
user_id:
type: string
type: object
http.SyncHabitEntryDTO:
properties:
completed_at:
type: string
habit_id:
type: string
id:
type: string
scheduled_date:
type: string
updated_at:
type: string
value:
type: number
type: object
http.TodaysHabitEntryResponse:
properties:
completed_at:
type: string
id:
type: string
value:
type: number
type: object
http.TodaysHabitResponse:
properties:
entry:
$ref: '#/definitions/http.TodaysHabitEntryResponse'
id:
type: string
is_carried_over:
@@ -164,6 +302,86 @@ definitions:
type:
$ref: '#/definitions/value_objects.HabitType'
type: object
http.ValidationErrorResponse:
properties:
error:
type: string
field:
type: string
type: object
http.VerifyEmailRequest:
properties:
token:
type: string
type: object
pagination.Response:
properties:
page:
type: integer
page_size:
type: integer
total_items:
type: integer
total_pages:
type: integer
type: object
queries.ExportEntryDTO:
properties:
completed_at:
type: string
habit_id:
type: string
id:
type: string
scheduled_date:
type: string
value:
type: number
type: object
queries.ExportHabitDTO:
properties:
archived_at:
type: string
carry_over:
type: boolean
created_at:
type: string
description:
type: string
frequency:
$ref: '#/definitions/value_objects.Frequency'
id:
type: string
is_negative:
type: boolean
name:
type: string
specific_dates:
items:
type: integer
type: array
specific_days:
items:
type: integer
type: array
target_value:
type: number
type:
$ref: '#/definitions/value_objects.HabitType'
type: object
queries.ExportUserDataResult:
properties:
entries:
items:
$ref: '#/definitions/queries.ExportEntryDTO'
type: array
exported_at:
type: string
habits:
items:
$ref: '#/definitions/queries.ExportHabitDTO'
type: array
type: object
queries.HabitStatsDTO:
properties:
completion_rate:
@@ -215,6 +433,46 @@ info:
termsOfService: http://swagger.io/terms/
title: Apocapoc API
paths:
/auth/forgot-password:
post:
consumes:
- application/json
description: Request a password reset email with a reset token
parameters:
- description: User email
in: body
name: request
required: true
schema:
$ref: '#/definitions/http.ForgotPasswordRequest'
produces:
- application/json
responses:
"200":
description: Reset email sent successfully
schema:
additionalProperties:
type: string
type: object
"400":
description: Invalid email
schema:
$ref: '#/definitions/http.ErrorResponse'
"403":
description: Email not verified
schema:
$ref: '#/definitions/http.ErrorResponse'
"404":
description: User not found
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/http.ErrorResponse'
summary: Request password reset
tags:
- auth
/auth/login:
post:
consumes:
@@ -244,6 +502,10 @@ paths:
description: Invalid email or password
schema:
$ref: '#/definitions/http.ErrorResponse'
"403":
description: Email not verified
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal server error
schema:
@@ -333,9 +595,8 @@ paths:
post:
consumes:
- application/json
description: Create a new user account and receive both access token and refresh
token. Store both tokens securely - the refresh token is used to obtain new
access tokens when they expire.
description: Create a new user account. If email verification is enabled, you
will receive a verification email. Otherwise, you can login immediately.
parameters:
- description: 'Registration data (password requires: min 8 chars, uppercase,
lowercase, digit, special char)'
@@ -348,11 +609,15 @@ paths:
- application/json
responses:
"201":
description: Returns access token, refresh token, and user ID
description: Returns user ID and message about next steps
schema:
$ref: '#/definitions/http.AuthResponse'
$ref: '#/definitions/http.RegisterResponse'
"400":
description: 'Invalid input: email format, password requirements, or timezone'
description: 'Invalid input: email format or password requirements'
schema:
$ref: '#/definitions/http.ValidationErrorResponse'
"403":
description: Registration is closed
schema:
$ref: '#/definitions/http.ErrorResponse'
"409":
@@ -366,18 +631,182 @@ paths:
summary: Register a new user
tags:
- auth
/auth/resend-verification:
post:
consumes:
- application/json
description: Resend the email verification link to the user's email address
parameters:
- description: User email
in: body
name: request
required: true
schema:
$ref: '#/definitions/http.ResendVerificationRequest'
produces:
- application/json
responses:
"200":
description: Verification email sent
schema:
additionalProperties:
type: string
type: object
"400":
description: Invalid email
schema:
$ref: '#/definitions/http.ErrorResponse'
"404":
description: User not found
schema:
$ref: '#/definitions/http.ErrorResponse'
"409":
description: Email already verified
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/http.ErrorResponse'
summary: Resend verification email
tags:
- auth
/auth/reset-password:
post:
consumes:
- application/json
description: Reset user password using the reset token from email
parameters:
- description: Reset token and new password
in: body
name: request
required: true
schema:
$ref: '#/definitions/http.ResetPasswordRequest'
produces:
- application/json
responses:
"200":
description: Password reset successfully
schema:
additionalProperties:
type: string
type: object
"400":
description: Invalid token or password requirements not met
schema:
$ref: '#/definitions/http.ErrorResponse'
"404":
description: User not found
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/http.ErrorResponse'
summary: Reset password
tags:
- auth
/auth/verify-email:
post:
consumes:
- application/json
description: Verify user email address using the token sent via email
parameters:
- description: Verification token
in: body
name: request
required: true
schema:
$ref: '#/definitions/http.VerifyEmailRequest'
produces:
- application/json
responses:
"200":
description: Email verified successfully
schema:
additionalProperties:
type: string
type: object
"400":
description: Invalid or expired token
schema:
$ref: '#/definitions/http.ErrorResponse'
"409":
description: Email already verified
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/http.ErrorResponse'
summary: Verify email address
tags:
- auth
/export:
get:
description: Export all user habits and entries in JSON format with gzip compression.
Limited to 1 export per hour.
produces:
- application/json
responses:
"200":
description: Compressed JSON export
schema:
$ref: '#/definitions/queries.ExportUserDataResult'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/http.ErrorResponse'
"429":
description: Rate limit exceeded
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/http.ErrorResponse'
security:
- BearerAuth: []
summary: Export user data
tags:
- export
/habits:
get:
description: Get all active habits for the authenticated user
description: Get all active habits for the authenticated user with optional
pagination and filters
parameters:
- description: 'Page number (default: 1)'
in: query
name: page
type: integer
- description: 'Page size (default: 50, max: 100)'
in: query
name: page_size
type: integer
- description: Filter by type (BOOLEAN, COUNTER, VALUE)
in: query
name: type
type: string
- description: Filter by frequency (DAILY, WEEKLY, MONTHLY)
in: query
name: frequency
type: string
- description: 'Include archived habits (default: false)'
in: query
name: archived
type: boolean
- description: Search by name or description
in: query
name: search
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/http.UserHabitResponse'
type: array
$ref: '#/definitions/http.GetUserHabitsResponse'
"401":
description: Unauthorized
schema:
@@ -708,7 +1137,15 @@ paths:
- habits
/habits/today:
get:
description: Get all habits scheduled for today for the authenticated user
description: Get all habits scheduled for today for the authenticated user.
Includes the entry for today if it exists. Requires timezone as query parameter
(e.g., ?timezone=America/New_York).
parameters:
- description: IANA timezone (e.g., 'America/New_York', 'Europe/Madrid', 'UTC')
in: query
name: timezone
required: true
type: string
produces:
- application/json
responses:
@@ -718,6 +1155,10 @@ paths:
items:
$ref: '#/definitions/http.TodaysHabitResponse'
type: array
"400":
description: Invalid or missing timezone
schema:
$ref: '#/definitions/http.ErrorResponse'
"401":
description: Unauthorized
schema:
@@ -786,6 +1227,110 @@ paths:
summary: Get habit statistics
tags:
- stats
/sync/batch:
post:
consumes:
- application/json
description: Apply a batch of changes from the client for offline sync (Last-Write-Wins)
parameters:
- description: Sync batch data
in: body
name: request
required: true
schema:
$ref: '#/definitions/http.SyncBatchRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties:
type: string
type: object
"400":
description: Bad Request
schema:
$ref: '#/definitions/http.ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/http.ErrorResponse'
security:
- BearerAuth: []
summary: Apply sync batch
tags:
- sync
/sync/changes:
get:
consumes:
- application/json
description: Get all changes (habits and entries) since a given timestamp for
offline sync
parameters:
- description: ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)
in: query
name: since
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/http.SyncChangesResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/http.ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/http.ErrorResponse'
security:
- BearerAuth: []
summary: Get sync changes
tags:
- sync
/users/me:
delete:
description: Permanently delete the authenticated user's account and all associated
data (habits, entries, tokens). This action cannot be undone.
produces:
- application/json
responses:
"200":
description: Account deleted successfully
schema:
additionalProperties:
type: string
type: object
"401":
description: Unauthorized - invalid or missing token
schema:
$ref: '#/definitions/http.ErrorResponse'
"404":
description: User not found
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/http.ErrorResponse'
security:
- BearerAuth: []
summary: Delete user account
tags:
- users
securityDefinitions:
BearerAuth:
description: Type "Bearer" followed by a space and JWT token.