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/chore/handler.go | 974 ++++++++++++++++++++++++++++++++++++++ internal/chore/model/model.go | 72 +++ internal/chore/repo/repository.go | 216 +++++++++ internal/chore/scheduler.go | 145 ++++++ 4 files changed, 1407 insertions(+) create mode 100644 internal/chore/handler.go create mode 100644 internal/chore/model/model.go create mode 100644 internal/chore/repo/repository.go create mode 100644 internal/chore/scheduler.go (limited to 'internal/chore') diff --git a/internal/chore/handler.go b/internal/chore/handler.go new file mode 100644 index 0000000..bc90c4c --- /dev/null +++ b/internal/chore/handler.go @@ -0,0 +1,974 @@ +package chore + +import ( + "encoding/json" + "fmt" + "html" + "log" + "math" + "math/rand" + "strconv" + "strings" + "time" + + auth "donetick.com/core/internal/authorization" + chModel "donetick.com/core/internal/chore/model" + 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" + telegram "donetick.com/core/internal/notifier/telegram" + tRepo "donetick.com/core/internal/thing/repo" + uModel "donetick.com/core/internal/user/model" + "donetick.com/core/logging" + jwt "github.com/appleboy/gin-jwt/v2" + "github.com/gin-gonic/gin" +) + +type ThingTrigger struct { + ID int `json:"thingID" binding:"required"` + TriggerState string `json:"triggerState" binding:"required"` + Condition string `json:"condition"` +} + +type ChoreReq struct { + Name string `json:"name" binding:"required"` + FrequencyType string `json:"frequencyType"` + ID int `json:"id"` + DueDate string `json:"dueDate"` + Assignees []chModel.ChoreAssignees `json:"assignees"` + AssignStrategy string `json:"assignStrategy" binding:"required"` + AssignedTo int `json:"assignedTo"` + IsRolling bool `json:"isRolling"` + IsActive bool `json:"isActive"` + Frequency int `json:"frequency"` + FrequencyMetadata *chModel.FrequencyMetadata `json:"frequencyMetadata"` + Notification bool `json:"notification"` + NotificationMetadata *chModel.NotificationMetadata `json:"notificationMetadata"` + Labels []string `json:"labels"` + ThingTrigger *ThingTrigger `json:"thingTrigger"` +} +type Handler struct { + choreRepo *chRepo.ChoreRepository + circleRepo *cRepo.CircleRepository + notifier *telegram.TelegramNotifier + nPlanner *nps.NotificationPlanner + nRepo *nRepo.NotificationRepository + tRepo *tRepo.ThingRepository +} + +func NewHandler(cr *chRepo.ChoreRepository, circleRepo *cRepo.CircleRepository, nt *telegram.TelegramNotifier, + np *nps.NotificationPlanner, nRepo *nRepo.NotificationRepository, tRepo *tRepo.ThingRepository) *Handler { + return &Handler{ + choreRepo: cr, + circleRepo: circleRepo, + notifier: nt, + nPlanner: np, + nRepo: nRepo, + tRepo: tRepo, + } +} + +func (h *Handler) getChores(c *gin.Context) { + u, ok := auth.CurrentUser(c) + if !ok { + c.JSON(500, gin.H{ + "error": "Error getting current circle", + }) + return + } + chores, err := h.choreRepo.GetChores(c, u.CircleID, u.ID) + if err != nil { + c.JSON(500, gin.H{ + "error": "Error getting chores", + }) + return + } + + c.JSON(200, gin.H{ + "res": chores, + }) +} + +func (h *Handler) getChore(c *gin.Context) { + + currentUser, ok := auth.CurrentUser(c) + if !ok { + c.JSON(500, gin.H{ + "error": "Error getting current user", + }) + return + } + + rawID := c.Param("id") + id, err := strconv.Atoi(rawID) + if err != nil { + c.JSON(400, gin.H{ + "error": "Invalid ID", + }) + return + } + + chore, err := h.choreRepo.GetChore(c, id) + if err != nil { + c.JSON(500, gin.H{ + "error": "Error getting chore", + }) + return + } + isAssignee := false + + for _, assignee := range chore.Assignees { + if assignee.UserID == currentUser.ID { + isAssignee = true + break + } + } + + if currentUser.ID != chore.CreatedBy && !isAssignee { + c.JSON(403, gin.H{ + "error": "You are not allowed to view this chore", + }) + return + } + + c.JSON(200, gin.H{ + "res": chore, + }) +} + +func (h *Handler) createChore(c *gin.Context) { + logger := logging.FromContext(c) + currentUser, ok := auth.CurrentUser(c) + + logger.Debug("Create chore", "currentUser", currentUser) + if !ok { + c.JSON(500, gin.H{ + "error": "Error getting current user", + }) + return + } + // Validate chore: + var choreReq ChoreReq + if err := c.ShouldBindJSON(&choreReq); err != nil { + log.Print(err) + c.JSON(400, gin.H{ + "error": "Invalid request", + }) + return + } + + circleUsers, err := h.circleRepo.GetCircleUsers(c, currentUser.CircleID) + for _, assignee := range choreReq.Assignees { + userFound := false + for _, circleUser := range circleUsers { + if assignee.UserID == circleUser.UserID { + userFound = true + + break + + } + } + if !userFound { + c.JSON(400, gin.H{ + "error": "Assignee not found in circle", + }) + return + } + + } + if choreReq.AssignedTo <= 0 && len(choreReq.Assignees) > 0 { + // if the assigned to field is not set, randomly assign the chore to one of the assignees + choreReq.AssignedTo = choreReq.Assignees[rand.Intn(len(choreReq.Assignees))].UserID + } + + var dueDate *time.Time + + if choreReq.DueDate != "" { + rawDueDate, err := time.Parse(time.RFC3339, choreReq.DueDate) + rawDueDate = rawDueDate.UTC() + dueDate = &rawDueDate + if err != nil { + c.JSON(400, gin.H{ + "error": "Invalid date", + }) + return + } + + } else { + c.JSON(400, gin.H{ + "error": "Due date is required", + }) + return + + } + + freqencyMetadataBytes, err := json.Marshal(choreReq.FrequencyMetadata) + if err != nil { + c.JSON(500, gin.H{ + "error": "Error marshalling frequency metadata", + }) + return + } + stringFrequencyMetadata := string(freqencyMetadataBytes) + + notificationMetadataBytes, err := json.Marshal(choreReq.NotificationMetadata) + if err != nil { + c.JSON(500, gin.H{ + "error": "Error marshalling notification metadata", + }) + return + } + stringNotificationMetadata := string(notificationMetadataBytes) + + var stringLabels *string + if len(choreReq.Labels) > 0 { + var escapedLabels []string + for _, label := range choreReq.Labels { + escapedLabels = append(escapedLabels, html.EscapeString(label)) + } + + labels := strings.Join(escapedLabels, ",") + stringLabels = &labels + } + createdChore := &chModel.Chore{ + + Name: choreReq.Name, + FrequencyType: choreReq.FrequencyType, + Frequency: choreReq.Frequency, + FrequencyMetadata: &stringFrequencyMetadata, + NextDueDate: dueDate, + AssignStrategy: choreReq.AssignStrategy, + AssignedTo: choreReq.AssignedTo, + IsRolling: choreReq.IsRolling, + UpdatedBy: currentUser.ID, + IsActive: true, + Notification: choreReq.Notification, + NotificationMetadata: &stringNotificationMetadata, + Labels: stringLabels, + CreatedBy: currentUser.ID, + CreatedAt: time.Now().UTC(), + CircleID: currentUser.CircleID, + } + id, err := h.choreRepo.CreateChore(c, createdChore) + createdChore.ID = id + + if err != nil { + c.JSON(500, gin.H{ + "error": "Error creating chore", + }) + return + } + + var choreAssignees []*chModel.ChoreAssignees + for _, assignee := range choreReq.Assignees { + choreAssignees = append(choreAssignees, &chModel.ChoreAssignees{ + ChoreID: id, + UserID: assignee.UserID, + }) + } + + if err := h.choreRepo.UpdateChoreAssignees(c, choreAssignees); err != nil { + c.JSON(500, gin.H{ + "error": "Error adding chore assignees", + }) + return + } + go func() { + h.nPlanner.GenerateNotifications(c, createdChore) + }() + shouldReturn := HandleThingAssociation(choreReq, h, c, currentUser) + if shouldReturn { + return + } + c.JSON(200, gin.H{ + "res": id, + }) +} + +func (h *Handler) editChore(c *gin.Context) { + // logger := logging.FromContext(c) + currentUser, ok := auth.CurrentUser(c) + if !ok { + c.JSON(500, gin.H{ + "error": "Error getting current user", + }) + return + } + + var choreReq ChoreReq + if err := c.ShouldBindJSON(&choreReq); err != nil { + log.Print(err) + c.JSON(400, gin.H{ + "error": "Invalid request", + }) + return + } + + circleUsers, err := h.circleRepo.GetCircleUsers(c, currentUser.CircleID) + if err != nil { + c.JSON(500, gin.H{ + "error": "Error getting circle users", + }) + return + } + + existedChoreAssignees, err := h.choreRepo.GetChoreAssignees(c, choreReq.ID) + if err != nil { + c.JSON(500, gin.H{ + "error": "Error getting chore assignees", + }) + return + } + + var choreAssigneesToAdd []*chModel.ChoreAssignees + var choreAssigneesToDelete []*chModel.ChoreAssignees + + // filter assignees that not in the circle + for _, assignee := range choreReq.Assignees { + userFound := false + for _, circleUser := range circleUsers { + if assignee.UserID == circleUser.UserID { + userFound = true + break + } + } + if !userFound { + c.JSON(400, gin.H{ + "error": "Assignee not found in circle", + }) + return + } + userAlreadyAssignee := false + for _, existedChoreAssignee := range existedChoreAssignees { + if existedChoreAssignee.UserID == assignee.UserID { + userAlreadyAssignee = true + break + } + } + if !userAlreadyAssignee { + choreAssigneesToAdd = append(choreAssigneesToAdd, &chModel.ChoreAssignees{ + ChoreID: choreReq.ID, + UserID: assignee.UserID, + }) + } + } + + // remove assignees if they are not in the assignees list anymore + for _, existedChoreAssignee := range existedChoreAssignees { + userFound := false + for _, assignee := range choreReq.Assignees { + if existedChoreAssignee.UserID == assignee.UserID { + userFound = true + break + } + } + if !userFound { + choreAssigneesToDelete = append(choreAssigneesToDelete, existedChoreAssignee) + } + } + + var dueDate *time.Time + + if choreReq.DueDate != "" { + rawDueDate, err := time.Parse(time.RFC3339, choreReq.DueDate) + rawDueDate = rawDueDate.UTC() + dueDate = &rawDueDate + if err != nil { + c.JSON(400, gin.H{ + "error": "Invalid date", + }) + return + } + + } + + // validate assignedTo part of the assignees: + assigneeFound := false + for _, assignee := range choreReq.Assignees { + if assignee.UserID == choreReq.AssignedTo { + assigneeFound = true + break + } + } + if !assigneeFound { + c.JSON(400, gin.H{ + "error": "Assigned to not found in assignees", + }) + return + } + + if choreReq.AssignedTo <= 0 && len(choreReq.Assignees) > 0 { + // if the assigned to field is not set, randomly assign the chore to one of the assignees + choreReq.AssignedTo = choreReq.Assignees[rand.Intn(len(choreReq.Assignees))].UserID + } + oldChore, err := h.choreRepo.GetChore(c, choreReq.ID) + + if err != nil { + c.JSON(500, gin.H{ + "error": "Error getting chore", + }) + return + } + if currentUser.ID != oldChore.CreatedBy { + c.JSON(403, gin.H{ + "error": "You are not allowed to edit this chore", + }) + return + } + freqencyMetadataBytes, err := json.Marshal(choreReq.FrequencyMetadata) + if err != nil { + c.JSON(500, gin.H{ + "error": "Error marshalling frequency metadata", + }) + return + } + + stringFrequencyMetadata := string(freqencyMetadataBytes) + + notificationMetadataBytes, err := json.Marshal(choreReq.NotificationMetadata) + if err != nil { + c.JSON(500, gin.H{ + "error": "Error marshalling notification metadata", + }) + return + } + stringNotificationMetadata := string(notificationMetadataBytes) + + // escape special characters in labels and store them as a string : + var stringLabels *string + if len(choreReq.Labels) > 0 { + var escapedLabels []string + for _, label := range choreReq.Labels { + escapedLabels = append(escapedLabels, html.EscapeString(label)) + } + + labels := strings.Join(escapedLabels, ",") + stringLabels = &labels + } + updatedChore := &chModel.Chore{ + ID: choreReq.ID, + Name: choreReq.Name, + FrequencyType: choreReq.FrequencyType, + Frequency: choreReq.Frequency, + FrequencyMetadata: &stringFrequencyMetadata, + // Assignees: &assignees, + NextDueDate: dueDate, + AssignStrategy: choreReq.AssignStrategy, + AssignedTo: choreReq.AssignedTo, + IsRolling: choreReq.IsRolling, + IsActive: choreReq.IsActive, + Notification: choreReq.Notification, + NotificationMetadata: &stringNotificationMetadata, + Labels: stringLabels, + CircleID: oldChore.CircleID, + UpdatedBy: currentUser.ID, + CreatedBy: oldChore.CreatedBy, + CreatedAt: oldChore.CreatedAt, + } + if err := h.choreRepo.UpsertChore(c, updatedChore); err != nil { + c.JSON(500, gin.H{ + "error": "Error adding chore", + }) + return + } + if len(choreAssigneesToAdd) > 0 { + err = h.choreRepo.UpdateChoreAssignees(c, choreAssigneesToAdd) + + if err != nil { + c.JSON(500, gin.H{ + "error": "Error updating chore assignees", + }) + return + } + } + if len(choreAssigneesToDelete) > 0 { + err = h.choreRepo.DeleteChoreAssignees(c, choreAssigneesToDelete) + if err != nil { + c.JSON(500, gin.H{ + "error": "Error deleting chore assignees", + }) + return + } + } + go func() { + h.nPlanner.GenerateNotifications(c, updatedChore) + }() + if oldChore.ThingChore.ThingID != 0 { + // TODO: Add check to see if dissociation is necessary + h.tRepo.DissociateThingWithChore(c, oldChore.ThingChore.ThingID, oldChore.ID) + + } + shouldReturn := HandleThingAssociation(choreReq, h, c, currentUser) + if shouldReturn { + return + } + + c.JSON(200, gin.H{ + "message": "Chore added successfully", + }) +} + +func HandleThingAssociation(choreReq ChoreReq, h *Handler, c *gin.Context, currentUser *uModel.User) bool { + if choreReq.ThingTrigger != nil { + thing, err := h.tRepo.GetThingByID(c, choreReq.ThingTrigger.ID) + if err != nil { + c.JSON(500, gin.H{ + "error": "Error getting thing", + }) + return true + } + if thing.UserID != currentUser.ID { + c.JSON(403, gin.H{ + "error": "You are not allowed to trigger this thing", + }) + return true + } + if err := h.tRepo.AssociateThingWithChore(c, choreReq.ThingTrigger.ID, choreReq.ID, choreReq.ThingTrigger.TriggerState, choreReq.ThingTrigger.Condition); err != nil { + c.JSON(500, gin.H{ + "error": "Error associating thing with chore", + }) + return true + } + + } + return false +} + +func (h *Handler) deleteChore(c *gin.Context) { + // logger := logging.FromContext(c) + currentUser, ok := auth.CurrentUser(c) + if !ok { + c.JSON(500, gin.H{ + "error": "Error getting current user", + }) + return + } + + rawID := c.Param("id") + id, err := strconv.Atoi(rawID) + if err != nil { + c.JSON(400, gin.H{ + "error": "Invalid ID", + }) + return + } + // check if the user is the owner of the chore before deleting + if err := h.choreRepo.IsChoreOwner(c, id, currentUser.ID); err != nil { + c.JSON(403, gin.H{ + "error": "You are not allowed to delete this chore", + }) + return + } + + if err := h.choreRepo.DeleteChore(c, id); err != nil { + c.JSON(500, gin.H{ + "error": "Error deleting chore", + }) + return + } + h.nRepo.DeleteAllChoreNotifications(id) + c.JSON(200, gin.H{ + "message": "Chore deleted successfully", + }) +} + +// func (h *Handler) createChore(c *gin.Context) { +// logger := logging.FromContext(c) +// currentUser, ok := auth.CurrentUser(c) + +// logger.Debug("Create chore", "currentUser", currentUser) +// if !ok { +// c.JSON(500, gin.H{ +// "error": "Error getting current user", +// }) +// return +// } +// id, err := h.choreRepo.CreateChore(currentUser.ID, currentUser.CircleID) +// if err != nil { +// c.JSON(500, gin.H{ +// "error": "Error creating chore", +// }) +// return +// } + +// c.JSON(200, gin.H{ +// "res": id, +// }) +// } + +func (h *Handler) updateAssignee(c *gin.Context) { + rawID := c.Param("id") + id, err := strconv.Atoi(rawID) + if err != nil { + c.JSON(400, gin.H{ + "error": "Invalid ID", + }) + return + } + type AssigneeReq struct { + AssignedTo int `json:"assignedTo" binding:"required"` + UpdatedBy int `json:"updatedBy" binding:"required"` + } + + var assigneeReq AssigneeReq + if err := c.ShouldBindJSON(&assigneeReq); err != nil { + log.Print(err) + c.JSON(400, gin.H{ + "error": "Invalid request", + }) + return + } + chore, err := h.choreRepo.GetChore(c, id) + if err != nil { + c.JSON(500, gin.H{ + "error": "Error getting chore", + }) + return + } + // confirm that the assignee is one of the assignees: + assigneeFound := false + for _, assignee := range chore.Assignees { + + if assignee.UserID == assigneeReq.AssignedTo { + assigneeFound = true + break + } + } + if !assigneeFound { + c.JSON(400, gin.H{ + "error": "Assignee not found in assignees", + }) + return + } + + chore.UpdatedBy = assigneeReq.UpdatedBy + chore.AssignedTo = assigneeReq.AssignedTo + if err := h.choreRepo.UpsertChore(c, chore); err != nil { + c.JSON(500, gin.H{ + "error": "Error updating assignee", + }) + return + } + + c.JSON(200, gin.H{ + "res": chore, + }) +} + +func (h *Handler) skipChore(c *gin.Context) { + rawID := c.Param("id") + id, err := strconv.Atoi(rawID) + if err != nil { + c.JSON(400, gin.H{ + "error": "Invalid ID", + }) + return + } + + chore, err := h.choreRepo.GetChore(c, id) + if err != nil { + c.JSON(500, gin.H{ + "error": "Error getting chore", + }) + return + } + newDueDate, err := scheduleNextDueDate(chore, chore.NextDueDate.UTC()) + if err != nil { + c.JSON(500, gin.H{ + "error": "Error scheduling next due date", + }) + return + } + chore.NextDueDate = newDueDate + if err := h.choreRepo.UpsertChore(c, chore); err != nil { + c.JSON(500, gin.H{ + "error": "Error skipping chore", + }) + return + } + if err := h.choreRepo.UpsertChore(c, chore); err != nil { + c.JSON(500, gin.H{ + "error": "Error skipping chore", + }) + return + } + c.JSON(200, gin.H{ + "res": chore, + }) +} + +func (h *Handler) updateDueDate(c *gin.Context) { + currentUser, ok := auth.CurrentUser(c) + if !ok { + c.JSON(500, gin.H{ + "error": "Error getting current user", + }) + return + } + + type DueDateReq struct { + DueDate string `json:"dueDate" binding:"required"` + } + + var dueDateReq DueDateReq + if err := c.ShouldBindJSON(&dueDateReq); err != nil { + log.Print(err) + c.JSON(400, gin.H{ + "error": "Invalid request", + }) + return + } + + rawID := c.Param("id") + id, err := strconv.Atoi(rawID) + if err != nil { + c.JSON(400, gin.H{ + "error": "Invalid ID", + }) + return + } + + rawDueDate, err := time.Parse(time.RFC3339, dueDateReq.DueDate) + if err != nil { + c.JSON(400, gin.H{ + "error": "Invalid date", + }) + return + } + dueDate := rawDueDate.UTC() + chore, err := h.choreRepo.GetChore(c, id) + if err != nil { + c.JSON(500, gin.H{ + "error": "Error getting chore", + }) + return + } + chore.NextDueDate = &dueDate + chore.UpdatedBy = currentUser.ID + if err := h.choreRepo.UpsertChore(c, chore); err != nil { + c.JSON(500, gin.H{ + "error": "Error updating due date", + }) + return + } + + c.JSON(200, gin.H{ + "res": chore, + }) +} +func (h *Handler) completeChore(c *gin.Context) { + type CompleteChoreReq struct { + Note string `json:"note"` + } + var req CompleteChoreReq + currentUser, ok := auth.CurrentUser(c) + if !ok { + c.JSON(500, gin.H{ + "error": "Error getting current user", + }) + return + } + completeChoreID := c.Param("id") + var completedDate time.Time + rawCompletedDate := c.Query("completedDate") + if rawCompletedDate == "" { + completedDate = time.Now().UTC() + } else { + var err error + completedDate, err = time.Parse(time.RFC3339, rawCompletedDate) + if err != nil { + c.JSON(400, gin.H{ + "error": "Invalid date", + }) + return + } + } + + var additionalNotes *string + _ = c.ShouldBind(&req) + + if req.Note != "" { + additionalNotes = &req.Note + } + + id, err := strconv.Atoi(completeChoreID) + if err != nil { + c.JSON(400, gin.H{ + "error": "Invalid ID", + }) + return + } + chore, err := h.choreRepo.GetChore(c, id) + if err != nil { + c.JSON(500, gin.H{ + "error": "Error getting chore", + }) + return + } + + nextDueDate, err := scheduleNextDueDate(chore, completedDate) + if err != nil { + log.Printf("Error scheduling next due date: %s", err) + c.JSON(500, gin.H{ + "error": "Error scheduling next due date", + }) + return + } + choreHistory, err := h.choreRepo.GetChoreHistory(c, chore.ID) + if err != nil { + c.JSON(500, gin.H{ + "error": "Error getting chore history", + }) + return + } + + nextAssignedTo, err := checkNextAssignee(chore, choreHistory, currentUser.ID) + if err != nil { + log.Printf("Error checking next assignee: %s", err) + c.JSON(500, gin.H{ + "error": "Error checking next assignee", + }) + return + } + + if err := h.choreRepo.CompleteChore(c, chore, additionalNotes, currentUser.ID, nextDueDate, completedDate, nextAssignedTo); err != nil { + c.JSON(500, gin.H{ + "error": "Error completing chore", + }) + return + } + updatedChore, err := h.choreRepo.GetChore(c, id) + if err != nil { + c.JSON(500, gin.H{ + "error": "Error getting chore", + }) + return + } + go func() { + h.notifier.SendChoreCompletion(c, chore, []*uModel.User{currentUser}) + h.nPlanner.GenerateNotifications(c, updatedChore) + }() + c.JSON(200, gin.H{ + "res": updatedChore, + }) +} + +func (h *Handler) GetChoreHistory(c *gin.Context) { + rawID := c.Param("id") + id, err := strconv.Atoi(rawID) + if err != nil { + c.JSON(400, gin.H{ + "error": "Invalid ID", + }) + return + } + + choreHistory, err := h.choreRepo.GetChoreHistory(c, id) + if err != nil { + c.JSON(500, gin.H{ + "error": "Error getting chore history", + }) + return + } + + c.JSON(200, gin.H{ + "res": choreHistory, + }) +} + +func checkNextAssignee(chore *chModel.Chore, choresHistory []*chModel.ChoreHistory, performerID int) (int, error) { + // copy the history to avoid modifying the original: + history := make([]*chModel.ChoreHistory, len(choresHistory)) + copy(history, choresHistory) + + assigneesMap := map[int]bool{} + for _, assignee := range chore.Assignees { + assigneesMap[assignee.UserID] = true + } + var nextAssignee int + if len(history) == 0 { + // if there is no history, just assume the current operation as the first + history = append(history, &chModel.ChoreHistory{ + AssignedTo: performerID, + }) + + } + + switch chore.AssignStrategy { + case "least_assigned": + // find the assignee with the least number of chores + assigneeChores := map[int]int{} + for _, performer := range chore.Assignees { + assigneeChores[performer.UserID] = 0 + } + for _, history := range history { + if ok := assigneesMap[history.AssignedTo]; !ok { + // calculate the number of chores assigned to each assignee + assigneeChores[history.AssignedTo]++ + } + } + + minChores := math.MaxInt64 + for assignee, numChores := range assigneeChores { + // if this is the first assignee or if the number of + // chores assigned to this assignee is less than the current minimum + if numChores < minChores { + minChores = numChores + // set the next assignee to this assignee + nextAssignee = assignee + } + } + case "least_completed": + // find the assignee who has completed the least number of chores + assigneeChores := map[int]int{} + for _, performer := range chore.Assignees { + assigneeChores[performer.UserID] = 0 + } + for _, history := range history { + // calculate the number of chores completed by each assignee + assigneeChores[history.CompletedBy]++ + } + + // max Int value + minChores := math.MaxInt64 + for assignee, numChores := range assigneeChores { + // if this is the first assignee or if the number of + // chores completed by this assignee is less than the current minimum + if numChores < minChores { + minChores = numChores + // set the next assignee to this assignee + nextAssignee = assignee + } + } + case "random": + nextAssignee = chore.Assignees[rand.Intn(len(chore.Assignees))].UserID + case "keep_last_assigned": + // keep the last assignee + nextAssignee = history[len(history)-1].AssignedTo + + default: + return chore.AssignedTo, fmt.Errorf("invalid assign strategy") + + } + return nextAssignee, nil +} + +func Routes(router *gin.Engine, h *Handler, auth *jwt.GinJWTMiddleware) { + + choresRoutes := router.Group("chores") + choresRoutes.Use(auth.MiddlewareFunc()) + { + choresRoutes.GET("/", h.getChores) + choresRoutes.PUT("/", h.editChore) + choresRoutes.POST("/", h.createChore) + choresRoutes.GET("/:id", h.getChore) + choresRoutes.GET("/:id/history", h.GetChoreHistory) + choresRoutes.POST("/:id/do", h.completeChore) + choresRoutes.POST("/:id/skip", h.skipChore) + choresRoutes.PUT("/:id/assignee", h.updateAssignee) + choresRoutes.PUT("/:id/dueDate", h.updateDueDate) + choresRoutes.DELETE("/:id", h.deleteChore) + } + +} diff --git a/internal/chore/model/model.go b/internal/chore/model/model.go new file mode 100644 index 0000000..4de7808 --- /dev/null +++ b/internal/chore/model/model.go @@ -0,0 +1,72 @@ +package model + +import ( + "time" + + tModel "donetick.com/core/internal/thing/model" +) + +type Chore struct { + ID int `json:"id" gorm:"primary_key"` + Name string `json:"name" gorm:"column:name"` // Chore description + FrequencyType string `json:"frequencyType" gorm:"column:frequency_type"` // "daily", "weekly", "monthly", "yearly", "adaptive",or "custom" + Frequency int `json:"frequency" gorm:"column:frequency"` // Number of days, weeks, months, or years between chores + FrequencyMetadata *string `json:"frequencyMetadata" gorm:"column:frequency_meta"` // Additional frequency information + NextDueDate *time.Time `json:"nextDueDate" gorm:"column:next_due_date;index"` // When the chore is due + IsRolling bool `json:"isRolling" gorm:"column:is_rolling"` // Whether the chore is rolling + AssignedTo int `json:"assignedTo" gorm:"column:assigned_to"` // Who the chore is assigned to + Assignees []ChoreAssignees `json:"assignees" gorm:"foreignkey:ChoreID;references:ID"` // Assignees of the chore + AssignStrategy string `json:"assignStrategy" gorm:"column:assign_strategy"` // How the chore is assigned + IsActive bool `json:"isActive" gorm:"column:is_active"` // Whether the chore is active + 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 + 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 + CreatedBy int `json:"createdBy" gorm:"column:created_by"` // Who created the chore + UpdatedBy int `json:"updatedBy" gorm:"column:updated_by"` // Who last updated the chore + ThingChore tModel.ThingChore `json:"thingChore" gorm:"foreignkey:chore_id;references:id;<-:false"` // ThingChore relationship +} +type ChoreAssignees struct { + ID int `json:"-" gorm:"primary_key"` + ChoreID int `json:"-" gorm:"column:chore_id;uniqueIndex:idx_chore_user"` // The chore this assignee is for + UserID int `json:"userId" gorm:"column:user_id;uniqueIndex:idx_chore_user"` // The user this assignee is for +} +type ChoreHistory struct { + ID int `json:"id" gorm:"primary_key"` // Unique identifier + ChoreID int `json:"choreId" gorm:"column:chore_id"` // The chore this history is for + CompletedAt time.Time `json:"completedAt" gorm:"column:completed_at"` // When the chore was completed + CompletedBy int `json:"completedBy" gorm:"column:completed_by"` // Who completed the chore + AssignedTo int `json:"assignedTo" gorm:"column:assigned_to"` // Who the chore was assigned to + Note *string `json:"notes" gorm:"column:notes"` // Notes about the chore + DueDate *time.Time `json:"dueDate" gorm:"column:due_date"` // When the chore was due +} + +type FrequencyMetadata struct { + Days []*string `json:"days,omitempty"` + Months []*string `json:"months,omitempty"` + Unit *string `json:"unit,omitempty"` +} + +type NotificationMetadata struct { + DueDate bool `json:"dueDate,omitempty"` + Completion bool `json:"completion,omitempty"` + Nagging bool `json:"nagging,omitempty"` + PreDue bool `json:"predue,omitempty"` +} + +type Tag struct { + ID int `json:"-" gorm:"primary_key"` + 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"` +// } diff --git a/internal/chore/repo/repository.go b/internal/chore/repo/repository.go new file mode 100644 index 0000000..1ab0f0b --- /dev/null +++ b/internal/chore/repo/repository.go @@ -0,0 +1,216 @@ +package chore + +import ( + "context" + "fmt" + "time" + + config "donetick.com/core/config" + chModel "donetick.com/core/internal/chore/model" + "gorm.io/gorm" +) + +type ChoreRepository struct { + db *gorm.DB + dbType string +} + +func NewChoreRepository(db *gorm.DB, cfg *config.Config) *ChoreRepository { + return &ChoreRepository{db: db, dbType: cfg.Database.Type} +} + +func (r *ChoreRepository) UpsertChore(c context.Context, chore *chModel.Chore) error { + return r.db.WithContext(c).Model(&chore).Save(chore).Error +} + +func (r *ChoreRepository) UpdateChores(c context.Context, chores []*chModel.Chore) error { + return r.db.WithContext(c).Save(&chores).Error +} +func (r *ChoreRepository) CreateChore(c context.Context, chore *chModel.Chore) (int, error) { + if err := r.db.WithContext(c).Create(chore).Error; err != nil { + return 0, err + } + return chore.ID, nil +} + +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 { + return nil, err + } + return &chore, nil +} + +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 { + return nil, err + } + return chores, nil +} +func (r *ChoreRepository) DeleteChore(c context.Context, id int) error { + r.db.WithContext(c).Where("chore_id = ?", id).Delete(&chModel.ChoreAssignees{}) + return r.db.WithContext(c).Delete(&chModel.Chore{}, id).Error +} + +func (r *ChoreRepository) SoftDelete(c context.Context, id int, userID int) error { + return r.db.WithContext(c).Model(&chModel.Chore{}).Where("id = ?", id).Where("created_by = ? ", userID).Update("is_active", false).Error + +} + +func (r *ChoreRepository) IsChoreOwner(c context.Context, choreID int, userID int) error { + var chore chModel.Chore + err := r.db.WithContext(c).Model(&chModel.Chore{}).Where("id = ? AND created_by = ?", choreID, userID).First(&chore).Error + return err +} + +// func (r *ChoreRepository) ListChores(circleID int) ([]*chModel.Chore, error) { +// var chores []*Chore +// if err := r.db.WithContext(c).Find(&chores).Where("is_active = ?", true).Order("next_due_date").Error; err != nil { +// return nil, err +// } +// return chores, nil +// } + +func (r *ChoreRepository) CompleteChore(c context.Context, chore *chModel.Chore, note *string, userID int, dueDate *time.Time, completedDate time.Time, nextAssignedTo int) error { + err := r.db.WithContext(c).Transaction(func(tx *gorm.DB) error { + ch := &chModel.ChoreHistory{ + ChoreID: chore.ID, + CompletedAt: completedDate, + CompletedBy: userID, + AssignedTo: chore.AssignedTo, + DueDate: chore.NextDueDate, + Note: note, + } + if err := tx.Create(ch).Error; err != nil { + return err + } + updates := map[string]interface{}{} + updates["next_due_date"] = dueDate + + if dueDate != nil { + updates["assigned_to"] = nextAssignedTo + } else { + updates["is_active"] = false + } + // Perform the update operation once, using the prepared updates map. + if err := tx.Model(&chModel.Chore{}).Where("id = ?", chore.ID).Updates(updates).Error; err != nil { + return err + } + + return nil + }) + return err +} + +func (r *ChoreRepository) GetChoreHistory(c context.Context, choreID int) ([]*chModel.ChoreHistory, error) { + var histories []*chModel.ChoreHistory + if err := r.db.WithContext(c).Where("chore_id = ?", choreID).Order("completed_at desc").Find(&histories).Error; err != nil { + return nil, err + } + return histories, nil +} + +func (r *ChoreRepository) UpdateChoreAssignees(c context.Context, assignees []*chModel.ChoreAssignees) error { + return r.db.WithContext(c).Save(&assignees).Error +} + +func (r *ChoreRepository) DeleteChoreAssignees(c context.Context, choreAssignees []*chModel.ChoreAssignees) error { + return r.db.WithContext(c).Delete(&choreAssignees).Error +} + +func (r *ChoreRepository) GetChoreAssignees(c context.Context, choreID int) ([]*chModel.ChoreAssignees, error) { + var assignees []*chModel.ChoreAssignees + if err := r.db.WithContext(c).Find(&assignees, "chore_id = ?", choreID).Error; err != nil { + return nil, err + } + return assignees, nil +} + +func (r *ChoreRepository) RemoveChoreAssigneeByCircleID(c context.Context, userID int, circleID int) error { + return r.db.WithContext(c).Where("user_id = ? AND chore_id IN (SELECT id FROM chores WHERE circle_id = ? and created_by != ?)", userID, circleID, userID).Delete(&chModel.ChoreAssignees{}).Error +} + +// func (r *ChoreRepository) getChoreDueToday(circleID int) ([]*chModel.Chore, error) { +// var chores []*Chore +// if err := r.db.WithContext(c).Where("next_due_date <= ?", time.Now().UTC()).Find(&chores).Error; err != nil { +// return nil, err +// } +// return chores, nil +// } + +func (r *ChoreRepository) GetAllActiveChores(c context.Context) ([]*chModel.Chore, error) { + var chores []*chModel.Chore + // query := r.db.WithContext(c).Table("chores").Joins("left join notifications n on n.chore_id = chores.id and n.scheduled_for < chores.next_due_date") + // if err := query.Where("chores.is_active = ? and chores.notification = ? and (n.is_sent = ? or n.is_sent is null)", true, true, false).Find(&chores).Error; err != nil { + // return nil, err + // } + return chores, nil +} + +func (r *ChoreRepository) GetChoresForNotification(c context.Context) ([]*chModel.Chore, error) { + var chores []*chModel.Chore + query := r.db.WithContext(c).Table("chores").Joins("left join notifications n on n.chore_id = chores.id and n.scheduled_for = chores.next_due_date and n.type = 1") + if err := query.Where("chores.is_active = ? and chores.notification = ? and n.id is null", true, true).Find(&chores).Error; err != nil { + return nil, err + } + return chores, nil +} + +// func (r *ChoreReposity) GetOverdueChoresForNotification(c context.Context, overdueDuration time.Duration, everyDuration time.Duration, untilDuration time.Duration) ([]*chModel.Chore, error) { +// var chores []*chModel.Chore +// query := r.db.Debug().WithContext(c).Table("chores").Select("chores.*, MAX(n.created_at) as max_notification_created_at").Joins("left join notifications n on n.chore_id = chores.id and n.scheduled_for = chores.next_due_date and n.type = 2") +// if err := query.Where("chores.is_active = ? and chores.notification = ? and chores.next_due_date < ? and chores.next_due_date > ?", true, true, time.Now().Add(overdueDuration).UTC(), time.Now().Add(untilDuration).UTC()).Where(readJSONBooleanField(r.dbType, "chores.notification_meta", "nagging")).Having("MAX(n.created_at) is null or MAX(n.created_at) < ?", time.Now().Add(everyDuration).UTC()).Group("chores.id").Find(&chores).Error; err != nil { +// return nil, err +// } +// return chores, nil +// } + +func (r *ChoreRepository) GetOverdueChoresForNotification(c context.Context, overdueFor time.Duration, everyDuration time.Duration, untilDuration time.Duration) ([]*chModel.Chore, error) { + var chores []*chModel.Chore + now := time.Now().UTC() + overdueTime := now.Add(-overdueFor) + everyTime := now.Add(-everyDuration) + untilTime := now.Add(-untilDuration) + + query := r.db.Debug().WithContext(c). + Table("chores"). + Select("chores.*, MAX(n.created_at) as max_notification_created_at"). + Joins("left join notifications n on n.chore_id = chores.id and n.type = 2"). + Where("chores.is_active = ? AND chores.notification = ? AND chores.next_due_date < ? AND chores.next_due_date > ?", true, true, overdueTime, untilTime). + Where(readJSONBooleanField(r.dbType, "chores.notification_meta", "nagging")). + Group("chores.id"). + Having("MAX(n.created_at) IS NULL OR MAX(n.created_at) < ?", everyTime) + + if err := query.Find(&chores).Error; err != nil { + return nil, err + } + + return chores, nil +} + +// a predue notfication is a notification send before the due date in 6 hours, 3 hours : +func (r *ChoreRepository) GetPreDueChoresForNotification(c context.Context, preDueDuration time.Duration, everyDuration time.Duration) ([]*chModel.Chore, error) { + var chores []*chModel.Chore + query := r.db.WithContext(c).Table("chores").Select("chores.*, MAX(n.created_at) as max_notification_created_at").Joins("left join notifications n on n.chore_id = chores.id and n.scheduled_for = chores.next_due_date and n.type = 3") + if err := query.Where("chores.is_active = ? and chores.notification = ? and chores.next_due_date > ? and chores.next_due_date < ?", true, true, time.Now().UTC(), time.Now().Add(everyDuration*2).UTC()).Where(readJSONBooleanField(r.dbType, "chores.notification_meta", "predue")).Having("MAX(n.created_at) is null or MAX(n.created_at) < ?", time.Now().Add(everyDuration).UTC()).Group("chores.id").Find(&chores).Error; err != nil { + return nil, err + } + return chores, nil +} + +func readJSONBooleanField(dbType string, columnName string, fieldName string) string { + if dbType == "postgres" { + return fmt.Sprintf("(%s::json->>'%s')::boolean", columnName, fieldName) + } + return fmt.Sprintf("JSON_EXTRACT(%s, '$.%s')", columnName, fieldName) +} + +func (r *ChoreRepository) SetDueDate(c context.Context, choreID int, dueDate time.Time) error { + return r.db.WithContext(c).Model(&chModel.Chore{}).Where("id = ?", choreID).Update("next_due_date", dueDate).Error +} + +func (r *ChoreRepository) SetDueDateIfNotExisted(c context.Context, choreID int, dueDate time.Time) error { + return r.db.WithContext(c).Model(&chModel.Chore{}).Where("id = ? and next_due_date is null", choreID).Update("next_due_date", dueDate).Error +} diff --git a/internal/chore/scheduler.go b/internal/chore/scheduler.go new file mode 100644 index 0000000..55cdf01 --- /dev/null +++ b/internal/chore/scheduler.go @@ -0,0 +1,145 @@ +package chore + +import ( + "encoding/json" + "fmt" + "math/rand" + "strings" + "time" + + chModel "donetick.com/core/internal/chore/model" +) + +func scheduleNextDueDate(chore *chModel.Chore, completedDate time.Time) (*time.Time, error) { + // if Chore is rolling then the next due date calculated from the completed date, otherwise it's calculated from the due date + var nextDueDate time.Time + var baseDate time.Time + var frequencyMetadata chModel.FrequencyMetadata + err := json.Unmarshal([]byte(*chore.FrequencyMetadata), &frequencyMetadata) + if err != nil { + return nil, fmt.Errorf("error unmarshalling frequency metadata") + } + if chore.FrequencyType == "once" { + return nil, nil + } + if chore.NextDueDate != nil { + // no due date set, use the current date + + baseDate = chore.NextDueDate.UTC() + } else { + baseDate = completedDate.UTC() + } + if chore.IsRolling && chore.NextDueDate.Before(completedDate) { + // we need to check if chore due date is before the completed date to handle this senario: + // if user trying to complete chore due in future (multiple time for insance) due date will be calculated + // from the last completed date and due date change only in seconds. + // this make sure that the due date is always in future if the chore is rolling + + baseDate = completedDate.UTC() + } + + if chore.FrequencyType == "daily" { + nextDueDate = baseDate.AddDate(0, 0, 1) + } else if chore.FrequencyType == "weekly" { + nextDueDate = baseDate.AddDate(0, 0, 7) + } else if chore.FrequencyType == "monthly" { + nextDueDate = baseDate.AddDate(0, 1, 0) + } else if chore.FrequencyType == "yearly" { + nextDueDate = baseDate.AddDate(1, 0, 0) + } else if chore.FrequencyType == "adaptive" { + // TODO: calculate next due date based on the history of the chore + // calculate the difference between the due date and now in days: + diff := completedDate.UTC().Sub(chore.NextDueDate.UTC()) + nextDueDate = completedDate.UTC().Add(diff) + } else if chore.FrequencyType == "once" { + // if the chore is a one-time chore, then the next due date is nil + } else if chore.FrequencyType == "interval" { + // calculate the difference between the due date and now in days: + if *frequencyMetadata.Unit == "hours" { + nextDueDate = baseDate.UTC().Add(time.Hour * time.Duration(chore.Frequency)) + } else if *frequencyMetadata.Unit == "days" { + nextDueDate = baseDate.UTC().AddDate(0, 0, chore.Frequency) + } else if *frequencyMetadata.Unit == "weeks" { + nextDueDate = baseDate.UTC().AddDate(0, 0, chore.Frequency*7) + } else if *frequencyMetadata.Unit == "months" { + nextDueDate = baseDate.UTC().AddDate(0, chore.Frequency, 0) + } else if *frequencyMetadata.Unit == "years" { + nextDueDate = baseDate.UTC().AddDate(chore.Frequency, 0, 0) + } else { + + return nil, fmt.Errorf("invalid frequency unit, cannot calculate next due date") + } + } else if chore.FrequencyType == "days_of_the_week" { + // TODO : this logic is bad, need to be refactored and be better. + // coding at night is almost always bad idea. + // calculate the difference between the due date and now in days: + var frequencyMetadata chModel.FrequencyMetadata + err := json.Unmarshal([]byte(*chore.FrequencyMetadata), &frequencyMetadata) + if err != nil { + + return nil, fmt.Errorf("error unmarshalling frequency metadata") + } + //we can only assign to days of the week that part of the frequency metadata.days + //it's array of days of the week, for example ["monday", "tuesday", "wednesday"] + + // we need to find the next day of the week in the frequency metadata.days that we can schedule + // if this the last or there is only one. will use same otherwise find the next one: + + // find the index of the chore day in the frequency metadata.days + // loop for next 7 days from the base, if the day in the frequency metadata.days then we can schedule it: + for i := 1; i <= 7; i++ { + nextDueDate = baseDate.AddDate(0, 0, i) + nextDay := strings.ToLower(nextDueDate.Weekday().String()) + for _, day := range frequencyMetadata.Days { + if strings.ToLower(*day) == nextDay { + nextDate := nextDueDate.UTC() + return &nextDate, nil + } + } + } + } else if chore.FrequencyType == "day_of_the_month" { + var frequencyMetadata chModel.FrequencyMetadata + err := json.Unmarshal([]byte(*chore.FrequencyMetadata), &frequencyMetadata) + if err != nil { + + return nil, fmt.Errorf("error unmarshalling frequency metadata") + } + + for i := 1; i <= 12; i++ { + nextDueDate = baseDate.AddDate(0, i, 0) + // set the date to the first day of the month: + nextDueDate = time.Date(nextDueDate.Year(), nextDueDate.Month(), chore.Frequency, nextDueDate.Hour(), nextDueDate.Minute(), 0, 0, nextDueDate.Location()) + nextMonth := strings.ToLower(nextDueDate.Month().String()) + for _, month := range frequencyMetadata.Months { + if *month == nextMonth { + nextDate := nextDueDate.UTC() + return &nextDate, nil + } + } + } + } else if chore.FrequencyType == "no_repeat" { + return nil, nil + } else if chore.FrequencyType == "trigger" { + // if the chore is a trigger chore, then the next due date is nil + return nil, nil + } else { + return nil, fmt.Errorf("invalid frequency type, cannot calculate next due date") + } + return &nextDueDate, nil + +} + +func RemoveAssigneeAndReassign(chore *chModel.Chore, userID int) { + for i, assignee := range chore.Assignees { + if assignee.UserID == userID { + chore.Assignees = append(chore.Assignees[:i], chore.Assignees[i+1:]...) + break + } + } + if len(chore.Assignees) == 0 { + chore.AssignedTo = chore.CreatedBy + } else { + chore.AssignedTo = chore.Assignees[rand.Intn(len(chore.Assignees))].UserID + } + chore.UpdatedAt = time.Now() +} -- cgit