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 ✓
259 lines
7.0 KiB
Go
259 lines
7.0 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
)
|
|
|
|
func RunMigrations(db *sql.DB) error {
|
|
migrations := []string{
|
|
createUsersTable,
|
|
createHabitsTable,
|
|
createHabitEntriesTable,
|
|
createRefreshTokensTable,
|
|
createPasswordResetTokensTable,
|
|
createIndexes,
|
|
}
|
|
|
|
for _, migration := range migrations {
|
|
if _, err := db.Exec(migration); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := addEmailVerificationColumns(db); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := removeTimezoneColumn(db); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := addSyncColumns(db); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func addEmailVerificationColumns(db *sql.DB) error {
|
|
columns := []struct {
|
|
name string
|
|
definition string
|
|
}{
|
|
{"email_verified", "ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT 0"},
|
|
{"email_verification_token", "ALTER TABLE users ADD COLUMN email_verification_token TEXT"},
|
|
{"email_verification_expiry", "ALTER TABLE users ADD COLUMN email_verification_expiry DATETIME"},
|
|
}
|
|
|
|
for _, col := range columns {
|
|
exists, err := columnExists(db, "users", col.name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !exists {
|
|
if _, err := db.Exec(col.definition); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func removeTimezoneColumn(db *sql.DB) error {
|
|
exists, err := columnExists(db, "users", "timezone")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !exists {
|
|
return nil
|
|
}
|
|
|
|
if _, err := db.Exec("ALTER TABLE users DROP COLUMN timezone"); err != nil {
|
|
return fmt.Errorf("failed to drop timezone column: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func columnExists(db *sql.DB, table, column string) (bool, error) {
|
|
query := fmt.Sprintf("SELECT COUNT(*) FROM pragma_table_info('%s') WHERE name = ?", table)
|
|
var count int
|
|
err := db.QueryRow(query, column).Scan(&count)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
func indexExists(db *sql.DB, indexName string) (bool, error) {
|
|
query := "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?"
|
|
var count int
|
|
err := db.QueryRow(query, indexName).Scan(&count)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
func addSyncColumns(db *sql.DB) error {
|
|
// Columns to add to habits table
|
|
habitColumns := []struct {
|
|
name string
|
|
definition string
|
|
}{
|
|
{"updated_at", "ALTER TABLE habits ADD COLUMN updated_at DATETIME"},
|
|
{"deleted_at", "ALTER TABLE habits ADD COLUMN deleted_at DATETIME"},
|
|
}
|
|
|
|
for _, col := range habitColumns {
|
|
exists, err := columnExists(db, "habits", col.name)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check if column %s exists: %w", col.name, err)
|
|
}
|
|
|
|
if !exists {
|
|
if _, err := db.Exec(col.definition); err != nil {
|
|
return fmt.Errorf("failed to add column %s: %w", col.name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize updated_at with created_at for existing records
|
|
if _, err := db.Exec("UPDATE habits SET updated_at = created_at WHERE updated_at IS NULL"); err != nil {
|
|
return fmt.Errorf("failed to initialize updated_at: %w", err)
|
|
}
|
|
|
|
// Columns to add to habit_entries table
|
|
entryColumns := []struct {
|
|
name string
|
|
definition string
|
|
}{
|
|
{"updated_at", "ALTER TABLE habit_entries ADD COLUMN updated_at DATETIME"},
|
|
{"deleted_at", "ALTER TABLE habit_entries ADD COLUMN deleted_at DATETIME"},
|
|
}
|
|
|
|
for _, col := range entryColumns {
|
|
exists, err := columnExists(db, "habit_entries", col.name)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check if column %s exists: %w", col.name, err)
|
|
}
|
|
|
|
if !exists {
|
|
if _, err := db.Exec(col.definition); err != nil {
|
|
return fmt.Errorf("failed to add column %s: %w", col.name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize updated_at with completed_at for existing entries
|
|
if _, err := db.Exec("UPDATE habit_entries SET updated_at = completed_at WHERE updated_at IS NULL"); err != nil {
|
|
return fmt.Errorf("failed to initialize updated_at for entries: %w", err)
|
|
}
|
|
|
|
// Create indexes for sync queries
|
|
indexes := []struct {
|
|
name string
|
|
definition string
|
|
}{
|
|
{"idx_habits_updated_at", "CREATE INDEX IF NOT EXISTS idx_habits_updated_at ON habits(user_id, updated_at)"},
|
|
{"idx_habits_deleted_at", "CREATE INDEX IF NOT EXISTS idx_habits_deleted_at ON habits(deleted_at)"},
|
|
{"idx_habit_entries_updated_at", "CREATE INDEX IF NOT EXISTS idx_habit_entries_updated_at ON habit_entries(habit_id, updated_at)"},
|
|
{"idx_habit_entries_deleted_at", "CREATE INDEX IF NOT EXISTS idx_habit_entries_deleted_at ON habit_entries(deleted_at)"},
|
|
}
|
|
|
|
for _, idx := range indexes {
|
|
exists, err := indexExists(db, idx.name)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to check if index %s exists: %w", idx.name, err)
|
|
}
|
|
|
|
if !exists {
|
|
if _, err := db.Exec(idx.definition); err != nil {
|
|
return fmt.Errorf("failed to create index %s: %w", idx.name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
const createUsersTable = `
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id TEXT PRIMARY KEY,
|
|
email TEXT UNIQUE NOT NULL,
|
|
password_hash TEXT NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
`
|
|
|
|
const createHabitsTable = `
|
|
CREATE TABLE IF NOT EXISTS habits (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
type TEXT CHECK(type IN ('BOOLEAN', 'COUNTER', 'VALUE')),
|
|
frequency TEXT CHECK(frequency IN ('DAILY', 'WEEKLY', 'MONTHLY')),
|
|
specific_days TEXT,
|
|
specific_dates TEXT,
|
|
carry_over BOOLEAN DEFAULT 0,
|
|
is_negative BOOLEAN DEFAULT 0,
|
|
target_value REAL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
archived_at DATETIME,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
`
|
|
|
|
const createHabitEntriesTable = `
|
|
CREATE TABLE IF NOT EXISTS habit_entries (
|
|
id TEXT PRIMARY KEY,
|
|
habit_id TEXT NOT NULL,
|
|
scheduled_date DATE NOT NULL,
|
|
completed_at DATETIME NOT NULL,
|
|
value REAL,
|
|
FOREIGN KEY (habit_id) REFERENCES habits(id) ON DELETE CASCADE,
|
|
UNIQUE(habit_id, scheduled_date)
|
|
);
|
|
`
|
|
|
|
const createRefreshTokensTable = `
|
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
token TEXT UNIQUE NOT NULL,
|
|
expires_at DATETIME NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
revoked_at DATETIME,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
`
|
|
|
|
const createPasswordResetTokensTable = `
|
|
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
|
id TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL,
|
|
token TEXT UNIQUE NOT NULL,
|
|
expires_at DATETIME NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
used_at DATETIME,
|
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
);
|
|
`
|
|
|
|
const createIndexes = `
|
|
CREATE INDEX IF NOT EXISTS idx_habits_user ON habits(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_habits_active ON habits(user_id, archived_at);
|
|
CREATE INDEX IF NOT EXISTS idx_entries_habit ON habit_entries(habit_id);
|
|
CREATE INDEX IF NOT EXISTS idx_entries_scheduled ON habit_entries(scheduled_date);
|
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token);
|
|
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON password_reset_tokens(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token);
|
|
`
|