diff --git a/internal/chore/handler.go b/internal/chore/handler.go index 0300ca7..a97969d 100644 --- a/internal/chore/handler.go +++ b/internal/chore/handler.go @@ -15,6 +15,7 @@ import ( chModel "donetick.com/core/internal/chore/model" chRepo "donetick.com/core/internal/chore/repo" cRepo "donetick.com/core/internal/circle/repo" + lRepo "donetick.com/core/internal/label/repo" nRepo "donetick.com/core/internal/notifier/repo" nps "donetick.com/core/internal/notifier/service" telegram "donetick.com/core/internal/notifier/telegram" @@ -31,6 +32,10 @@ type ThingTrigger struct { Condition string `json:"condition"` } +type LabelReq struct { + LabelID int `json:"id" binding:"required"` +} + type ChoreReq struct { Name string `json:"name" binding:"required"` FrequencyType chModel.FrequencyType `json:"frequencyType"` @@ -46,6 +51,7 @@ type ChoreReq struct { Notification bool `json:"notification"` NotificationMetadata *chModel.NotificationMetadata `json:"notificationMetadata"` Labels []string `json:"labels"` + LabelsV2 *[]LabelReq `json:"labelsV2"` ThingTrigger *ThingTrigger `json:"thingTrigger"` } type Handler struct { @@ -55,10 +61,11 @@ type Handler struct { nPlanner *nps.NotificationPlanner nRepo *nRepo.NotificationRepository tRepo *tRepo.ThingRepository + lRepo *lRepo.LabelRepository } func NewHandler(cr *chRepo.ChoreRepository, circleRepo *cRepo.CircleRepository, nt *telegram.TelegramNotifier, - np *nps.NotificationPlanner, nRepo *nRepo.NotificationRepository, tRepo *tRepo.ThingRepository) *Handler { + np *nps.NotificationPlanner, nRepo *nRepo.NotificationRepository, tRepo *tRepo.ThingRepository, lRepo *lRepo.LabelRepository) *Handler { return &Handler{ choreRepo: cr, circleRepo: circleRepo, @@ -66,6 +73,7 @@ func NewHandler(cr *chRepo.ChoreRepository, circleRepo *cRepo.CircleRepository, nPlanner: np, nRepo: nRepo, tRepo: tRepo, + lRepo: lRepo, } } @@ -262,6 +270,17 @@ func (h *Handler) createChore(c *gin.Context) { }) } + labelsV2 := make([]int, len(*choreReq.LabelsV2)) + for i, label := range *choreReq.LabelsV2 { + labelsV2[i] = int(label.LabelID) + } + if err := h.lRepo.AssignLabelsToChore(c, createdChore.ID, currentUser.ID, currentUser.CircleID, labelsV2, []int{}); err != nil { + c.JSON(500, gin.H{ + "error": "Error adding labels", + }) + return + } + if err := h.choreRepo.UpdateChoreAssignees(c, choreAssignees); err != nil { c.JSON(500, gin.H{ "error": "Error adding chore assignees", @@ -440,6 +459,38 @@ func (h *Handler) editChore(c *gin.Context) { labels := strings.Join(escapedLabels, ",") stringLabels = &labels } + + // Create a map to store the existing labels for quick lookup + oldLabelsMap := make(map[int]struct{}) + for _, oldLabel := range *oldChore.LabelsV2 { + oldLabelsMap[oldLabel.ID] = struct{}{} + } + newLabelMap := make(map[int]struct{}) + for _, newLabel := range *choreReq.LabelsV2 { + newLabelMap[newLabel.LabelID] = struct{}{} + } + // check what labels need to be added and what labels need to be deleted: + labelsV2ToAdd := make([]int, 0) + labelsV2ToBeRemoved := make([]int, 0) + + for _, label := range *choreReq.LabelsV2 { + if _, ok := oldLabelsMap[label.LabelID]; !ok { + labelsV2ToAdd = append(labelsV2ToAdd, label.LabelID) + } + } + for _, oldLabel := range *oldChore.LabelsV2 { + if _, ok := newLabelMap[oldLabel.ID]; !ok { + labelsV2ToBeRemoved = append(labelsV2ToBeRemoved, oldLabel.ID) + } + } + + if err := h.lRepo.AssignLabelsToChore(c, choreReq.ID, currentUser.ID, currentUser.CircleID, labelsV2ToAdd, labelsV2ToBeRemoved); err != nil { + c.JSON(500, gin.H{ + "error": "Error adding labels", + }) + return + } + updatedChore := &chModel.Chore{ ID: choreReq.ID, Name: choreReq.Name, diff --git a/internal/chore/model/model.go b/internal/chore/model/model.go index 6e88f80..2feabad 100644 --- a/internal/chore/model/model.go +++ b/internal/chore/model/model.go @@ -37,6 +37,7 @@ type Chore struct { Notification bool `json:"notification" gorm:"column:notification"` // Whether the chore has notification NotificationMetadata *string `json:"notificationMetadata" gorm:"column:notification_meta"` // Additional notification information Labels *string `json:"labels" gorm:"column:labels"` // Labels for the chore + LabelsV2 *[]Label `json:"labelsV2" gorm:"many2many:chore_labels"` // Labels for the chore CircleID int `json:"circleId" gorm:"column:circle_id;index"` // The circle this chore is in CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"` // When the chore was created UpdatedAt time.Time `json:"updatedAt" gorm:"column:updated_at"` // When the chore was last updated @@ -83,16 +84,6 @@ type Tag struct { Name string `json:"name" gorm:"column:name;unique"` } -// type ChoreTag struct { -// ChoreID int `json:"choreId" gorm:"primaryKey;autoIncrement:false"` -// TagID int `json:"tagId" gorm:"primaryKey;autoIncrement:false"` -// } - -// type CircleTag struct { -// CircleID int `json:"circleId" gorm:"primaryKey;autoIncrement:false"` -// TagID int `json:"tagId" gorm:"primaryKey;autoIncrement:false"` -// } - type ChoreDetail struct { ID int `json:"id" gorm:"column:id"` Name string `json:"name" gorm:"column:name"` @@ -106,3 +97,18 @@ type ChoreDetail struct { Notes *string `json:"notes" gorm:"column:notes"` CreatedBy int `json:"createdBy" gorm:"column:created_by"` } + +type Label struct { + ID int `json:"id" gorm:"primary_key"` + Name string `json:"name" gorm:"column:name"` + Color string `json:"color" gorm:"column:color"` + CircleID *int `json:"-" gorm:"column:circle_id"` + CreatedBy int `json:"created_by" gorm:"column:created_by"` +} + +type ChoreLabels struct { + ChoreID int `json:"choreId" gorm:"primaryKey;autoIncrement:false"` + LabelID int `json:"labelId" gorm:"primaryKey;autoIncrement:false"` + UserID int `json:"userId" gorm:"primaryKey;autoIncrement:false"` + Label Label +} diff --git a/internal/chore/repo/repository.go b/internal/chore/repo/repository.go index cf5462c..3768fdf 100644 --- a/internal/chore/repo/repository.go +++ b/internal/chore/repo/repository.go @@ -35,7 +35,7 @@ func (r *ChoreRepository) CreateChore(c context.Context, chore *chModel.Chore) ( func (r *ChoreRepository) GetChore(c context.Context, choreID int) (*chModel.Chore, error) { var chore chModel.Chore - if err := r.db.Debug().WithContext(c).Model(&chModel.Chore{}).Preload("Assignees").Preload("ThingChore").First(&chore, choreID).Error; err != nil { + if err := r.db.Debug().WithContext(c).Model(&chModel.Chore{}).Preload("Assignees").Preload("ThingChore").Preload("LabelsV2").First(&chore, choreID).Error; err != nil { return nil, err } return &chore, nil @@ -44,7 +44,7 @@ func (r *ChoreRepository) GetChore(c context.Context, choreID int) (*chModel.Cho func (r *ChoreRepository) GetChores(c context.Context, circleID int, userID int) ([]*chModel.Chore, error) { var chores []*chModel.Chore // if err := r.db.WithContext(c).Preload("Assignees").Where("is_active = ?", true).Order("next_due_date asc").Find(&chores, "circle_id = ?", circleID).Error; err != nil { - if err := r.db.WithContext(c).Preload("Assignees").Joins("left join chore_assignees on chores.id = chore_assignees.chore_id").Where("chores.circle_id = ? AND (chores.created_by = ? OR chore_assignees.user_id = ?)", circleID, userID, userID).Group("chores.id").Order("next_due_date asc").Find(&chores, "circle_id = ?", circleID).Error; err != nil { + if err := r.db.WithContext(c).Preload("Assignees").Preload("LabelsV2").Joins("left join chore_assignees on chores.id = chore_assignees.chore_id").Where("chores.circle_id = ? AND (chores.created_by = ? OR chore_assignees.user_id = ?)", circleID, userID, userID).Group("chores.id").Order("next_due_date asc").Find(&chores, "circle_id = ?", circleID).Error; err != nil { return nil, err } return chores, nil diff --git a/internal/database/database.go b/internal/database/database.go index 7d4681d..e74d4f8 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -33,13 +33,11 @@ func NewDatabase(cfg *config.Config) (*gorm.DB, error) { } default: - path := os.Getenv("DT_SQLITE_PATH") if path == "" { - db, err = gorm.Open(sqlite.Open("donetick.db"), &gorm.Config{}) - } else { - db, err = gorm.Open(sqlite.Open(path), &gorm.Config{}) + path = "donetick.db" } + db, err = gorm.Open(sqlite.Open(path), &gorm.Config{}) } diff --git a/internal/database/migration.go b/internal/database/migration.go new file mode 100644 index 0000000..1546310 --- /dev/null +++ b/internal/database/migration.go @@ -0,0 +1,72 @@ +package database + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + + "donetick.com/core/config" + chModel "donetick.com/core/internal/chore/model" + cModel "donetick.com/core/internal/circle/model" + nModel "donetick.com/core/internal/notifier/model" + tModel "donetick.com/core/internal/thing/model" + uModel "donetick.com/core/internal/user/model" // Pure go SQLite driver, checkout https://github.com/glebarez/sqlite for details + migrations "donetick.com/core/migrations" + migrate "github.com/rubenv/sql-migrate" + "gorm.io/gorm" +) + +func Migration(db *gorm.DB) error { + if err := db.AutoMigrate(uModel.User{}, chModel.Chore{}, + chModel.ChoreHistory{}, + cModel.Circle{}, + cModel.UserCircle{}, + chModel.ChoreAssignees{}, + nModel.Notification{}, + uModel.UserPasswordReset{}, + tModel.Thing{}, + tModel.ThingChore{}, + tModel.ThingHistory{}, + uModel.APIToken{}, + uModel.UserNotificationTarget{}, + chModel.Label{}, + chModel.ChoreLabels{}, + migrations.Migration{}, + ); err != nil { + return err + } + + return nil +} + +func MigrationScripts(gormDB *gorm.DB, cfg *config.Config) error { + migrations := &migrate.FileMigrationSource{ + Dir: migrationDir(), + } + + path := os.Getenv("DT_SQLITE_PATH") + if path == "" { + path = "donetick.db" + } + + db, err := gormDB.DB() + if err != nil { + return err + } + + n, err := migrate.Exec(db, "sqlite3", migrations, migrate.Up) + if err != nil { + return err + } + fmt.Printf("Applied %d migrations!\n", n) + return nil +} + +func migrationDir() string { + _, filename, _, ok := runtime.Caller(1) + if !ok { + return "" + } + return filepath.Join(filepath.Dir(filename), "../../migrations") +} diff --git a/internal/label/handler.go b/internal/label/handler.go new file mode 100644 index 0000000..655352a --- /dev/null +++ b/internal/label/handler.go @@ -0,0 +1,176 @@ +package label + +import ( + "strconv" + + auth "donetick.com/core/internal/authorization" + lModel "donetick.com/core/internal/label/model" + lRepo "donetick.com/core/internal/label/repo" + jwt "github.com/appleboy/gin-jwt/v2" + "github.com/gin-gonic/gin" +) + +type LabelReq struct { + Name string `json:"name" binding:"required"` + Color string `json:"color"` +} + +type UpdateLabelReq struct { + ID int `json:"id" binding:"required"` + LabelReq +} + +type Handler struct { + lRepo *lRepo.LabelRepository +} + +func NewHandler(lRepo *lRepo.LabelRepository) *Handler { + return &Handler{ + lRepo: lRepo, + } +} + +func (h *Handler) getLabels(c *gin.Context) { + // get current user: + currentUser, ok := auth.CurrentUser(c) + if !ok { + c.JSON(500, gin.H{ + "error": "Error getting current user", + }) + return + } + + labels, err := h.lRepo.GetUserLabels(c, currentUser.ID, currentUser.CircleID) + if err != nil { + c.JSON(500, gin.H{ + "error": "Error getting labels", + }) + return + } + c.JSON(200, + labels, + ) +} + +func (h *Handler) createLabel(c *gin.Context) { + // get current user: + currentUser, ok := auth.CurrentUser(c) + if !ok { + c.JSON(500, gin.H{ + "error": "Error getting current user", + }) + return + } + + var req LabelReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{ + "error": "Error binding label", + }) + return + } + + label := &lModel.Label{ + Name: req.Name, + Color: req.Color, + CreatedBy: currentUser.ID, + } + if err := h.lRepo.CreateLabels(c, []*lModel.Label{label}); err != nil { + c.JSON(500, gin.H{ + "error": "Error creating label", + }) + return + } + + c.JSON(200, gin.H{ + "res": label, + }) +} + +func (h *Handler) updateLabel(c *gin.Context) { + currentUser, ok := auth.CurrentUser(c) + if !ok { + c.JSON(500, gin.H{ + "error": "Error getting current user", + }) + return + } + + var req UpdateLabelReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{ + "error": "Error binding label", + }) + return + } + + label := &lModel.Label{ + Name: req.Name, + Color: req.Color, + ID: req.ID, + } + if err := h.lRepo.UpdateLabel(c, currentUser.ID, label); err != nil { + c.JSON(500, gin.H{ + "error": "Error updating label", + }) + return + } + + c.JSON(200, gin.H{ + "res": label, + }) +} + +func (h *Handler) deleteLabel(c *gin.Context) { + currentUser, ok := auth.CurrentUser(c) + // read label id from path: + + if !ok { + c.JSON(500, gin.H{ + "error": "Error getting current user", + }) + return + } + + labelIDRaw := c.Param("id") + if labelIDRaw == "" { + c.JSON(400, gin.H{ + "error": "Label ID is required", + }) + return + } + + labelID, err := strconv.Atoi(labelIDRaw) + if err != nil { + c.JSON(400, gin.H{ + "error": "Invalid label ID", + }) + return + } + + // unassociate label from all chores: + if err := h.lRepo.DeassignLabelFromAllChoreAndDelete(c, currentUser.ID, labelID); err != nil { + c.JSON(500, gin.H{ + "error": "Error unassociating label from chores", + }) + return + } + + c.JSON(200, gin.H{ + "res": "Label deleted", + }) + +} + +func Routes(r *gin.Engine, h *Handler, auth *jwt.GinJWTMiddleware) { + + labelRoutes := r.Group("labels") + labelRoutes.Use(auth.MiddlewareFunc()) + { + labelRoutes.GET("", h.getLabels) + labelRoutes.POST("", h.createLabel) + labelRoutes.PUT("", h.updateLabel) + labelRoutes.DELETE("/:id", h.deleteLabel) + } + +} diff --git a/internal/label/model/model.go b/internal/label/model/model.go new file mode 100644 index 0000000..45084ec --- /dev/null +++ b/internal/label/model/model.go @@ -0,0 +1,9 @@ +package model + +type Label struct { + ID int `json:"id" gorm:"primary_key"` + Name string `json:"name" gorm:"column:name"` + Color string `json:"color" gorm:"column:color"` + CircleID *int `json:"-" gorm:"column:circle_id"` + CreatedBy int `json:"created_by" gorm:"column:created_by"` +} diff --git a/internal/label/repo/repository.go b/internal/label/repo/repository.go new file mode 100644 index 0000000..e27bbe7 --- /dev/null +++ b/internal/label/repo/repository.go @@ -0,0 +1,163 @@ +package chore + +import ( + "context" + "errors" + + config "donetick.com/core/config" + chModel "donetick.com/core/internal/chore/model" + lModel "donetick.com/core/internal/label/model" + "donetick.com/core/logging" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type LabelRepository struct { + db *gorm.DB +} + +func NewLabelRepository(db *gorm.DB, cfg *config.Config) *LabelRepository { + return &LabelRepository{db: db} +} + +func (r *LabelRepository) GetUserLabels(ctx context.Context, userID int, circleID int) ([]*lModel.Label, error) { + var labels []*lModel.Label + if err := r.db.WithContext(ctx).Where("created_by = ? OR circle_id = ? ", userID, circleID).Find(&labels).Error; err != nil { + return nil, err + } + return labels, nil +} + +func (r *LabelRepository) CreateLabels(ctx context.Context, labels []*lModel.Label) error { + if err := r.db.WithContext(ctx).Create(&labels).Error; err != nil { + return err + } + return nil +} + +func (r *LabelRepository) GetLabelsByIDs(ctx context.Context, ids []int) ([]*lModel.Label, error) { + var labels []*lModel.Label + if err := r.db.WithContext(ctx).Where("id IN (?)", ids).Find(&labels).Error; err != nil { + return nil, err + } + return labels, nil +} + +func (r *LabelRepository) isLabelsAssignableByUser(ctx context.Context, userID int, circleID int, toBeAdded []int, toBeRemoved []int) bool { + // combine both toBeAdded and toBeRemoved: + labelIDs := append(toBeAdded, toBeRemoved...) + + log := logging.FromContext(ctx) + var count int64 + if err := r.db.WithContext(ctx).Model(&lModel.Label{}).Where("id IN (?) AND (created_by = ? OR circle_id = ?) ", labelIDs, userID, circleID).Count(&count).Error; err != nil { + log.Error(err) + return false + } + return count == int64(len(labelIDs)) +} + +func (r *LabelRepository) AssignLabelsToChore(ctx context.Context, choreID int, userID int, circleID int, toBeAdded []int, toBeRemoved []int) error { + if len(toBeAdded) < 1 && len(toBeRemoved) < 1 { + return nil + } + if !r.isLabelsAssignableByUser(ctx, userID, circleID, toBeAdded, toBeRemoved) { + return errors.New("labels are not assignable by user") + } + + var choreLabels []*chModel.ChoreLabels + for _, labelID := range toBeAdded { + choreLabels = append(choreLabels, &chModel.ChoreLabels{ + ChoreID: choreID, + LabelID: labelID, + UserID: userID, + }) + } + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + + if err := r.db.WithContext(ctx).Where("chore_id = ? AND user_id = ? AND label_id IN (?)", choreID, userID, toBeRemoved).Delete(&chModel.ChoreLabels{}).Error; err != nil { + return err + } + + if err := r.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "chore_id"}, {Name: "label_id"}, {Name: "user_id"}}, + DoNothing: true, + }).Create(&choreLabels).Error; err != nil { + return err + } + return nil + }) +} + +func (r *LabelRepository) DeassignLabelsFromChore(ctx context.Context, choreID int, userID int, labelIDs []int) error { + if err := r.db.WithContext(ctx).Where("chore_id = ? AND user_id = ? AND label_id IN (?)", choreID, userID, labelIDs).Delete(&chModel.ChoreLabels{}).Error; err != nil { + return err + } + return nil +} + +func (r *LabelRepository) DeassignLabelFromAllChoreAndDelete(ctx context.Context, userID int, labelID int) error { + // create one transaction to confirm if the label is owned by the user then delete all ChoreLabels record for this label: + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + log := logging.FromContext(ctx) + var labelCount int64 + if err := tx.Model(&lModel.Label{}).Where("id = ? AND created_by = ?", labelID, userID).Count(&labelCount).Error; err != nil { + log.Debug(err) + return err + } + if labelCount < 1 { + return errors.New("label is not owned by user") + } + + if err := tx.Where("label_id = ?", labelID).Delete(&chModel.ChoreLabels{}).Error; err != nil { + log.Debug("Error deleting chore labels") + return err + } + // delete the actual label: + if err := tx.Where("id = ?", labelID).Delete(&lModel.Label{}).Error; err != nil { + log.Debug("Error deleting label") + return err + } + + return nil + }) +} + +func (r *LabelRepository) isLabelsOwner(ctx context.Context, userID int, labelIDs []int) bool { + var count int64 + r.db.WithContext(ctx).Model(&lModel.Label{}).Where("id IN (?) AND user_id = ?", labelIDs, userID).Count(&count) + return count == 1 +} + +func (r *LabelRepository) DeleteLabels(ctx context.Context, userID int, ids []int) error { + // remove all ChoreLabels record for this: + if r.isLabelsOwner(ctx, userID, ids) { + return errors.New("labels are not owned by user") + } + + tx := r.db.WithContext(ctx).Begin() + + if err := tx.Where("label_id IN (?)", ids).Delete(&chModel.ChoreLabels{}).Error; err != nil { + tx.Rollback() + return err + } + + if err := tx.Where("id IN (?)", ids).Delete(&lModel.Label{}).Error; err != nil { + tx.Rollback() + return err + } + + if err := tx.Commit().Error; err != nil { + tx.Rollback() + return err + } + + return nil +} + +func (r *LabelRepository) UpdateLabel(ctx context.Context, userID int, label *lModel.Label) error { + + if err := r.db.WithContext(ctx).Model(&lModel.Label{}).Where("id = ? and created_by = ?", label.ID, userID).Updates(label).Error; err != nil { + return err + } + return nil +} diff --git a/main.go b/main.go index e49f8ab..399060a 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "donetick.com/core/config" "donetick.com/core/frontend" + "donetick.com/core/migrations" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "go.uber.org/fx" @@ -21,6 +22,9 @@ import ( cRepo "donetick.com/core/internal/circle/repo" "donetick.com/core/internal/database" "donetick.com/core/internal/email" + label "donetick.com/core/internal/label" + lRepo "donetick.com/core/internal/label/repo" + notifier "donetick.com/core/internal/notifier" nRepo "donetick.com/core/internal/notifier/repo" nps "donetick.com/core/internal/notifier/service" @@ -31,7 +35,6 @@ import ( uRepo "donetick.com/core/internal/user/repo" "donetick.com/core/internal/utils" "donetick.com/core/logging" - "donetick.com/core/migration" ) func main() { @@ -75,6 +78,10 @@ func main() { // things fx.Provide(tRepo.NewThingRepository), + // Labels: + fx.Provide(lRepo.NewLabelRepository), + fx.Provide(label.NewHandler), + fx.Provide(thing.NewWebhook), fx.Provide(thing.NewHandler), @@ -87,6 +94,7 @@ func main() { circle.Routes, thing.Routes, thing.Webhooks, + label.Routes, frontend.Routes, func(r *gin.Engine) {}, @@ -127,7 +135,12 @@ func newServer(lc fx.Lifecycle, cfg *config.Config, db *gorm.DB, notifier *notif lc.Append(fx.Hook{ OnStart: func(context.Context) error { if cfg.Database.Migration { - migration.Migration(db) + database.Migration(db) + migrations.Run(context.Background(), db) + err := database.MigrationScripts(db, cfg) + if err != nil { + panic(err) + } } notifier.Start(context.Background()) go func() { diff --git a/migration/migration.go b/migration/migration.go deleted file mode 100644 index 9d020d2..0000000 --- a/migration/migration.go +++ /dev/null @@ -1,30 +0,0 @@ -package migration - -import ( - chModel "donetick.com/core/internal/chore/model" - cModel "donetick.com/core/internal/circle/model" - nModel "donetick.com/core/internal/notifier/model" - tModel "donetick.com/core/internal/thing/model" - uModel "donetick.com/core/internal/user/model" - "gorm.io/gorm" -) - -func Migration(db *gorm.DB) error { - if err := db.AutoMigrate(uModel.User{}, chModel.Chore{}, - chModel.ChoreHistory{}, - cModel.Circle{}, - cModel.UserCircle{}, - chModel.ChoreAssignees{}, - nModel.Notification{}, - uModel.UserPasswordReset{}, - tModel.Thing{}, - tModel.ThingChore{}, - tModel.ThingHistory{}, - uModel.APIToken{}, - uModel.UserNotificationTarget{}, - ); err != nil { - return err - } - - return nil -} diff --git a/migrations/20241123_migrate_label_to_labels_v2.go b/migrations/20241123_migrate_label_to_labels_v2.go new file mode 100644 index 0000000..b9ede95 --- /dev/null +++ b/migrations/20241123_migrate_label_to_labels_v2.go @@ -0,0 +1,123 @@ +package migrations + +import ( + "context" + "strings" + + "donetick.com/core/logging" + "gorm.io/gorm" +) + +type MigrateLabels20241123 struct{} + +func (m MigrateLabels20241123) ID() string { + return "20241123_migrate_label_to_labels_v2" +} + +func (m MigrateLabels20241123) Description() string { + return `Migrate label to labels v2 table, Allow more advanced features with labels like assign color` +} + +func (m MigrateLabels20241123) Down(ctx context.Context, db *gorm.DB) error { + return nil +} + +func (m MigrateLabels20241123) Up(ctx context.Context, db *gorm.DB) error { + log := logging.FromContext(ctx) + + type Label struct { + ID int `gorm:"column:id;primary_key"` + Name string `gorm:"column:name"` + CreatedBy int `gorm:"column:created_by"` + CircleID *int `gorm:"column:circle_id"` + ChoresId []int `gorm:"-"` + } + + type Chore struct { + Labels *string `gorm:"column:labels"` + ID int `gorm:"column:id;primary_key"` + CircleID int `gorm:"column:circle_id"` + CreatedBy int `gorm:"column:created_by"` + } + + type ChoreLabel struct { + ChoreID int `gorm:"column:chore_id"` + LabelID int `gorm:"column:label_id"` + UserID int `gorm:"column:user_id"` + } + + // Start a transaction + return db.Transaction(func(tx *gorm.DB) error { + // Get all chores with labels + var choreRecords []Chore + if err := tx.Table("chores").Select("id, labels, circle_id, created_by").Find(&choreRecords).Error; err != nil { + log.Errorf("Failed to fetch chores with label: %v", err) + return err + } + + // Map to store new labels + newLabelsMap := make(map[string]Label) + for _, choreRecord := range choreRecords { + if choreRecord.Labels != nil { + labels := strings.Split(*choreRecord.Labels, ",") + for _, label := range labels { + label = strings.TrimSpace(label) + if _, ok := newLabelsMap[label]; !ok { + newLabelsMap[label] = Label{ + Name: label, + CreatedBy: choreRecord.CreatedBy, + CircleID: &choreRecord.CircleID, + ChoresId: []int{choreRecord.ID}, + } + } else { + labelToUpdate := newLabelsMap[label] + labelToUpdate.ChoresId = append(labelToUpdate.ChoresId, choreRecord.ID) + newLabelsMap[label] = labelToUpdate + } + } + } + } + + // Insert new labels and update chore_labels + for labelName, label := range newLabelsMap { + // Check if the label already exists + var existingLabel Label + if err := tx.Table("labels").Where("name = ? AND created_by = ? AND circle_id = ?", labelName, label.CreatedBy, label.CircleID).First(&existingLabel).Error; err != nil { + if err == gorm.ErrRecordNotFound { + // Insert new label + if err := tx.Table("labels").Create(&label).Error; err != nil { + log.Errorf("Failed to insert new label: %v", err) + return err + } + existingLabel = label + } else { + log.Errorf("Failed to check existing label: %v", err) + return err + } + } + + // Prepare chore_labels for batch insertion + var choreLabels []ChoreLabel + for _, choreId := range label.ChoresId { + choreLabels = append(choreLabels, ChoreLabel{ + ChoreID: choreId, + LabelID: existingLabel.ID, + UserID: label.CreatedBy, + }) + } + + // Batch insert chore_labels + if err := tx.Table("chore_labels").Create(&choreLabels).Error; err != nil { + log.Errorf("Failed to insert chore labels: %v", err) + return err + } + } + + return nil + }) +} + +// Register this migration +func init() { + Register(MigrateLabels20241123{}) +} diff --git a/migrations/base.go b/migrations/base.go new file mode 100644 index 0000000..9a2747b --- /dev/null +++ b/migrations/base.go @@ -0,0 +1,100 @@ +package migrations + +import ( + "context" + "time" + + "donetick.com/core/logging" + "gorm.io/gorm" +) + +type Migration struct { + // DB Representation for migration table, mainly used for tracking the migration status. + ID string `json:"id" gorm:"primary_key"` + Description string `json:"description" gorm:"column:description"` + AppliedAt time.Time `json:"appliedAt" gorm:"column:applied_at"` +} + +type MigrationScript interface { + // Actual migration script interface which needs to be implemented by each migration script. + Description() string + ID() string + Up(ctx context.Context, db *gorm.DB) error + Down(ctx context.Context, db *gorm.DB) error +} + +var registry []MigrationScript + +// Register a migration +func Register(migration MigrationScript) { + registry = append(registry, migration) +} + +// Sort the migrations by ID +func sortMigrations(migrations []MigrationScript) []MigrationScript { + for i := 0; i < len(migrations); i++ { + for j := i + 1; j < len(migrations); j++ { + if migrations[i].ID() > migrations[j].ID() { + migrations[i], migrations[j] = migrations[j], migrations[i] + } + } + } + return migrations +} + +// Run pending migrations +func Run(ctx context.Context, db *gorm.DB) error { + log := logging.FromContext(ctx) + // Confirm the migrations table exists :) + if err := db.AutoMigrate(&Migration{}); err != nil { + return err + } + + // Sort the registry by ID + registry = sortMigrations(registry) + var successfulCount int + var skippedCount int + for _, migration := range registry { + // Check if migration is already applied + var count int64 + db.Model(&Migration{}).Where("id = ?", migration.ID()).Count(&count) + if count > 0 { + skippedCount++ + log.Debug("Skipping migration %s as it is already applied", migration.ID()) + continue + } + + // Run the migration + log.Infof("Applying migration: %s", migration.ID()) + if err := migration.Up(ctx, db); err != nil { + + log.Errorf("Failed to apply migration %s: %s", migration.ID(), err) + if err := migration.Down(ctx, db); err != nil { + log.Errorf("Failed to rollback migration %s: %s\n", migration.ID(), err) + } + return err + } + + // Record the migration + record := Migration{ + ID: migration.ID(), + Description: migration.Description(), + AppliedAt: time.Now().UTC(), + } + + if err := db.Create(&record).Error; err != nil { + log.Errorf("Failed to record migration %s: %s\n", migration.ID(), err) + return err + } + + successfulCount++ + } + + if len(registry) == 0 { + log.Info("Migratons: No pending migrations") + } else { + var failedCount = len(registry) - successfulCount - skippedCount + log.Infof("Migrations: %d successful, %d failed, %d skipped, %d total in registry", successfulCount, failedCount, skippedCount, len(registry)) + } + return nil +}