Add subtask model and repository, implement webhook notification handling

fix Issue with Scheduler
Support NotificationPlatformWebhook
This commit is contained in:
Mo Tarbin 2025-02-25 23:56:49 -05:00
parent 41be361463
commit 8db572f1ec
13 changed files with 337 additions and 38 deletions

View file

@ -16,6 +16,7 @@ import (
chModel "donetick.com/core/internal/chore/model"
cRepo "donetick.com/core/internal/circle/repo"
stRepo "donetick.com/core/internal/subtask/repo"
uRepo "donetick.com/core/internal/user/repo"
)
@ -25,15 +26,17 @@ type API struct {
circleRepo *cRepo.CircleRepository
nPlanner *nps.NotificationPlanner
eventProducer *events.EventsProducer
stRepo *stRepo.SubTasksRepository
}
func NewAPI(cr *chRepo.ChoreRepository, userRepo *uRepo.UserRepository, circleRepo *cRepo.CircleRepository, nPlanner *nps.NotificationPlanner, eventProducer *events.EventsProducer) *API {
func NewAPI(cr *chRepo.ChoreRepository, userRepo *uRepo.UserRepository, circleRepo *cRepo.CircleRepository, nPlanner *nps.NotificationPlanner, eventProducer *events.EventsProducer, stRepo *stRepo.SubTasksRepository) *API {
return &API{
choreRepo: cr,
userRepo: userRepo,
circleRepo: circleRepo,
nPlanner: nPlanner,
eventProducer: eventProducer,
stRepo: stRepo,
}
}
@ -197,6 +200,10 @@ func (h *API) CompleteChore(c *gin.Context) {
})
return
}
if chore.SubTasks != nil && chore.FrequencyType != chModel.FrequencyTypeOnce {
h.stRepo.ResetSubtasksCompletion(c, chore.ID)
}
updatedChore, err := h.choreRepo.GetChore(c, choreID)
if err != nil {
c.JSON(500, gin.H{

View file

@ -20,6 +20,8 @@ import (
"donetick.com/core/internal/notifier"
nRepo "donetick.com/core/internal/notifier/repo"
nps "donetick.com/core/internal/notifier/service"
stModel "donetick.com/core/internal/subtask/model"
stRepo "donetick.com/core/internal/subtask/repo"
tRepo "donetick.com/core/internal/thing/repo"
uModel "donetick.com/core/internal/user/model"
"donetick.com/core/logging"
@ -36,11 +38,12 @@ type Handler struct {
tRepo *tRepo.ThingRepository
lRepo *lRepo.LabelRepository
eventProducer *events.EventsProducer
stRepo *stRepo.SubTasksRepository
}
func NewHandler(cr *chRepo.ChoreRepository, circleRepo *cRepo.CircleRepository, nt *notifier.Notifier,
np *nps.NotificationPlanner, nRepo *nRepo.NotificationRepository, tRepo *tRepo.ThingRepository, lRepo *lRepo.LabelRepository,
ep *events.EventsProducer) *Handler {
ep *events.EventsProducer, stRepo *stRepo.SubTasksRepository) *Handler {
return &Handler{
choreRepo: cr,
circleRepo: circleRepo,
@ -50,6 +53,7 @@ func NewHandler(cr *chRepo.ChoreRepository, circleRepo *cRepo.CircleRepository,
tRepo: tRepo,
lRepo: lRepo,
eventProducer: ep,
stRepo: stRepo,
}
}
@ -259,6 +263,7 @@ func (h *Handler) createChore(c *gin.Context) {
Points: choreReq.Points,
CompletionWindow: choreReq.CompletionWindow,
Description: choreReq.Description,
SubTasks: choreReq.SubTasks,
}
id, err := h.choreRepo.CreateChore(c, createdChore)
createdChore.ID = id
@ -270,6 +275,10 @@ func (h *Handler) createChore(c *gin.Context) {
return
}
if choreReq.SubTasks != nil {
h.stRepo.CreateSubtasks(c, nil, choreReq.SubTasks, createdChore.ID)
}
var choreAssignees []*chModel.ChoreAssignees
for _, assignee := range choreReq.Assignees {
choreAssignees = append(choreAssignees, &chModel.ChoreAssignees{
@ -521,6 +530,7 @@ func (h *Handler) editChore(c *gin.Context) {
Points: choreReq.Points,
CompletionWindow: choreReq.CompletionWindow,
Description: choreReq.Description,
Priority: choreReq.Priority,
}
if err := h.choreRepo.UpsertChore(c, updatedChore); err != nil {
c.JSON(500, gin.H{
@ -528,6 +538,54 @@ func (h *Handler) editChore(c *gin.Context) {
})
return
}
if choreReq.SubTasks != nil {
ToBeRemoved := []stModel.SubTask{}
ToBeAdded := []stModel.SubTask{}
if oldChore.SubTasks == nil {
oldChore.SubTasks = &[]stModel.SubTask{}
}
if choreReq.SubTasks == nil {
choreReq.SubTasks = &[]stModel.SubTask{}
}
for _, existedSubTask := range *oldChore.SubTasks {
found := false
for _, newSubTask := range *choreReq.SubTasks {
if existedSubTask.ID == newSubTask.ID {
found = true
break
}
}
if !found {
ToBeRemoved = append(ToBeRemoved, existedSubTask)
}
}
for _, newSubTask := range *choreReq.SubTasks {
found := false
newSubTask.ChoreID = oldChore.ID
for _, existedSubTask := range *oldChore.SubTasks {
if existedSubTask.ID == newSubTask.ID {
if existedSubTask.Name != newSubTask.Name || existedSubTask.OrderID != newSubTask.OrderID {
// there is a change in the subtask, update it
break
}
found = true
break
}
}
if !found {
ToBeAdded = append(ToBeAdded, newSubTask)
}
}
if err := h.stRepo.UpdateSubtask(c, oldChore.ID, ToBeRemoved, ToBeAdded); err != nil {
c.JSON(500, gin.H{
"error": "Error adding subtasks",
})
return
}
}
if len(choreAssigneesToAdd) > 0 {
err = h.choreRepo.UpdateChoreAssignees(c, choreAssigneesToAdd)
@ -1077,6 +1135,10 @@ func (h *Handler) completeChore(c *gin.Context) {
})
return
}
if updatedChore.SubTasks != nil && updatedChore.FrequencyType != chModel.FrequencyTypeOnce {
h.stRepo.ResetSubtasksCompletion(c, updatedChore.ID)
}
// go func() {
// h.notifier.SendChoreCompletion(c, chore, currentUser)
@ -1355,6 +1417,76 @@ func (h *Handler) DeleteHistory(c *gin.Context) {
"message": "History deleted successfully",
})
}
func (h *Handler) UpdateSubtaskCompletedAt(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")
choreID, err := strconv.Atoi(rawID)
if err != nil {
c.JSON(400, gin.H{
"error": "Invalid Chore ID",
})
return
}
type SubtaskReq struct {
ID int `json:"id"`
ChoreID int `json:"choreId"`
CompletedAt *time.Time `json:"completedAt"`
}
var req SubtaskReq
if err := c.ShouldBindJSON(&req); err != nil {
log.Print(err)
c.JSON(400, gin.H{
"error": "Invalid request",
})
return
}
chore, err := h.choreRepo.GetChore(c, choreID)
if err != nil {
c.JSON(500, gin.H{
"error": "Error getting chore",
})
return
}
if !chore.CanComplete(currentUser.ID) {
c.JSON(400, gin.H{
"error": "User is not assigned to chore",
})
return
}
var completedAt *time.Time
if req.CompletedAt != nil {
completedAt = req.CompletedAt
}
err = h.stRepo.UpdateSubTaskStatus(c, currentUser.ID, req.ID, completedAt)
if err != nil {
c.JSON(500, gin.H{
"error": "Error getting subtask",
})
return
}
h.eventProducer.SubtaskUpdated(c, currentUser.WebhookURL,
&stModel.SubTask{
ID: req.ID,
ChoreID: req.ChoreID,
CompletedAt: completedAt,
CompletedBy: currentUser.ID,
},
)
c.JSON(200, gin.H{})
}
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))
@ -1470,6 +1602,7 @@ func Routes(router *gin.Engine, h *Handler, auth *jwt.GinJWTMiddleware) {
choresRoutes.PUT("/:id/priority", h.updatePriority)
choresRoutes.POST("/", h.createChore)
choresRoutes.GET("/:id", h.getChore)
choresRoutes.PUT("/:id/subtask", h.UpdateSubtaskCompletedAt)
choresRoutes.GET("/:id/details", h.GetChoreDetail)
choresRoutes.GET("/:id/history", h.GetChoreHistory)
choresRoutes.PUT("/:id/history/:history_id", h.ModifyHistory)

View file

@ -5,8 +5,8 @@ import (
cModel "donetick.com/core/internal/circle/model"
lModel "donetick.com/core/internal/label/model"
stModel "donetick.com/core/internal/subtask/model"
tModel "donetick.com/core/internal/thing/model"
thingModel "donetick.com/core/internal/thing/model"
)
type FrequencyType string
@ -62,6 +62,7 @@ type Chore struct {
CompletionWindow *int `json:"completionWindow,omitempty" gorm:"column:completion_window"` // Number seconds before the chore is due that it can be completed
Points *int `json:"points,omitempty" gorm:"column:points"` // Points for completing the chore
Description *string `json:"description,omitempty" gorm:"type:text;column:description"` // Description of the chore
SubTasks *[]stModel.SubTask `json:"subTasks,omitempty" gorm:"foreignkey:ChoreID;references:ID"` // Subtasks for the chore
}
type Status int8
@ -119,19 +120,20 @@ type Tag struct {
}
type ChoreDetail struct {
ID int `json:"id" gorm:"column:id"`
Name string `json:"name" gorm:"column:name"`
Description *string `json:"description" gorm:"column:description"`
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"`
Priority int `json:"priority" gorm:"column:priority"`
Notes *string `json:"notes" gorm:"column:notes"`
CreatedBy int `json:"createdBy" gorm:"column:created_by"`
CompletionWindow *int `json:"completionWindow,omitempty" gorm:"column:completion_window"`
ID int `json:"id" gorm:"column:id"`
Name string `json:"name" gorm:"column:name"`
Description *string `json:"description" gorm:"column:description"`
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"`
Priority int `json:"priority" gorm:"column:priority"`
Notes *string `json:"notes" gorm:"column:notes"`
CreatedBy int `json:"createdBy" gorm:"column:created_by"`
CompletionWindow *int `json:"completionWindow,omitempty" gorm:"column:completion_window"`
Subtasks *[]stModel.SubTask `json:"subTasks,omitempty" gorm:"foreignkey:ChoreID;references:ID"`
}
type Label struct {
@ -150,25 +152,27 @@ type ChoreLabels struct {
}
type ChoreReq struct {
Name string `json:"name" binding:"required"`
FrequencyType FrequencyType `json:"frequencyType"`
ID int `json:"id"`
DueDate string `json:"dueDate"`
Assignees []ChoreAssignees `json:"assignees"`
AssignStrategy AssignmentStrategy `json:"assignStrategy" binding:"required"`
AssignedTo int `json:"assignedTo"`
IsRolling bool `json:"isRolling"`
IsActive bool `json:"isActive"`
Frequency int `json:"frequency"`
FrequencyMetadata *FrequencyMetadata `json:"frequencyMetadata"`
Notification bool `json:"notification"`
NotificationMetadata *NotificationMetadata `json:"notificationMetadata"`
Labels []string `json:"labels"`
LabelsV2 *[]lModel.LabelReq `json:"labelsV2"`
ThingTrigger *thingModel.ThingTrigger `json:"thingTrigger"`
Points *int `json:"points"`
CompletionWindow *int `json:"completionWindow"`
Description *string `json:"description"`
Name string `json:"name" binding:"required"`
FrequencyType FrequencyType `json:"frequencyType"`
ID int `json:"id"`
DueDate string `json:"dueDate"`
Assignees []ChoreAssignees `json:"assignees"`
AssignStrategy AssignmentStrategy `json:"assignStrategy" binding:"required"`
AssignedTo int `json:"assignedTo"`
IsRolling bool `json:"isRolling"`
IsActive bool `json:"isActive"`
Frequency int `json:"frequency"`
FrequencyMetadata *FrequencyMetadata `json:"frequencyMetadata"`
Notification bool `json:"notification"`
NotificationMetadata *NotificationMetadata `json:"notificationMetadata"`
Labels []string `json:"labels"`
LabelsV2 *[]lModel.LabelReq `json:"labelsV2"`
ThingTrigger *tModel.ThingTrigger `json:"thingTrigger"`
Points *int `json:"points"`
CompletionWindow *int `json:"completionWindow"`
Description *string `json:"description"`
Priority int `json:"priority"`
SubTasks *[]stModel.SubTask `json:"subTasks"`
}
func (c *Chore) CanEdit(userID int, circleUsers []*cModel.UserCircleDetail) bool {

View file

@ -44,7 +44,7 @@ func (r *ChoreRepository) CreateChore(c context.Context, chore *chModel.Chore) (
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").Preload("LabelsV2").First(&chore, choreID).Error; err != nil {
if err := r.db.Debug().WithContext(c).Model(&chModel.Chore{}).Preload("SubTasks").Preload("Assignees").Preload("ThingChore").Preload("LabelsV2").First(&chore, choreID).Error; err != nil {
return nil, err
}
return &chore, nil
@ -279,6 +279,7 @@ func (r *ChoreRepository) GetChoreDetailByID(c context.Context, choreID int, cir
var choreDetail chModel.ChoreDetail
if err := r.db.WithContext(c).
Table("chores").
Preload("Subtasks").
Select(`
chores.id,
chores.name,

View file

@ -91,6 +91,16 @@ func scheduleNextDueDate(chore *chModel.Chore, completedDate time.Time) (*time.T
}
return nil, fmt.Errorf("no matching day of the week found")
case "day_of_the_month":
// for day of the month we need to pick the highest between completed date and next due date
// when the chore is rolling. i keep forgetting so am writing a detail comment here:
// if task due every 15 of jan, and you completed it on the 13 of jan( before the due date ) if we schedule from due date
// we will go back to 15 of jan. so we need to pick the highest between the two dates specifically for day of the month
if chore.IsRolling && chore.NextDueDate != nil {
secondAfterDueDate := chore.NextDueDate.UTC().Add(time.Second)
if completedDate.Before(secondAfterDueDate) {
baseDate = secondAfterDueDate
}
}
if len(frequencyMetadata.Months) == 0 {
return nil, fmt.Errorf("day_of_the_month requires at least one month")
}
@ -101,7 +111,13 @@ func scheduleNextDueDate(chore *chModel.Chore, completedDate time.Time) (*time.T
// Find the next valid day of the month, considering the year
currentMonth := int(baseDate.Month())
for i := 0; i < 12; i++ { // Start from 0 to check the current month first
var startFrom int
if chore.NextDueDate != nil && baseDate.Month() == chore.NextDueDate.Month() {
startFrom = 1
}
for i := startFrom; i < 12+startFrom; i++ { // Start from 0 to check the current month first
nextDueDate := baseDate.AddDate(0, i, 0)
nextMonth := (currentMonth + i) % 12 // Use modulo to cycle through months
if nextMonth == 0 {

View file

@ -224,11 +224,25 @@ func TestScheduleNextDueDateDayOfMonth(t *testing.T) {
chore: chModel.Chore{
FrequencyType: chModel.FrequencyTypeDayOfTheMonth,
Frequency: 15,
IsRolling: true,
FrequencyMetadata: jsonPtr(`{ "unit": "days", "time": "2025-01-20T18:00:00-05:00", "days": [], "months": [ "january" ] }`),
},
completedDate: now.AddDate(1, 1, 0),
want: timePtr(time.Date(2027, 1, 15, 18, 0, 0, 0, location)),
},
// test if completed before the 15th of the month:
{
name: "Day of the month - 15th of January(isRolling)(Completed before due date)",
chore: chModel.Chore{
NextDueDate: timePtr(time.Date(2025, 1, 15, 18, 0, 0, 0, location)),
FrequencyType: chModel.FrequencyTypeDayOfTheMonth,
Frequency: 15,
IsRolling: true,
FrequencyMetadata: jsonPtr(`{ "unit": "days", "time": "2025-01-20T18:00:00-05:00", "days": [], "months": [ "january" ] }`),
},
completedDate: now.AddDate(0, 0, 2),
want: timePtr(time.Date(2026, 1, 15, 18, 0, 0, 0, location)),
},
}
executeTestTable(t, tests)

View file

@ -10,6 +10,7 @@ import (
cModel "donetick.com/core/internal/circle/model"
nModel "donetick.com/core/internal/notifier/model"
pModel "donetick.com/core/internal/points"
stModel "donetick.com/core/internal/subtask/model"
tModel "donetick.com/core/internal/thing/model"
uModel "donetick.com/core/internal/user/model" // Pure go SQLite driver, checkout https://github.com/glebarez/sqlite for details
migrations "donetick.com/core/migrations"
@ -37,6 +38,7 @@ func Migration(db *gorm.DB) error {
chModel.ChoreLabels{},
migrations.Migration{},
pModel.PointsHistory{},
stModel.SubTask{},
); err != nil {
return err
}

View file

@ -28,7 +28,8 @@ const (
// EventTypeTaskCreated EventType = "task.created"
EventTypeTaskReminder EventType = "task.reminder"
// EventTypeTaskUpdated EventType = "task.updated"
EventTypeTaskCompleted EventType = "task.completed"
EventTypeTaskCompleted EventType = "task.completed"
EventTypeSubTaskCompleted EventType = "subtask.completed"
// EventTypeTaskReassigned EventType = "task.reassigned"
EventTypeTaskSkipped EventType = "task.skipped"
EventTypeThingChanged EventType = "thing.changed"
@ -172,3 +173,16 @@ func (p *EventsProducer) ThingsUpdated(ctx context.Context, url string, data int
Data: data,
})
}
func (p *EventsProducer) SubtaskUpdated(ctx context.Context, url *string, data interface{}) {
if url == nil {
p.logger.Debug("No subscribers for circle, skipping webhook")
return
}
p.publishEvent(Event{
URL: *url,
Type: EventTypeSubTaskCompleted,
Timestamp: time.Now(),
Data: data,
})
}

View file

@ -36,6 +36,7 @@ const (
NotificationPlatformNone NotificationPlatform = iota
NotificationPlatformTelegram
NotificationPlatformPushover
NotificationPlatformWebhook
)
type JSONB map[string]interface{}

View file

@ -41,6 +41,15 @@ func (n *Notifier) SendNotification(c context.Context, notification *nModel.Noti
return nil
}
err = n.Pushover.SendNotification(c, notification)
case nModel.NotificationPlatformWebhook:
// TODO: Implement webhook notification
// currently we have eventProducer to send events always as a webhook
// if NotificationPlatform is selected. this a case to catch
// when we only want to send a webhook
default:
log.Error("Unknown notification type", "type", notification.TypeID)
return nil
}
if err != nil {
log.Error("Failed to send notification", "err", err)

View file

@ -0,0 +1,12 @@
package model
import "time"
type SubTask struct {
ID int `json:"id" gorm:"primary_key"`
ChoreID int `json:"-" gorm:"column:chore_id;index"`
OrderID int8 `json:"orderId" gorm:"column:order_id"`
Name string `json:"name" gorm:"column:name"`
CompletedAt *time.Time `json:"completedAt" gorm:"column:completed_at"`
CompletedBy int `json:"completedBy" gorm:"column:completed_by"`
}

View file

@ -0,0 +1,84 @@
package repo
import (
"context"
"time"
stModel "donetick.com/core/internal/subtask/model"
"gorm.io/gorm"
)
type SubTasksRepository struct {
db *gorm.DB
}
func NewSubTasksRepository(db *gorm.DB) *SubTasksRepository {
return &SubTasksRepository{db}
}
func (r *SubTasksRepository) CreateSubtasks(c context.Context, tx *gorm.DB, subtasks *[]stModel.SubTask, choreID int) error {
if tx != nil {
return tx.Model(&stModel.SubTask{}).Save(subtasks).Error
}
return r.db.WithContext(c).Save(subtasks).Error
}
func (r *SubTasksRepository) UpdateSubtask(c context.Context, choreId int, toBeRemove []stModel.SubTask, toBeAdd []stModel.SubTask) error {
return r.db.WithContext(c).Transaction(func(tx *gorm.DB) error {
if len(toBeRemove) == 0 && len(toBeAdd) == 0 {
return nil
}
if len(toBeRemove) > 0 {
if err := tx.Delete(toBeRemove).Error; err != nil {
return err
}
}
if len(toBeAdd) > 0 {
var insertions []stModel.SubTask
var updates []stModel.SubTask
for _, subtask := range toBeAdd {
if subtask.ID == 0 {
insertions = append(insertions, subtask)
} else {
updates = append(updates, subtask)
}
}
if len(insertions) > 0 {
if err := tx.Create(&insertions).Error; err != nil {
return err
}
}
if len(updates) > 0 {
for _, subtask := range updates {
if err := tx.Model(&stModel.SubTask{}).Where("chore_id = ? AND id = ?", choreId, subtask.ID).Updates(subtask).Error; err != nil {
return err
}
}
}
}
return nil
})
}
func (r *SubTasksRepository) DeleteSubtask(c context.Context, tx *gorm.DB, subtaskID int) error {
if tx != nil {
return tx.Delete(&stModel.SubTask{}, subtaskID).Error
}
return r.db.WithContext(c).Delete(&stModel.SubTask{}, subtaskID).Error
}
func (r *SubTasksRepository) UpdateSubTaskStatus(c context.Context, userID int, subtaskID int, completedAt *time.Time) error {
return r.db.Model(&stModel.SubTask{}).Where("id = ?", subtaskID).Updates(map[string]interface{}{
"completed_at": completedAt,
"completed_by": userID,
}).Error
}
func (r *SubTasksRepository) ResetSubtasksCompletion(c context.Context, choreID int) error {
return r.db.Model(&stModel.SubTask{}).Where("chore_id = ?", choreID).Updates(map[string]interface{}{
"completed_at": nil,
"completed_by": nil,
}).Error
}

View file

@ -26,6 +26,7 @@ import (
label "donetick.com/core/internal/label"
lRepo "donetick.com/core/internal/label/repo"
"donetick.com/core/internal/resource"
spRepo "donetick.com/core/internal/subtask/repo"
notifier "donetick.com/core/internal/notifier"
nRepo "donetick.com/core/internal/notifier/repo"
@ -89,6 +90,7 @@ func main() {
// points
fx.Provide(pRepo.NewPointsRepository),
fx.Provide(spRepo.NewSubTasksRepository),
// Labels:
fx.Provide(lRepo.NewLabelRepository),