aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--internal/chore/handler.go69
-rw-r--r--internal/chore/model/model.go53
-rw-r--r--internal/chore/repo/repository.go46
-rw-r--r--internal/chore/scheduler.go28
-rw-r--r--internal/notifier/service/planner.go9
-rw-r--r--internal/notifier/telegram/telegram.go2
-rw-r--r--internal/thing/handler.go10
-rw-r--r--internal/thing/repo/repository.go4
-rw-r--r--internal/user/handler.go7
9 files changed, 193 insertions, 35 deletions
diff --git a/internal/chore/handler.go b/internal/chore/handler.go
index 3d9f9e7..5ec8cdd 100644
--- a/internal/chore/handler.go
+++ b/internal/chore/handler.go
@@ -488,7 +488,7 @@ func (h *Handler) editChore(c *gin.Context) {
go func() {
h.nPlanner.GenerateNotifications(c, updatedChore)
}()
- if oldChore.ThingChore.ThingID != 0 {
+ if oldChore.ThingChore != nil {
// TODO: Add check to see if dissociation is necessary
h.tRepo.DissociateThingWithChore(c, oldChore.ThingChore.ThingID, oldChore.ID)
@@ -562,6 +562,8 @@ func (h *Handler) deleteChore(c *gin.Context) {
return
}
h.nRepo.DeleteAllChoreNotifications(id)
+ h.tRepo.DissociateChoreWithThing(c, id)
+
c.JSON(200, gin.H{
"message": "Chore deleted successfully",
})
@@ -800,14 +802,33 @@ func (h *Handler) completeChore(c *gin.Context) {
})
return
}
+ var nextDueDate *time.Time
+ if chore.FrequencyType == "adaptive" {
+ history, err := h.choreRepo.GetChoreHistoryWithLimit(c, chore.ID, 5)
+ if err != nil {
+ c.JSON(500, gin.H{
+ "error": "Error getting chore history",
+ })
+ return
+ }
+ nextDueDate, err = scheduleAdaptiveNextDueDate(chore, completedDate, history)
+ if err != nil {
+ log.Printf("Error scheduling next due date: %s", err)
+ c.JSON(500, gin.H{
+ "error": "Error scheduling next due date",
+ })
+ 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
+ } else {
+ 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 {
@@ -871,6 +892,37 @@ func (h *Handler) GetChoreHistory(c *gin.Context) {
})
}
+func (h *Handler) GetChoreDetail(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
+ }
+
+ detailed, err := h.choreRepo.GetChoreDetailByID(c, id, currentUser.CircleID)
+ if err != nil {
+ c.JSON(500, gin.H{
+ "error": "Error getting chore history",
+ })
+ return
+ }
+
+ c.JSON(200, gin.H{
+ "res": detailed,
+ })
+}
+
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))
@@ -957,6 +1009,7 @@ func Routes(router *gin.Engine, h *Handler, auth *jwt.GinJWTMiddleware) {
choresRoutes.PUT("/", h.editChore)
choresRoutes.POST("/", h.createChore)
choresRoutes.GET("/:id", h.getChore)
+ choresRoutes.GET("/:id/details", h.GetChoreDetail)
choresRoutes.GET("/:id/history", h.GetChoreHistory)
choresRoutes.POST("/:id/do", h.completeChore)
choresRoutes.POST("/:id/skip", h.skipChore)
diff --git a/internal/chore/model/model.go b/internal/chore/model/model.go
index 4de7808..90f6c29 100644
--- a/internal/chore/model/model.go
+++ b/internal/chore/model/model.go
@@ -7,26 +7,26 @@ import (
)
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
+ 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"`
@@ -70,3 +70,16 @@ type Tag struct {
// CircleID int `json:"circleId" gorm:"primaryKey;autoIncrement:false"`
// TagID int `json:"tagId" gorm:"primaryKey;autoIncrement:false"`
// }
+
+type ChoreDetail struct {
+ ID int `json:"id" gorm:"column:id"`
+ Name string `json:"name" gorm:"column:name"`
+ FrequencyType string `json:"frequencyType" gorm:"column:frequency_type"`
+ NextDueDate *time.Time `json:"nextDueDate" gorm:"column:next_due_date"`
+ AssignedTo int `json:"assignedTo" gorm:"column:assigned_to"`
+ LastCompletedDate *time.Time `json:"lastCompletedDate" gorm:"column:last_completed_date"`
+ LastCompletedBy *int `json:"lastCompletedBy" gorm:"column:last_completed_by"`
+ TotalCompletedCount int `json:"totalCompletedCount" gorm:"column:total_completed"`
+ Notes *string `json:"notes" gorm:"column:notes"`
+ CreatedBy int `json:"createdBy" gorm:"column:created_by"`
+}
diff --git a/internal/chore/repo/repository.go b/internal/chore/repo/repository.go
index 1ab0f0b..7284202 100644
--- a/internal/chore/repo/repository.go
+++ b/internal/chore/repo/repository.go
@@ -111,6 +111,13 @@ func (r *ChoreRepository) GetChoreHistory(c context.Context, choreID int) ([]*ch
}
return histories, nil
}
+func (r *ChoreRepository) GetChoreHistoryWithLimit(c context.Context, choreID int, limit int) ([]*chModel.ChoreHistory, error) {
+ var histories []*chModel.ChoreHistory
+ if err := r.db.WithContext(c).Where("chore_id = ?", choreID).Order("completed_at desc").Limit(limit).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
@@ -214,3 +221,42 @@ func (r *ChoreRepository) SetDueDate(c context.Context, choreID int, dueDate tim
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
}
+
+func (r *ChoreRepository) GetChoreDetailByID(c context.Context, choreID int, circleID int) (*chModel.ChoreDetail, error) {
+ var choreDetail chModel.ChoreDetail
+ if err := r.db.WithContext(c).
+ Table("chores").
+ Select(`
+ chores.id,
+ chores.name,
+ chores.frequency_type,
+ chores.next_due_date,
+ chores.assigned_to,
+ chores.created_by,
+ recent_history.last_completed_date,
+ recent_history.notes,
+ recent_history.last_assigned_to as last_completed_by,
+ COUNT(chore_histories.id) as total_completed`).
+ Joins("LEFT JOIN chore_histories ON chores.id = chore_histories.chore_id").
+ Joins(`LEFT JOIN (
+ SELECT
+ chore_id,
+ assigned_to AS last_assigned_to,
+ completed_at AS last_completed_date,
+ notes
+
+ FROM chore_histories
+ WHERE (chore_id, completed_at) IN (
+ SELECT chore_id, MAX(completed_at)
+ FROM chore_histories
+ GROUP BY chore_id
+ )
+ ) AS recent_history ON chores.id = recent_history.chore_id`).
+ Where("chores.id = ? and chores.circle_id = ?", choreID, circleID).
+ Group("chores.id, recent_history.last_completed_date, recent_history.last_assigned_to, recent_history.notes").
+ First(&choreDetail).Error; err != nil {
+ return nil, err
+
+ }
+ return &choreDetail, nil
+}
diff --git a/internal/chore/scheduler.go b/internal/chore/scheduler.go
index 55cdf01..c1ff48d 100644
--- a/internal/chore/scheduler.go
+++ b/internal/chore/scheduler.go
@@ -47,6 +47,7 @@ func scheduleNextDueDate(chore *chModel.Chore, completedDate time.Time) (*time.T
} 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())
@@ -129,6 +130,33 @@ func scheduleNextDueDate(chore *chModel.Chore, completedDate time.Time) (*time.T
}
+func scheduleAdaptiveNextDueDate(chore *chModel.Chore, completedDate time.Time, history []*chModel.ChoreHistory) (*time.Time, error) {
+ // will generate due date base on history and the different between the completed date and the due date
+ // the more recent the higher weight
+ if len(history) <= 1 {
+ if chore.NextDueDate != nil {
+ diff := completedDate.UTC().Sub(chore.NextDueDate.UTC())
+ nextDueDate := completedDate.UTC().Add(diff)
+ return &nextDueDate, nil
+ }
+ return nil, nil
+ }
+ var weight float64
+ var totalWeight float64
+ var nextDueDate time.Time
+ for i := 0; i < len(history)-1; i++ {
+ delay := history[i].CompletedAt.UTC().Sub(history[i+1].CompletedAt.UTC()).Seconds()
+ weight = delay * float64(len(history)-i)
+ totalWeight += weight
+ }
+ // calculate the average delay
+ averageDelay := totalWeight / float64(len(history)-1)
+ // calculate the difference between the completed date and the due date
+ nextDueDate = completedDate.UTC().Add(time.Duration(averageDelay) * time.Second)
+
+ return &nextDueDate, nil
+}
+
func RemoveAssigneeAndReassign(chore *chModel.Chore, userID int) {
for i, assignee := range chore.Assignees {
if assignee.UserID == userID {
diff --git a/internal/notifier/service/planner.go b/internal/notifier/service/planner.go
index 22502ab..05d600d 100644
--- a/internal/notifier/service/planner.go
+++ b/internal/notifier/service/planner.go
@@ -48,6 +48,9 @@ func (n *NotificationPlanner) GenerateNotifications(c context.Context, chore *ch
var mt *chModel.NotificationMetadata
if err := json.Unmarshal([]byte(*chore.NotificationMetadata), &mt); err != nil {
log.Error("Error unmarshalling notification metadata", err)
+ return false
+ }
+ if chore.NextDueDate == nil {
return true
}
if mt.DueDate {
@@ -83,7 +86,7 @@ func generateDueNotifications(chore *chModel.Chore, users []*cModel.UserCircleDe
TypeID: 1,
UserID: user.ID,
TargetID: fmt.Sprint(user.ChatID),
- Text: fmt.Sprintf("📅 Reminder: '%s' is due today and assigned to %s.", chore.Name, assignee.DisplayName),
+ Text: fmt.Sprintf("📅 Reminder: *%s* is due today and assigned to %s.", chore.Name, assignee.DisplayName),
}
notifications = append(notifications, notification)
}
@@ -109,7 +112,7 @@ func generatePreDueNotifications(chore *chModel.Chore, users []*cModel.UserCircl
TypeID: 3,
UserID: user.ID,
TargetID: fmt.Sprint(user.ChatID),
- Text: fmt.Sprintf("📢 Heads up! Chore '%s' is due soon (on %s) and assigned to %s.", chore.Name, chore.NextDueDate.Format("January 2nd"), assignee.DisplayName),
+ Text: fmt.Sprintf("📢 Heads up! *%s* is due soon (on %s) and assigned to %s.", chore.Name, chore.NextDueDate.Format("January 2nd"), assignee.DisplayName),
}
notifications = append(notifications, notification)
@@ -138,7 +141,7 @@ func generateOverdueNotifications(chore *chModel.Chore, users []*cModel.UserCirc
TypeID: 2,
UserID: user.ID,
TargetID: fmt.Sprint(user.ChatID),
- Text: fmt.Sprintf("🚨 '%s' is now %d hours overdue. Please complete it as soon as possible. (Assigned to %s)", chore.Name, hours, assignee.DisplayName),
+ Text: fmt.Sprintf("🚨 *%s* is now %d hours overdue. Please complete it as soon as possible. (Assigned to %s)", chore.Name, hours, assignee.DisplayName),
}
notifications = append(notifications, notification)
}
diff --git a/internal/notifier/telegram/telegram.go b/internal/notifier/telegram/telegram.go
index e35f0c8..54c0905 100644
--- a/internal/notifier/telegram/telegram.go
+++ b/internal/notifier/telegram/telegram.go
@@ -55,7 +55,7 @@ func (tn *TelegramNotifier) SendChoreCompletion(c context.Context, chore *chMode
if user.ChatID == 0 {
continue
}
- text := fmt.Sprintf("🎉 '%s' is completed! is off the list, %s! 🌟 ", chore.Name, user.DisplayName)
+ text := fmt.Sprintf("🎉 *%s* is completed! is off the list, %s! 🌟 ", chore.Name, user.DisplayName)
msg := tgbotapi.NewMessage(user.ChatID, text)
msg.ParseMode = "Markdown"
_, err := tn.bot.Send(msg)
diff --git a/internal/thing/handler.go b/internal/thing/handler.go
index 65bc871..7121c82 100644
--- a/internal/thing/handler.go
+++ b/internal/thing/handler.go
@@ -261,6 +261,16 @@ func (h *Handler) DeleteThing(c *gin.Context) {
c.JSON(403, gin.H{"error": "Forbidden"})
return
}
+ // confirm there are no chores associated with the thing:
+ thingChores, err := h.tRepo.GetThingChoresByThingId(c, thing.ID)
+ if err != nil {
+ c.JSON(500, gin.H{"error": "Unable to find tasks linked to this thing"})
+ return
+ }
+ if len(thingChores) > 0 {
+ c.JSON(405, gin.H{"error": "Unable to delete thing with associated tasks"})
+ return
+ }
if err := h.tRepo.DeleteThing(c, thingID); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
diff --git a/internal/thing/repo/repository.go b/internal/thing/repo/repository.go
index a7b1fc9..ad5a92f 100644
--- a/internal/thing/repo/repository.go
+++ b/internal/thing/repo/repository.go
@@ -70,6 +70,10 @@ func (r *ThingRepository) DissociateThingWithChore(c context.Context, thingID in
return r.db.WithContext(c).Where("thing_id = ? AND chore_id = ?", thingID, choreID).Delete(&tModel.ThingChore{}).Error
}
+func (r *ThingRepository) DissociateChoreWithThing(c context.Context, choreID int) error {
+ return r.db.WithContext(c).Where("chore_id = ?", choreID).Delete(&tModel.ThingChore{}).Error
+}
+
func (r *ThingRepository) GetThingHistoryWithOffset(c context.Context, thingID int, offset int) ([]*tModel.ThingHistory, error) {
var thingHistory []*tModel.ThingHistory
if err := r.db.WithContext(c).Model(&tModel.ThingHistory{}).Where("thing_id = ?", thingID).Order("created_at desc").Offset(offset).Limit(10).Find(&thingHistory).Error; err != nil {
diff --git a/internal/user/handler.go b/internal/user/handler.go
index 0eee6f2..15e881f 100644
--- a/internal/user/handler.go
+++ b/internal/user/handler.go
@@ -69,6 +69,7 @@ func (h *Handler) signUp(c *gin.Context) {
type SignUpReq struct {
Username string `json:"username" binding:"required,min=4,max=20"`
Password string `json:"password" binding:"required,min=8,max=45"`
+ Email string `json:"email" binding:"required,email"`
DisplayName string `json:"displayName"`
}
var signupReq SignUpReq
@@ -96,6 +97,7 @@ func (h *Handler) signUp(c *gin.Context) {
Username: signupReq.Username,
Password: password,
DisplayName: signupReq.DisplayName,
+ Email: signupReq.Email,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}); err != nil {
@@ -281,9 +283,8 @@ func (h *Handler) resetPassword(c *gin.Context) {
}
user, err := h.userRepo.FindByEmail(c, req.Email)
if err != nil {
- c.JSON(http.StatusNotFound, gin.H{
- "error": "User not found",
- })
+ c.JSON(http.StatusOK, gin.H{})
+ log.Error("account.handler.resetPassword failed to find user")
return
}
if user.Provider != 0 {