Move to Donetick Org, first commit
This commit is contained in:
commit
c13dd9addb
42 changed files with 7463 additions and 0 deletions
974
internal/chore/handler.go
Normal file
974
internal/chore/handler.go
Normal file
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
72
internal/chore/model/model.go
Normal file
72
internal/chore/model/model.go
Normal file
|
@ -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"`
|
||||
// }
|
216
internal/chore/repo/repository.go
Normal file
216
internal/chore/repo/repository.go
Normal file
|
@ -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
|
||||
}
|
145
internal/chore/scheduler.go
Normal file
145
internal/chore/scheduler.go
Normal file
|
@ -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()
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue