aboutsummaryrefslogtreecommitdiffstats
path: root/internal/thing
diff options
context:
space:
mode:
Diffstat (limited to 'internal/thing')
-rw-r--r--internal/thing/handler.go281
-rw-r--r--internal/thing/helper.go57
-rw-r--r--internal/thing/model/model.go30
-rw-r--r--internal/thing/repo/repository.go117
-rw-r--r--internal/thing/webhook.go175
5 files changed, 660 insertions, 0 deletions
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)
+ }
+
+}