aboutsummaryrefslogtreecommitdiffstats
path: root/internal/chore
diff options
context:
space:
mode:
authorLibravatar Mo Tarbin <mhed.t91@gmail.com>2024-06-30 21:41:41 -0400
committerLibravatar Mo Tarbin <mhed.t91@gmail.com>2024-06-30 21:41:41 -0400
commitc13dd9addbf89f716e4ef5cfdf1d673139ffcb68 (patch)
treebc09646ce1d6d3a402abb4694e19da51b57204f6 /internal/chore
downloaddonetick-c13dd9addbf89f716e4ef5cfdf1d673139ffcb68.tar.gz
donetick-c13dd9addbf89f716e4ef5cfdf1d673139ffcb68.tar.bz2
donetick-c13dd9addbf89f716e4ef5cfdf1d673139ffcb68.zip
Move to Donetick Org, first commit
Diffstat (limited to 'internal/chore')
-rw-r--r--internal/chore/handler.go974
-rw-r--r--internal/chore/model/model.go72
-rw-r--r--internal/chore/repo/repository.go216
-rw-r--r--internal/chore/scheduler.go145
4 files changed, 1407 insertions, 0 deletions
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()
+}