Add honeycomb completion heatmap and data migration framework
- Add completion_summary endpoint data to residence detail response - Track completed_from_column on task completions (overdue/due_soon/upcoming) - Add GetCompletionSummary repo method with monthly aggregation - Add one-time data migration framework (data_migrations table + registry) - Add backfill migration to classify historical completions - Add standalone backfill script for manual/dry-run usage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
94
internal/database/data_migrations.go
Normal file
94
internal/database/data_migrations.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DataMigration tracks one-time data migrations that have been applied.
|
||||
type DataMigration struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Name string `gorm:"column:name;uniqueIndex;size:255;not null"`
|
||||
AppliedAt time.Time `gorm:"column:applied_at;not null"`
|
||||
}
|
||||
|
||||
func (DataMigration) TableName() string {
|
||||
return "data_migrations"
|
||||
}
|
||||
|
||||
// dataMigrationEntry pairs a name with its run function.
|
||||
type dataMigrationEntry struct {
|
||||
Name string
|
||||
Fn func(tx *gorm.DB) error
|
||||
}
|
||||
|
||||
// registry holds all registered one-time data migrations, in order.
|
||||
var registry []dataMigrationEntry
|
||||
|
||||
// RegisterDataMigration adds a one-time migration. Call from init() functions.
|
||||
func RegisterDataMigration(name string, fn func(tx *gorm.DB) error) {
|
||||
registry = append(registry, dataMigrationEntry{Name: name, Fn: fn})
|
||||
}
|
||||
|
||||
// RunDataMigrations creates the tracking table and executes any migrations
|
||||
// that haven't been applied yet. Called once during server startup.
|
||||
func RunDataMigrations() error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure tracking table exists
|
||||
if err := db.AutoMigrate(&DataMigration{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(registry) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort by name for deterministic order
|
||||
sort.Slice(registry, func(i, j int) bool {
|
||||
return registry[i].Name < registry[j].Name
|
||||
})
|
||||
|
||||
// Load already-applied migrations
|
||||
var applied []DataMigration
|
||||
if err := db.Find(&applied).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
appliedSet := make(map[string]bool, len(applied))
|
||||
for _, m := range applied {
|
||||
appliedSet[m.Name] = true
|
||||
}
|
||||
|
||||
// Run pending migrations
|
||||
for _, entry := range registry {
|
||||
if appliedSet[entry.Name] {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Info().Str("migration", entry.Name).Msg("Running data migration")
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := entry.Fn(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
// Record that this migration has been applied
|
||||
return tx.Create(&DataMigration{
|
||||
Name: entry.Name,
|
||||
AppliedAt: time.Now().UTC(),
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("migration", entry.Name).Msg("Data migration failed")
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info().Str("migration", entry.Name).Msg("Data migration completed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user