aa8f7af55d
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 ✓
546 lines
13 KiB
Go
546 lines
13 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"apocapoc-api/internal/domain/entities"
|
|
"apocapoc-api/internal/domain/repositories"
|
|
"apocapoc-api/internal/shared/errors"
|
|
"apocapoc-api/internal/shared/pagination"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type HabitRepository struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func NewHabitRepository(db *sql.DB) *HabitRepository {
|
|
return &HabitRepository{db: db}
|
|
}
|
|
|
|
func (r *HabitRepository) Create(ctx context.Context, habit *entities.Habit) error {
|
|
habit.ID = uuid.New().String()
|
|
|
|
specificDays, _ := json.Marshal(habit.SpecificDays)
|
|
specificDates, _ := json.Marshal(habit.SpecificDates)
|
|
|
|
query := `
|
|
INSERT INTO habits (
|
|
id, user_id, name, description, type, frequency,
|
|
specific_days, specific_dates, carry_over, is_negative, target_value, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`
|
|
|
|
_, err := r.db.ExecContext(ctx, query,
|
|
habit.ID,
|
|
habit.UserID,
|
|
habit.Name,
|
|
habit.Description,
|
|
habit.Type,
|
|
habit.Frequency,
|
|
specificDays,
|
|
specificDates,
|
|
habit.CarryOver,
|
|
habit.IsNegative,
|
|
habit.TargetValue,
|
|
habit.CreatedAt,
|
|
habit.UpdatedAt,
|
|
)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create habit: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *HabitRepository) FindByID(ctx context.Context, id string) (*entities.Habit, error) {
|
|
query := `
|
|
SELECT id, user_id, name, description, type, frequency,
|
|
specific_days, specific_dates, carry_over, is_negative, target_value,
|
|
created_at, updated_at, archived_at, deleted_at
|
|
FROM habits
|
|
WHERE id = ? AND deleted_at IS NULL
|
|
`
|
|
|
|
var (
|
|
habit entities.Habit
|
|
specificDays sql.NullString
|
|
specificDates sql.NullString
|
|
updatedAt sql.NullTime
|
|
archivedAt sql.NullTime
|
|
deletedAt sql.NullTime
|
|
)
|
|
|
|
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
|
&habit.ID,
|
|
&habit.UserID,
|
|
&habit.Name,
|
|
&habit.Description,
|
|
&habit.Type,
|
|
&habit.Frequency,
|
|
&specificDays,
|
|
&specificDates,
|
|
&habit.CarryOver,
|
|
&habit.IsNegative,
|
|
&habit.TargetValue,
|
|
&habit.CreatedAt,
|
|
&updatedAt,
|
|
&archivedAt,
|
|
&deletedAt,
|
|
)
|
|
|
|
if err == sql.ErrNoRows {
|
|
return nil, errors.ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to find habit: %w", err)
|
|
}
|
|
|
|
if updatedAt.Valid {
|
|
habit.UpdatedAt = updatedAt.Time
|
|
}
|
|
if archivedAt.Valid {
|
|
habit.ArchivedAt = &archivedAt.Time
|
|
}
|
|
if deletedAt.Valid {
|
|
habit.DeletedAt = &deletedAt.Time
|
|
}
|
|
|
|
if specificDays.Valid {
|
|
json.Unmarshal([]byte(specificDays.String), &habit.SpecificDays)
|
|
}
|
|
if specificDates.Valid {
|
|
json.Unmarshal([]byte(specificDates.String), &habit.SpecificDates)
|
|
}
|
|
|
|
return &habit, nil
|
|
}
|
|
|
|
func (r *HabitRepository) FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
|
|
query := `
|
|
SELECT id, user_id, name, description, type, frequency,
|
|
specific_days, specific_dates, carry_over, is_negative, target_value,
|
|
created_at, updated_at, archived_at, deleted_at
|
|
FROM habits
|
|
WHERE user_id = ? AND archived_at IS NULL AND deleted_at IS NULL
|
|
ORDER BY created_at DESC
|
|
`
|
|
|
|
rows, err := r.db.QueryContext(ctx, query, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to find habits: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
return r.scanHabits(rows)
|
|
}
|
|
|
|
func (r *HabitRepository) Update(ctx context.Context, habit *entities.Habit) error {
|
|
habit.Touch()
|
|
|
|
specificDays, _ := json.Marshal(habit.SpecificDays)
|
|
specificDates, _ := json.Marshal(habit.SpecificDates)
|
|
|
|
query := `
|
|
UPDATE habits
|
|
SET name = ?, description = ?, type = ?, frequency = ?,
|
|
specific_days = ?, specific_dates = ?, carry_over = ?, is_negative = ?,
|
|
target_value = ?, archived_at = ?, updated_at = ?
|
|
WHERE id = ? AND deleted_at IS NULL
|
|
`
|
|
|
|
result, err := r.db.ExecContext(ctx, query,
|
|
habit.Name,
|
|
habit.Description,
|
|
habit.Type,
|
|
habit.Frequency,
|
|
specificDays,
|
|
specificDates,
|
|
habit.CarryOver,
|
|
habit.IsNegative,
|
|
habit.TargetValue,
|
|
habit.ArchivedAt,
|
|
habit.UpdatedAt,
|
|
habit.ID,
|
|
)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to update habit: %w", err)
|
|
}
|
|
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return errors.ErrNotFound
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *HabitRepository) scanHabits(rows *sql.Rows) ([]*entities.Habit, error) {
|
|
var habits []*entities.Habit
|
|
|
|
for rows.Next() {
|
|
var (
|
|
habit entities.Habit
|
|
specificDays sql.NullString
|
|
specificDates sql.NullString
|
|
updatedAt sql.NullTime
|
|
archivedAt sql.NullTime
|
|
deletedAt sql.NullTime
|
|
)
|
|
|
|
err := rows.Scan(
|
|
&habit.ID,
|
|
&habit.UserID,
|
|
&habit.Name,
|
|
&habit.Description,
|
|
&habit.Type,
|
|
&habit.Frequency,
|
|
&specificDays,
|
|
&specificDates,
|
|
&habit.CarryOver,
|
|
&habit.IsNegative,
|
|
&habit.TargetValue,
|
|
&habit.CreatedAt,
|
|
&updatedAt,
|
|
&archivedAt,
|
|
&deletedAt,
|
|
)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if specificDays.Valid {
|
|
json.Unmarshal([]byte(specificDays.String), &habit.SpecificDays)
|
|
}
|
|
if specificDates.Valid {
|
|
json.Unmarshal([]byte(specificDates.String), &habit.SpecificDates)
|
|
}
|
|
if updatedAt.Valid {
|
|
habit.UpdatedAt = updatedAt.Time
|
|
}
|
|
if archivedAt.Valid {
|
|
habit.ArchivedAt = &archivedAt.Time
|
|
}
|
|
if deletedAt.Valid {
|
|
habit.DeletedAt = &deletedAt.Time
|
|
}
|
|
|
|
habits = append(habits, &habit)
|
|
}
|
|
|
|
return habits, nil
|
|
}
|
|
|
|
func (r *HabitRepository) FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
|
|
query := `
|
|
SELECT id, user_id, name, description, type, frequency,
|
|
specific_days, specific_dates, carry_over, is_negative, target_value,
|
|
created_at, updated_at, archived_at, deleted_at
|
|
FROM habits
|
|
WHERE user_id = ? AND deleted_at IS NULL
|
|
ORDER BY created_at DESC
|
|
`
|
|
|
|
rows, err := r.db.QueryContext(ctx, query, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to find habits: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
return r.scanHabits(rows)
|
|
}
|
|
|
|
func (r *HabitRepository) Delete(ctx context.Context, id string) error {
|
|
query := `DELETE FROM habits WHERE id = ?`
|
|
|
|
result, err := r.db.ExecContext(ctx, query, id)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete habit: %w", err)
|
|
}
|
|
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return errors.ErrNotFound
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *HabitRepository) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
|
query := `
|
|
SELECT id, user_id, name, description, type, frequency,
|
|
specific_days, specific_dates, carry_over, is_negative, target_value,
|
|
created_at, updated_at, archived_at, deleted_at
|
|
FROM habits
|
|
WHERE user_id = ? AND archived_at IS NULL AND deleted_at IS NULL
|
|
ORDER BY created_at DESC
|
|
LIMIT ? OFFSET ?
|
|
`
|
|
|
|
rows, err := r.db.QueryContext(ctx, query, userID, params.Limit(), params.Offset())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to find habits: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
return r.scanHabits(rows)
|
|
}
|
|
|
|
func (r *HabitRepository) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
|
query := `
|
|
SELECT COUNT(*)
|
|
FROM habits
|
|
WHERE user_id = ? AND archived_at IS NULL AND deleted_at IS NULL
|
|
`
|
|
|
|
var count int
|
|
err := r.db.QueryRowContext(ctx, query, userID).Scan(&count)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to count habits: %w", err)
|
|
}
|
|
|
|
return count, nil
|
|
}
|
|
|
|
func (r *HabitRepository) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
|
baseQuery := `
|
|
SELECT id, user_id, name, description, type, frequency,
|
|
specific_days, specific_dates, carry_over, is_negative, target_value,
|
|
created_at, updated_at, archived_at, deleted_at
|
|
FROM habits
|
|
WHERE user_id = ?`
|
|
|
|
args := []interface{}{userID}
|
|
conditions := []string{}
|
|
|
|
// Always exclude soft deleted
|
|
conditions = append(conditions, "deleted_at IS NULL")
|
|
|
|
if !filter.IncludeArchived {
|
|
conditions = append(conditions, "archived_at IS NULL")
|
|
}
|
|
|
|
if filter.Type != nil {
|
|
conditions = append(conditions, "type = ?")
|
|
args = append(args, string(*filter.Type))
|
|
}
|
|
|
|
if filter.Frequency != nil {
|
|
conditions = append(conditions, "frequency = ?")
|
|
args = append(args, string(*filter.Frequency))
|
|
}
|
|
|
|
if filter.Search != "" {
|
|
conditions = append(conditions, "(name LIKE ? OR description LIKE ?)")
|
|
searchPattern := "%" + filter.Search + "%"
|
|
args = append(args, searchPattern, searchPattern)
|
|
}
|
|
|
|
for _, condition := range conditions {
|
|
baseQuery += " AND " + condition
|
|
}
|
|
|
|
baseQuery += " ORDER BY created_at DESC"
|
|
|
|
if paginationParams != nil {
|
|
baseQuery += " LIMIT ? OFFSET ?"
|
|
args = append(args, paginationParams.Limit(), paginationParams.Offset())
|
|
}
|
|
|
|
rows, err := r.db.QueryContext(ctx, baseQuery, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to find habits: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
return r.scanHabits(rows)
|
|
}
|
|
|
|
func (r *HabitRepository) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
|
baseQuery := `SELECT COUNT(*) FROM habits WHERE user_id = ?`
|
|
|
|
args := []interface{}{userID}
|
|
conditions := []string{}
|
|
|
|
// Always exclude soft deleted
|
|
conditions = append(conditions, "deleted_at IS NULL")
|
|
|
|
if !filter.IncludeArchived {
|
|
conditions = append(conditions, "archived_at IS NULL")
|
|
}
|
|
|
|
if filter.Type != nil {
|
|
conditions = append(conditions, "type = ?")
|
|
args = append(args, string(*filter.Type))
|
|
}
|
|
|
|
if filter.Frequency != nil {
|
|
conditions = append(conditions, "frequency = ?")
|
|
args = append(args, string(*filter.Frequency))
|
|
}
|
|
|
|
if filter.Search != "" {
|
|
conditions = append(conditions, "(name LIKE ? OR description LIKE ?)")
|
|
searchPattern := "%" + filter.Search + "%"
|
|
args = append(args, searchPattern, searchPattern)
|
|
}
|
|
|
|
for _, condition := range conditions {
|
|
baseQuery += " AND " + condition
|
|
}
|
|
|
|
var count int
|
|
err := r.db.QueryRowContext(ctx, baseQuery, args...).Scan(&count)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to count habits: %w", err)
|
|
}
|
|
|
|
return count, nil
|
|
}
|
|
|
|
func (r *HabitRepository) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
|
changes := &repositories.HabitChanges{
|
|
Created: []*entities.Habit{},
|
|
Updated: []*entities.Habit{},
|
|
Deleted: []string{},
|
|
}
|
|
|
|
// Get created and updated habits (not deleted)
|
|
query := `
|
|
SELECT id, user_id, name, description, type, frequency,
|
|
specific_days, specific_dates, carry_over, is_negative, target_value,
|
|
created_at, updated_at, archived_at, deleted_at
|
|
FROM habits
|
|
WHERE user_id = ?
|
|
AND updated_at > ?
|
|
AND deleted_at IS NULL
|
|
ORDER BY updated_at ASC
|
|
`
|
|
|
|
rows, err := r.db.QueryContext(ctx, query, userID, since)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query habits changes: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var (
|
|
habit entities.Habit
|
|
specificDays sql.NullString
|
|
specificDates sql.NullString
|
|
updatedAt sql.NullTime
|
|
archivedAt sql.NullTime
|
|
deletedAt sql.NullTime
|
|
)
|
|
|
|
err := rows.Scan(
|
|
&habit.ID,
|
|
&habit.UserID,
|
|
&habit.Name,
|
|
&habit.Description,
|
|
&habit.Type,
|
|
&habit.Frequency,
|
|
&specificDays,
|
|
&specificDates,
|
|
&habit.CarryOver,
|
|
&habit.IsNegative,
|
|
&habit.TargetValue,
|
|
&habit.CreatedAt,
|
|
&updatedAt,
|
|
&archivedAt,
|
|
&deletedAt,
|
|
)
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to scan habit: %w", err)
|
|
}
|
|
|
|
if specificDays.Valid {
|
|
json.Unmarshal([]byte(specificDays.String), &habit.SpecificDays)
|
|
}
|
|
if specificDates.Valid {
|
|
json.Unmarshal([]byte(specificDates.String), &habit.SpecificDates)
|
|
}
|
|
if updatedAt.Valid {
|
|
habit.UpdatedAt = updatedAt.Time
|
|
}
|
|
if archivedAt.Valid {
|
|
habit.ArchivedAt = &archivedAt.Time
|
|
}
|
|
if deletedAt.Valid {
|
|
habit.DeletedAt = &deletedAt.Time
|
|
}
|
|
|
|
// Classify as created or updated based on when it was created
|
|
if habit.CreatedAt.After(since) {
|
|
changes.Created = append(changes.Created, &habit)
|
|
} else {
|
|
changes.Updated = append(changes.Updated, &habit)
|
|
}
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("error iterating habits: %w", err)
|
|
}
|
|
|
|
// Get deleted habits
|
|
queryDeleted := `
|
|
SELECT id
|
|
FROM habits
|
|
WHERE user_id = ?
|
|
AND deleted_at IS NOT NULL
|
|
AND deleted_at > ?
|
|
ORDER BY deleted_at ASC
|
|
`
|
|
|
|
rowsDeleted, err := r.db.QueryContext(ctx, queryDeleted, userID, since)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query deleted habits: %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 id: %w", err)
|
|
}
|
|
changes.Deleted = append(changes.Deleted, id)
|
|
}
|
|
|
|
if err := rowsDeleted.Err(); err != nil {
|
|
return nil, fmt.Errorf("error iterating deleted habits: %w", err)
|
|
}
|
|
|
|
return changes, nil
|
|
}
|
|
|
|
func (r *HabitRepository) SoftDelete(ctx context.Context, id string) error {
|
|
now := time.Now()
|
|
|
|
query := `
|
|
UPDATE habits
|
|
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: %w", err)
|
|
}
|
|
|
|
rows, _ := result.RowsAffected()
|
|
if rows == 0 {
|
|
return errors.ErrNotFound
|
|
}
|
|
|
|
return nil
|
|
}
|