From c13dd9addbf89f716e4ef5cfdf1d673139ffcb68 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 30 Jun 2024 21:41:41 -0400 Subject: Move to Donetick Org, first commit --- internal/thing/handler.go | 281 ++++++++++++++++++++++++++++++++++++++ internal/thing/helper.go | 57 ++++++++ internal/thing/model/model.go | 30 ++++ internal/thing/repo/repository.go | 117 ++++++++++++++++ internal/thing/webhook.go | 175 ++++++++++++++++++++++++ 5 files changed, 660 insertions(+) create mode 100644 internal/thing/handler.go create mode 100644 internal/thing/helper.go create mode 100644 internal/thing/model/model.go create mode 100644 internal/thing/repo/repository.go create mode 100644 internal/thing/webhook.go (limited to 'internal/thing') diff --git a/internal/thing/handler.go b/internal/thing/handler.go new file mode 100644 index 0000000..5166157 --- /dev/null +++ b/internal/thing/handler.go @@ -0,0 +1,281 @@ +package thing + +import ( + "strconv" + "time" + + auth "donetick.com/core/internal/authorization" + chRepo "donetick.com/core/internal/chore/repo" + cRepo "donetick.com/core/internal/circle/repo" + nRepo "donetick.com/core/internal/notifier/repo" + nps "donetick.com/core/internal/notifier/service" + tModel "donetick.com/core/internal/thing/model" + tRepo "donetick.com/core/internal/thing/repo" + "donetick.com/core/logging" + jwt "github.com/appleboy/gin-jwt/v2" + "github.com/gin-gonic/gin" +) + +type Handler struct { + choreRepo *chRepo.ChoreRepository + circleRepo *cRepo.CircleRepository + nPlanner *nps.NotificationPlanner + nRepo *nRepo.NotificationRepository + tRepo *tRepo.ThingRepository +} + +type ThingRequest struct { + ID int `json:"id"` + Name string `json:"name" binding:"required"` + Type string `json:"type" binding:"required"` + State string `json:"state"` +} + +func NewHandler(cr *chRepo.ChoreRepository, circleRepo *cRepo.CircleRepository, + np *nps.NotificationPlanner, nRepo *nRepo.NotificationRepository, tRepo *tRepo.ThingRepository) *Handler { + return &Handler{ + choreRepo: cr, + circleRepo: circleRepo, + nPlanner: np, + nRepo: nRepo, + tRepo: tRepo, + } +} + +func (h *Handler) CreateThing(c *gin.Context) { + log := logging.FromContext(c) + currentUser, ok := auth.CurrentUser(c) + if !ok { + c.JSON(401, gin.H{"error": "Unauthorized"}) + return + } + + var req ThingRequest + if err := c.BindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + thing := &tModel.Thing{ + Name: req.Name, + UserID: currentUser.ID, + Type: req.Type, + State: req.State, + } + if !isValidThingState(thing) { + c.JSON(400, gin.H{"error": "Invalid state"}) + return + } + log.Debug("Creating thing", thing) + if err := h.tRepo.UpsertThing(c, thing); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(201, gin.H{ + "res": thing, + }) +} + +func (h *Handler) UpdateThingState(c *gin.Context) { + currentUser, ok := auth.CurrentUser(c) + if !ok { + c.JSON(401, gin.H{"error": "Unauthorized"}) + return + } + + thingIDRaw := c.Param("id") + thingID, err := strconv.Atoi(thingIDRaw) + if err != nil { + c.JSON(400, gin.H{"error": "Invalid thing id"}) + return + } + + val := c.Query("value") + if val == "" { + c.JSON(400, gin.H{"error": "state or increment query param is required"}) + return + } + thing, err := h.tRepo.GetThingByID(c, thingID) + if thing.UserID != currentUser.ID { + c.JSON(403, gin.H{"error": "Forbidden"}) + return + } + if err != nil { + c.JSON(500, gin.H{"error": "Unable to find thing"}) + return + } + thing.State = val + if !isValidThingState(thing) { + c.JSON(400, gin.H{"error": "Invalid state"}) + return + } + + if err := h.tRepo.UpdateThingState(c, thing); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + + shouldReturn := EvaluateTriggerAndScheduleDueDate(h, c, thing) + if shouldReturn { + return + } + + c.JSON(200, gin.H{ + "res": thing, + }) +} + +func EvaluateTriggerAndScheduleDueDate(h *Handler, c *gin.Context, thing *tModel.Thing) bool { + thingChores, err := h.tRepo.GetThingChoresByThingId(c, thing.ID) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return true + } + for _, tc := range thingChores { + triggered := EvaluateThingChore(tc, thing.State) + if triggered { + h.choreRepo.SetDueDateIfNotExisted(c, tc.ChoreID, time.Now().UTC()) + } + } + return false +} + +func (h *Handler) UpdateThing(c *gin.Context) { + currentUser, ok := auth.CurrentUser(c) + if !ok { + c.JSON(401, gin.H{"error": "Unauthorized"}) + return + } + + var req ThingRequest + if err := c.BindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + + thing, err := h.tRepo.GetThingByID(c, req.ID) + + if err != nil { + c.JSON(500, gin.H{"error": "Unable to find thing"}) + return + } + if thing.UserID != currentUser.ID { + c.JSON(403, gin.H{"error": "Forbidden"}) + return + } + thing.Name = req.Name + thing.Type = req.Type + if req.State != "" { + thing.State = req.State + if !isValidThingState(thing) { + c.JSON(400, gin.H{"error": "Invalid state"}) + return + } + } + + if err := h.tRepo.UpsertThing(c, thing); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{ + "res": thing, + }) +} + +func (h *Handler) GetAllThings(c *gin.Context) { + currentUser, ok := auth.CurrentUser(c) + if !ok { + c.JSON(401, gin.H{"error": "Unauthorized"}) + return + } + + things, err := h.tRepo.GetUserThings(c, currentUser.ID) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{ + "res": things, + }) +} + +func (h *Handler) GetThingHistory(c *gin.Context) { + currentUser, ok := auth.CurrentUser(c) + if !ok { + c.JSON(401, gin.H{"error": "Unauthorized"}) + return + } + + thingIDRaw := c.Param("id") + thingID, err := strconv.Atoi(thingIDRaw) + if err != nil { + c.JSON(400, gin.H{"error": "Invalid thing id"}) + return + } + + thing, err := h.tRepo.GetThingByID(c, thingID) + if err != nil { + c.JSON(500, gin.H{"error": "Unable to find thing"}) + return + } + if thing.UserID != currentUser.ID { + c.JSON(403, gin.H{"error": "Forbidden"}) + return + } + offsetRaw := c.Query("offset") + offset, err := strconv.Atoi(offsetRaw) + if err != nil { + c.JSON(400, gin.H{"error": "Invalid offset"}) + return + } + history, err := h.tRepo.GetThingHistoryWithOffset(c, thingID, offset, 10) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{ + "res": history, + }) +} + +func (h *Handler) DeleteThing(c *gin.Context) { + currentUser, ok := auth.CurrentUser(c) + if !ok { + c.JSON(401, gin.H{"error": "Unauthorized"}) + return + } + + thingIDRaw := c.Param("id") + thingID, err := strconv.Atoi(thingIDRaw) + if err != nil { + c.JSON(400, gin.H{"error": "Invalid thing id"}) + return + } + + thing, err := h.tRepo.GetThingByID(c, thingID) + if err != nil { + c.JSON(500, gin.H{"error": "Unable to find thing"}) + return + } + if thing.UserID != currentUser.ID { + c.JSON(403, gin.H{"error": "Forbidden"}) + return + } + if err := h.tRepo.DeleteThing(c, thingID); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{}) +} +func Routes(r *gin.Engine, h *Handler, auth *jwt.GinJWTMiddleware) { + + thingRoutes := r.Group("things") + thingRoutes.Use(auth.MiddlewareFunc()) + { + thingRoutes.POST("", h.CreateThing) + thingRoutes.PUT("/:id/state", h.UpdateThingState) + thingRoutes.PUT("", h.UpdateThing) + thingRoutes.GET("", h.GetAllThings) + thingRoutes.GET("/:id/history", h.GetThingHistory) + thingRoutes.DELETE("/:id", h.DeleteThing) + } +} diff --git a/internal/thing/helper.go b/internal/thing/helper.go new file mode 100644 index 0000000..c3941ee --- /dev/null +++ b/internal/thing/helper.go @@ -0,0 +1,57 @@ +package thing + +import ( + "strconv" + + tModel "donetick.com/core/internal/thing/model" +) + +func isValidThingState(thing *tModel.Thing) bool { + switch thing.Type { + case "number": + _, err := strconv.Atoi(thing.State) + return err == nil + case "text": + return true + case "boolean": + return thing.State == "true" || thing.State == "false" + default: + return false + } +} + +func EvaluateThingChore(tchore *tModel.ThingChore, newState string) bool { + if tchore.Condition == "" { + return newState == tchore.TriggerState + } + + switch tchore.Condition { + case "eq": + return newState == tchore.TriggerState + case "neq": + return newState != tchore.TriggerState + } + + newStateInt, err := strconv.Atoi(newState) + if err != nil { + return false + } + TargetStateInt, err := strconv.Atoi(tchore.TriggerState) + if err != nil { + return false + } + + switch tchore.Condition { + case "gt": + return newStateInt > TargetStateInt + case "lt": + return newStateInt < TargetStateInt + case "gte": + return newStateInt >= TargetStateInt + case "lte": + return newStateInt <= TargetStateInt + default: + return newState == tchore.TriggerState + } + +} diff --git a/internal/thing/model/model.go b/internal/thing/model/model.go new file mode 100644 index 0000000..1780c64 --- /dev/null +++ b/internal/thing/model/model.go @@ -0,0 +1,30 @@ +package model + +import "time" + +type Thing struct { + ID int `json:"id" gorm:"primary_key"` + UserID int `json:"userID" gorm:"column:user_id"` + CircleID int `json:"circleId" gorm:"column:circle_id"` + Name string `json:"name" gorm:"column:name"` + State string `json:"state" gorm:"column:state"` + Type string `json:"type" gorm:"column:type"` + ThingChores []ThingChore `json:"thingChores" gorm:"foreignkey:ThingID;references:ID"` + UpdatedAt *time.Time `json:"updatedAt" gorm:"column:updated_at"` + CreatedAt *time.Time `json:"createdAt" gorm:"column:created_at"` +} + +type ThingHistory struct { + ID int `json:"id" gorm:"primary_key"` + ThingID int `json:"thingId" gorm:"column:thing_id"` + State string `json:"state" gorm:"column:state"` + UpdatedAt *time.Time `json:"updatedAt" gorm:"column:updated_at"` + CreatedAt *time.Time `json:"createdAt" gorm:"column:created_at"` +} + +type ThingChore struct { + ThingID int `json:"thingId" gorm:"column:thing_id;primaryKey;uniqueIndex:idx_thing_user"` + ChoreID int `json:"choreId" gorm:"column:chore_id;primaryKey;uniqueIndex:idx_thing_user"` + TriggerState string `json:"triggerState" gorm:"column:trigger_state"` + Condition string `json:"condition" gorm:"column:condition"` +} diff --git a/internal/thing/repo/repository.go b/internal/thing/repo/repository.go new file mode 100644 index 0000000..8b2dbaa --- /dev/null +++ b/internal/thing/repo/repository.go @@ -0,0 +1,117 @@ +package chore + +import ( + "context" + "time" + + config "donetick.com/core/config" + tModel "donetick.com/core/internal/thing/model" + "gorm.io/gorm" +) + +type ThingRepository struct { + db *gorm.DB + dbType string +} + +func NewThingRepository(db *gorm.DB, cfg *config.Config) *ThingRepository { + return &ThingRepository{db: db, dbType: cfg.Database.Type} +} + +func (r *ThingRepository) UpsertThing(c context.Context, thing *tModel.Thing) error { + return r.db.WithContext(c).Model(&thing).Save(thing).Error +} + +func (r *ThingRepository) UpdateThingState(c context.Context, thing *tModel.Thing) error { + // update the state of the thing where the id is the same: + if err := r.db.WithContext(c).Model(&thing).Where("id = ?", thing.ID).Updates(map[string]interface{}{ + "state": thing.State, + "updated_at": time.Now().UTC(), + }).Error; err != nil { + return err + } + // Create history Record of the thing : + createdAt := time.Now().UTC() + thingHistory := &tModel.ThingHistory{ + ThingID: thing.ID, + State: thing.State, + CreatedAt: &createdAt, + UpdatedAt: &createdAt, + } + + if err := r.db.WithContext(c).Create(thingHistory).Error; err != nil { + return err + } + + return nil +} +func (r *ThingRepository) GetThingByID(c context.Context, thingID int) (*tModel.Thing, error) { + var thing tModel.Thing + if err := r.db.WithContext(c).Model(&tModel.Thing{}).Preload("ThingChores").First(&thing, thingID).Error; err != nil { + return nil, err + } + return &thing, nil +} + +func (r *ThingRepository) GetThingByChoreID(c context.Context, choreID int) (*tModel.Thing, error) { + var thing tModel.Thing + if err := r.db.WithContext(c).Model(&tModel.Thing{}).Joins("left join thing_chores on things.id = thing_chores.thing_id").First(&thing, "thing_chores.chore_id = ?", choreID).Error; err != nil { + return nil, err + } + return &thing, nil +} + +func (r *ThingRepository) AssociateThingWithChore(c context.Context, thingID int, choreID int, triggerState string, condition string) error { + + return r.db.WithContext(c).Save(&tModel.ThingChore{ThingID: thingID, ChoreID: choreID, TriggerState: triggerState, Condition: condition}).Error +} + +func (r *ThingRepository) DissociateThingWithChore(c context.Context, thingID int, choreID int) error { + return r.db.WithContext(c).Where("thing_id = ? AND chore_id = ?", thingID, choreID).Delete(&tModel.ThingChore{}).Error +} + +func (r *ThingRepository) GetThingHistoryWithOffset(c context.Context, thingID int, offset int, limit int) ([]*tModel.ThingHistory, error) { + var thingHistory []*tModel.ThingHistory + if err := r.db.WithContext(c).Model(&tModel.ThingHistory{}).Where("thing_id = ?", thingID).Order("created_at desc").Offset(offset).Limit(limit).Find(&thingHistory).Error; err != nil { + return nil, err + } + return thingHistory, nil +} + +func (r *ThingRepository) GetUserThings(c context.Context, userID int) ([]*tModel.Thing, error) { + var things []*tModel.Thing + if err := r.db.WithContext(c).Model(&tModel.Thing{}).Where("user_id = ?", userID).Find(&things).Error; err != nil { + return nil, err + } + return things, nil +} + +func (r *ThingRepository) DeleteThing(c context.Context, thingID int) error { + // one transaction to delete the thing and its history : + return r.db.WithContext(c).Transaction(func(tx *gorm.DB) error { + if err := r.db.WithContext(c).Where("thing_id = ?", thingID).Delete(&tModel.ThingHistory{}).Error; err != nil { + return err + } + if err := r.db.WithContext(c).Delete(&tModel.Thing{}, thingID).Error; err != nil { + return err + } + return nil + }) +} + +// get ThingChores by thingID: +func (r *ThingRepository) GetThingChoresByThingId(c context.Context, thingID int) ([]*tModel.ThingChore, error) { + var thingChores []*tModel.ThingChore + if err := r.db.WithContext(c).Model(&tModel.ThingChore{}).Where("thing_id = ?", thingID).Find(&thingChores).Error; err != nil { + return nil, err + } + return thingChores, nil +} + +// func (r *ThingRepository) GetChoresByThingId(c context.Context, thingID int) ([]*chModel.Chore, error) { +// var chores []*chModel.Chore +// if err := r.db.WithContext(c).Model(&chModel.Chore{}).Joins("left join thing_chores on chores.id = thing_chores.chore_id").Where("thing_chores.thing_id = ?", thingID).Find(&chores).Error; err != nil { +// return nil, err +// } +// return chores, nil +// } diff --git a/internal/thing/webhook.go b/internal/thing/webhook.go new file mode 100644 index 0000000..0d110ab --- /dev/null +++ b/internal/thing/webhook.go @@ -0,0 +1,175 @@ +package thing + +import ( + "strconv" + "time" + + "donetick.com/core/config" + chRepo "donetick.com/core/internal/chore/repo" + cRepo "donetick.com/core/internal/circle/repo" + tModel "donetick.com/core/internal/thing/model" + tRepo "donetick.com/core/internal/thing/repo" + uRepo "donetick.com/core/internal/user/repo" + "donetick.com/core/internal/utils" + "donetick.com/core/logging" + jwt "github.com/appleboy/gin-jwt/v2" + "github.com/gin-gonic/gin" +) + +type Webhook struct { + choreRepo *chRepo.ChoreRepository + circleRepo *cRepo.CircleRepository + thingRepo *tRepo.ThingRepository + userRepo *uRepo.UserRepository + tRepo *tRepo.ThingRepository +} + +func NewWebhook(cr *chRepo.ChoreRepository, circleRepo *cRepo.CircleRepository, + thingRepo *tRepo.ThingRepository, userRepo *uRepo.UserRepository, tRepo *tRepo.ThingRepository) *Webhook { + return &Webhook{ + choreRepo: cr, + circleRepo: circleRepo, + thingRepo: thingRepo, + userRepo: userRepo, + tRepo: tRepo, + } +} + +func (h *Webhook) UpdateThingState(c *gin.Context) { + thing, shouldReturn := validateUserAndThing(c, h) + if shouldReturn { + return + } + + state := c.Query("state") + if state == "" { + c.JSON(400, gin.H{"error": "Invalid state value"}) + return + } + + thing.State = state + if !isValidThingState(thing) { + c.JSON(400, gin.H{"error": "Invalid state for thing"}) + return + } + + if err := h.thingRepo.UpdateThingState(c, thing); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + c.JSON(200, gin.H{}) +} + +func (h *Webhook) ChangeThingState(c *gin.Context) { + thing, shouldReturn := validateUserAndThing(c, h) + if shouldReturn { + return + } + addRemoveRaw := c.Query("op") + setRaw := c.Query("set") + + if addRemoveRaw == "" && setRaw == "" { + c.JSON(400, gin.H{"error": "Invalid increment value"}) + return + } + var xValue int + var err error + if addRemoveRaw != "" { + xValue, err = strconv.Atoi(addRemoveRaw) + if err != nil { + c.JSON(400, gin.H{"error": "Invalid increment value"}) + return + } + currentState, err := strconv.Atoi(thing.State) + if err != nil { + c.JSON(400, gin.H{"error": "Invalid state for thing"}) + return + } + newState := currentState + xValue + thing.State = strconv.Itoa(newState) + } + if setRaw != "" { + thing.State = setRaw + } + + if !isValidThingState(thing) { + c.JSON(400, gin.H{"error": "Invalid state for thing"}) + return + } + if err := h.thingRepo.UpdateThingState(c, thing); err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return + } + + shouldReturn1 := WebhookEvaluateTriggerAndScheduleDueDate(h, c, thing) + if shouldReturn1 { + return + } + + c.JSON(200, gin.H{"state": thing.State}) +} + +func WebhookEvaluateTriggerAndScheduleDueDate(h *Webhook, c *gin.Context, thing *tModel.Thing) bool { + // handler should be interface to not duplicate both WebhookEvaluateTriggerAndScheduleDueDate and EvaluateTriggerAndScheduleDueDate + // this is bad code written Saturday at 2:25 AM + + log := logging.FromContext(c) + + thingChores, err := h.tRepo.GetThingChoresByThingId(c, thing.ID) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + return true + } + for _, tc := range thingChores { + triggered := EvaluateThingChore(tc, thing.State) + if triggered { + errSave := h.choreRepo.SetDueDate(c, tc.ChoreID, time.Now().UTC()) + if errSave != nil { + log.Error("Error setting due date for chore ", errSave) + log.Error("Chore ID ", tc.ChoreID, " Thing ID ", thing.ID, " State ", thing.State) + } + } + + } + return false +} + +func validateUserAndThing(c *gin.Context, h *Webhook) (*tModel.Thing, bool) { + apiToken := c.GetHeader("secretkey") + if apiToken == "" { + c.JSON(401, gin.H{"error": "Unauthorized"}) + return nil, true + } + thingID, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return nil, true + } + user, err := h.userRepo.GetUserByToken(c, apiToken) + if err != nil { + c.JSON(401, gin.H{"error": "Unauthorized"}) + return nil, true + } + thing, err := h.thingRepo.GetThingByID(c, thingID) + if err != nil { + c.JSON(400, gin.H{"error": "Invalid thing id"}) + return nil, true + } + if thing.UserID != user.ID { + c.JSON(401, gin.H{"error": "Unauthorized"}) + return nil, true + } + return thing, false +} + +func Webhooks(cfg *config.Config, w *Webhook, r *gin.Engine, auth *jwt.GinJWTMiddleware) { + + thingsAPI := r.Group("webhooks/things") + + thingsAPI.Use(utils.TimeoutMiddleware(cfg.Server.WriteTimeout)) + { + thingsAPI.GET("/:id/state/change", w.ChangeThingState) + thingsAPI.GET("/:id/state", w.UpdateThingState) + } + +} -- cgit