aboutsummaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--internal/authorization/middleware.go137
-rw-r--r--internal/authorization/password.go60
-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
-rw-r--r--internal/circle/handler.go442
-rw-r--r--internal/circle/model/model.go35
-rw-r--r--internal/circle/repo/repository.go117
-rw-r--r--internal/database/database.go44
-rw-r--r--internal/email/sender.go509
-rw-r--r--internal/notifier/model/model.go15
-rw-r--r--internal/notifier/repo/repository.go43
-rw-r--r--internal/notifier/scheduler.go89
-rw-r--r--internal/notifier/service/planner.go149
-rw-r--r--internal/notifier/telegram/telegram.go127
-rw-r--r--internal/thing/handler.go281
-rw-r--r--internal/thing/helper.go57
-rw-r--r--internal/thing/model/model.go30
-rw-r--r--internal/thing/repo/repository.go117
-rw-r--r--internal/thing/webhook.go175
-rw-r--r--internal/user/handler.go511
-rw-r--r--internal/user/model/model.go38
-rw-r--r--internal/user/repo/repository.go160
-rw-r--r--internal/utils/key_generator.go28
-rw-r--r--internal/utils/middleware.go72
26 files changed, 4643 insertions, 0 deletions
diff --git a/internal/authorization/middleware.go b/internal/authorization/middleware.go
new file mode 100644
index 0000000..18a7026
--- /dev/null
+++ b/internal/authorization/middleware.go
@@ -0,0 +1,137 @@
+package auth
+
+import (
+ "net/http"
+ "time"
+
+ "donetick.com/core/config"
+ uModel "donetick.com/core/internal/user/model"
+ uRepo "donetick.com/core/internal/user/repo"
+ "donetick.com/core/logging"
+ jwt "github.com/appleboy/gin-jwt/v2"
+ "github.com/gin-gonic/gin"
+ "golang.org/x/crypto/bcrypt"
+)
+
+var identityKey = "id"
+
+type signIn struct {
+ Username string `form:"username" json:"username" binding:"required"`
+ Password string `form:"password" json:"password" binding:"required"`
+}
+
+func CurrentUser(c *gin.Context) (*uModel.User, bool) {
+ data, ok := c.Get(identityKey)
+ if !ok {
+ return nil, false
+ }
+ acc, ok := data.(*uModel.User)
+ return acc, ok
+}
+
+func MustCurrentUser(c *gin.Context) *uModel.User {
+ acc, ok := CurrentUser(c)
+ if ok {
+ return acc
+ }
+ panic("no account in gin.Context")
+}
+
+func NewAuthMiddleware(cfg *config.Config, userRepo *uRepo.UserRepository) (*jwt.GinJWTMiddleware, error) {
+ return jwt.New(&jwt.GinJWTMiddleware{
+ Realm: "test zone",
+ Key: []byte(cfg.Jwt.Secret),
+ Timeout: cfg.Jwt.SessionTime,
+ MaxRefresh: cfg.Jwt.MaxRefresh, // 7 days as long as their token is valid they can refresh it
+ IdentityKey: identityKey,
+ PayloadFunc: func(data interface{}) jwt.MapClaims {
+ if u, ok := data.(*uModel.User); ok {
+ return jwt.MapClaims{
+ identityKey: u.Username,
+ }
+ }
+ return jwt.MapClaims{}
+ },
+ IdentityHandler: func(c *gin.Context) interface{} {
+ claims := jwt.ExtractClaims(c)
+ username, ok := claims[identityKey].(string)
+ if !ok {
+ return nil
+ }
+ user, err := userRepo.GetUserByUsername(c.Request.Context(), username)
+ if err != nil {
+ return nil
+ }
+ return user
+ },
+ Authenticator: func(c *gin.Context) (interface{}, error) {
+ provider := c.Value("auth_provider")
+ switch provider {
+ case nil:
+ var req signIn
+ if err := c.ShouldBindJSON(&req); err != nil {
+ return "", jwt.ErrMissingLoginValues
+ }
+
+ // ctx := cache.WithCacheSkip(c.Request.Context(), true)
+ user, err := userRepo.GetUserByUsername(c.Request.Context(), req.Username)
+ if err != nil || user.Disabled {
+ return nil, jwt.ErrFailedAuthentication
+ }
+ err = Matches(user.Password, req.Password)
+ if err != nil {
+ if err != bcrypt.ErrMismatchedHashAndPassword {
+ logging.FromContext(c).Warnw("middleware.jwt.Authenticator found unknown error when matches password", "err", err)
+ }
+ return nil, jwt.ErrFailedAuthentication
+ }
+ return &uModel.User{
+ ID: user.ID,
+ Username: user.Username,
+ Password: "",
+ Image: user.Image,
+ CreatedAt: user.CreatedAt,
+ UpdatedAt: user.UpdatedAt,
+ Disabled: user.Disabled,
+ CircleID: user.CircleID,
+ }, nil
+ case "3rdPartyAuth":
+ // we should only reach this stage if a handler mannually call authenticator with it's context:
+
+ var authObject *uModel.User
+ v := c.Value("user_account")
+ authObject = v.(*uModel.User)
+
+ return authObject, nil
+
+ default:
+ return nil, jwt.ErrFailedAuthentication
+ }
+ },
+
+ Authorizator: func(data interface{}, c *gin.Context) bool {
+
+ if _, ok := data.(*uModel.User); ok {
+ return true
+ }
+ return false
+ },
+ Unauthorized: func(c *gin.Context, code int, message string) {
+ logging.FromContext(c).Info("middleware.jwt.Unauthorized", "code", code, "message", message)
+ c.JSON(code, gin.H{
+ "code": code,
+ "message": message,
+ })
+ },
+ LoginResponse: func(c *gin.Context, code int, token string, expire time.Time) {
+ c.JSON(http.StatusOK, gin.H{
+ "code": code,
+ "token": token,
+ "expire": expire,
+ })
+ },
+ TokenLookup: "header: Authorization",
+ TokenHeadName: "Bearer",
+ TimeFunc: time.Now,
+ })
+}
diff --git a/internal/authorization/password.go b/internal/authorization/password.go
new file mode 100644
index 0000000..00cd222
--- /dev/null
+++ b/internal/authorization/password.go
@@ -0,0 +1,60 @@
+package auth
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "math/big"
+
+ "donetick.com/core/logging"
+ "github.com/gin-gonic/gin"
+ "golang.org/x/crypto/bcrypt"
+)
+
+const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;':,.<>?/~"
+
+func EncodePassword(password string) (string, error) {
+ bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return "", err
+ }
+ return string(bytes), nil
+}
+
+func Matches(hashedPassword, password string) error {
+ return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
+}
+
+func GenerateRandomPassword(length int) string {
+ // Create a buffer to hold the random bytes.
+ buffer := make([]byte, length)
+
+ // Compute the maximum index for the characters.
+ maxIndex := big.NewInt(int64(len(chars)))
+
+ // Generate random bytes and use them to select characters from the set.
+ for i := 0; i < length; i++ {
+ randomIndex, _ := rand.Int(rand.Reader, maxIndex)
+ buffer[i] = chars[randomIndex.Int64()]
+ }
+
+ return string(buffer)
+}
+
+func GenerateEmailResetToken(c *gin.Context) (string, error) {
+ logger := logging.FromContext(c)
+ // Define the length of the token (in bytes). For example, 32 bytes will result in a 44-character base64-encoded token.
+ tokenLength := 32
+
+ // Generate a random byte slice.
+ tokenBytes := make([]byte, tokenLength)
+ _, err := rand.Read(tokenBytes)
+ if err != nil {
+ logger.Errorw("password.GenerateEmailResetToken failed to generate random bytes", "err", err)
+ return "", err
+ }
+
+ // Encode the byte slice to a base64 string.
+ token := base64.URLEncoding.EncodeToString(tokenBytes)
+
+ return token, nil
+}
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()
+}
diff --git a/internal/circle/handler.go b/internal/circle/handler.go
new file mode 100644
index 0000000..c15d322
--- /dev/null
+++ b/internal/circle/handler.go
@@ -0,0 +1,442 @@
+package circle
+
+import (
+ "log"
+
+ "strconv"
+ "time"
+
+ auth "donetick.com/core/internal/authorization"
+ "donetick.com/core/internal/chore"
+ chRepo "donetick.com/core/internal/chore/repo"
+ cModel "donetick.com/core/internal/circle/model"
+ cRepo "donetick.com/core/internal/circle/repo"
+ uModel "donetick.com/core/internal/user/model"
+ uRepo "donetick.com/core/internal/user/repo"
+ "donetick.com/core/logging"
+ jwt "github.com/appleboy/gin-jwt/v2"
+ "github.com/gin-gonic/gin"
+)
+
+type Handler struct {
+ circleRepo *cRepo.CircleRepository
+ userRepo *uRepo.UserRepository
+ choreRepo *chRepo.ChoreRepository
+}
+
+func NewHandler(cr *cRepo.CircleRepository, ur *uRepo.UserRepository, c *chRepo.ChoreRepository) *Handler {
+ return &Handler{
+ circleRepo: cr,
+ userRepo: ur,
+ choreRepo: c,
+ }
+}
+
+func (h *Handler) GetCircleMembers(c *gin.Context) {
+ // Get the circle ID from the JWT
+ log := logging.FromContext(c)
+ currentUser, ok := auth.CurrentUser(c)
+ if !ok {
+ log.Error("Error getting current user")
+ c.JSON(500, gin.H{
+ "error": "Error getting current user",
+ })
+ return
+ }
+
+ // Get all the members of the circle
+ members, err := h.circleRepo.GetCircleUsers(c, currentUser.CircleID)
+ if err != nil {
+ log.Error("Error getting circle members:", err)
+ c.JSON(500, gin.H{
+ "error": "Error getting circle members",
+ })
+ return
+ }
+
+ c.JSON(200, gin.H{
+ "res": members,
+ })
+}
+
+func (h *Handler) JoinCircle(c *gin.Context) {
+ // Get the circle ID from the JWT
+ log := logging.FromContext(c)
+ log.Debug("handlder.go: JoinCircle")
+ currentUser, ok := auth.CurrentUser(c)
+
+ if !ok {
+ c.JSON(500, gin.H{
+ "error": "Error getting current user",
+ })
+ return
+ }
+
+ requestedCircleID := c.Query("invite_code")
+ if requestedCircleID == "" {
+ c.JSON(400, gin.H{
+ "error": "Invalid request",
+ })
+ return
+ }
+
+ circle, err := h.circleRepo.GetCircleByInviteCode(c, requestedCircleID)
+
+ if circle.ID == currentUser.CircleID {
+ c.JSON(409, gin.H{
+ "error": "You are already a member of this circle",
+ })
+ return
+ }
+
+ // Add the user to the circle
+ err = h.circleRepo.AddUserToCircle(c, &cModel.UserCircle{
+ CircleID: circle.ID,
+ UserID: currentUser.ID,
+ Role: "member",
+ IsActive: false,
+ })
+
+ if err != nil {
+ log.Error("Error adding user to circle:", err)
+ c.JSON(500, gin.H{
+ "error": "Error adding user to circle",
+ })
+ return
+ }
+
+ c.JSON(200, gin.H{
+ "res": "User Requested to join circle successfully",
+ })
+}
+
+func (h *Handler) LeaveCircle(c *gin.Context) {
+ log := logging.FromContext(c)
+ log.Debug("handler.go: LeaveCircle")
+ currentUser, ok := auth.CurrentUser(c)
+ if !ok {
+ c.JSON(500, gin.H{
+ "error": "Error getting current user",
+ })
+ return
+ }
+ rawCircleID := c.Query("circle_id")
+ circleID, err := strconv.Atoi(rawCircleID)
+ if err != nil {
+ c.JSON(400, gin.H{
+ "error": "Invalid request",
+ })
+ return
+ }
+
+ orginalCircleID, err := h.circleRepo.GetUserOriginalCircle(c, currentUser.ID)
+ if err != nil {
+ log.Error("Error getting user original circle:", err)
+ c.JSON(500, gin.H{
+ "error": "Error getting user original circle",
+ })
+ return
+ }
+
+ // START : HANDLE USER LEAVING CIRCLE
+ // bulk update chores:
+ if err := handleUserLeavingCircle(h, c, currentUser, orginalCircleID); err != nil {
+ log.Error("Error handling user leaving circle:", err)
+ c.JSON(500, gin.H{
+ "error": "Error handling user leaving circle",
+ })
+ return
+ }
+
+ // END: HANDLE USER LEAVING CIRCLE
+
+ err = h.circleRepo.LeaveCircleByUserID(c, circleID, currentUser.ID)
+ if err != nil {
+ log.Error("Error leaving circle:", err)
+ c.JSON(500, gin.H{
+ "error": "Error leaving circle",
+ })
+ return
+ }
+
+ if err := h.userRepo.UpdateUserCircle(c, currentUser.ID, orginalCircleID); err != nil {
+ log.Error("Error updating user circle:", err)
+ c.JSON(500, gin.H{
+ "error": "Error updating user circle",
+ })
+ return
+ }
+ c.JSON(200, gin.H{
+ "res": "User left circle successfully",
+ })
+}
+
+func handleUserLeavingCircle(h *Handler, c *gin.Context, leavingUser *uModel.User, orginalCircleID int) error {
+ userAssignedCircleChores, err := h.choreRepo.GetChores(c, leavingUser.CircleID, leavingUser.ID)
+ if err != nil {
+ return err
+ }
+ for _, ch := range userAssignedCircleChores {
+
+ if ch.CreatedBy == leavingUser.ID && ch.AssignedTo != leavingUser.ID {
+ ch.AssignedTo = leavingUser.ID
+ ch.UpdatedAt = time.Now()
+ ch.UpdatedBy = leavingUser.ID
+ ch.CircleID = orginalCircleID
+ } else if ch.CreatedBy != leavingUser.ID && ch.AssignedTo == leavingUser.ID {
+ chore.RemoveAssigneeAndReassign(ch, leavingUser.ID)
+ }
+
+ }
+
+ h.choreRepo.UpdateChores(c, userAssignedCircleChores)
+ h.choreRepo.RemoveChoreAssigneeByCircleID(c, leavingUser.ID, leavingUser.CircleID)
+ return nil
+}
+
+func (h *Handler) DeleteCircleMember(c *gin.Context) {
+ log := logging.FromContext(c)
+ log.Debug("handler.go: DeleteCircleMember")
+ currentUser, ok := auth.CurrentUser(c)
+ if !ok {
+ c.JSON(500, gin.H{
+ "error": "Error getting current user",
+ })
+ return
+ }
+ rawCircleID := c.Param("id")
+ circleID, err := strconv.Atoi(rawCircleID)
+ if err != nil {
+ c.JSON(400, gin.H{
+ "error": "Invalid request",
+ })
+ return
+ }
+ rawMemeberIDToDeleted := c.Query("member_id")
+ memberIDToDeleted, err := strconv.Atoi(rawMemeberIDToDeleted)
+ if err != nil {
+ c.JSON(400, gin.H{
+ "error": "Invalid request",
+ })
+ return
+ }
+ admins, err := h.circleRepo.GetCircleAdmins(c, circleID)
+ if err != nil {
+ log.Error("Error getting circle admins:", err)
+ c.JSON(500, gin.H{
+ "error": "Error getting circle admins",
+ })
+ return
+ }
+ isAdmin := false
+ for _, admin := range admins {
+ if admin.UserID == currentUser.ID {
+ isAdmin = true
+ break
+ }
+ }
+ if !isAdmin {
+ c.JSON(403, gin.H{
+ "error": "You are not an admin of this circle",
+ })
+ return
+ }
+ orginalCircleID, err := h.circleRepo.GetUserOriginalCircle(c, memberIDToDeleted)
+ if handleUserLeavingCircle(h, c, &uModel.User{ID: memberIDToDeleted, CircleID: circleID}, orginalCircleID) != nil {
+ log.Error("Error handling user leaving circle:", err)
+ c.JSON(500, gin.H{
+ "error": "Error handling user leaving circle",
+ })
+ return
+ }
+
+ err = h.circleRepo.DeleteMemberByID(c, circleID, memberIDToDeleted)
+ if err != nil {
+ log.Error("Error deleting circle member:", err)
+ c.JSON(500, gin.H{
+ "error": "Error deleting circle member",
+ })
+ return
+ }
+ c.JSON(200, gin.H{
+ "res": "User deleted from circle successfully",
+ })
+}
+
+func (h *Handler) GetUserCircles(c *gin.Context) {
+ log := logging.FromContext(c)
+ currentUser, ok := auth.CurrentUser(c)
+ if !ok {
+ c.JSON(500, gin.H{
+ "error": "Error getting current user",
+ })
+ return
+ }
+
+ circles, err := h.circleRepo.GetUserCircles(c, currentUser.ID)
+ if err != nil {
+ log.Error("Error getting user circles:", err)
+ c.JSON(500, gin.H{
+ "error": "Error getting user circles",
+ })
+ return
+ }
+
+ c.JSON(200, gin.H{
+ "res": circles,
+ })
+}
+
+func (h *Handler) GetPendingCircleMembers(c *gin.Context) {
+ log := logging.FromContext(c)
+ currentUser, ok := auth.CurrentUser(c)
+ if !ok {
+ c.JSON(500, gin.H{
+ "error": "Error getting current user",
+ })
+ return
+ }
+
+ currentMemebers, err := h.circleRepo.GetCircleUsers(c, currentUser.CircleID)
+ if err != nil {
+ log.Error("Error getting circle members:", err)
+ c.JSON(500, gin.H{
+ "error": "Error getting circle members",
+ })
+ return
+ }
+
+ // confirm that the current user is an admin:
+ isAdmin := false
+ for _, member := range currentMemebers {
+ if member.UserID == currentUser.ID && member.Role == "admin" {
+ isAdmin = true
+ break
+ }
+ }
+ if !isAdmin {
+ c.JSON(403, gin.H{
+ "error": "You are not an admin of this circle",
+ })
+ return
+ }
+
+ members, err := h.circleRepo.GetPendingJoinRequests(c, currentUser.CircleID)
+ if err != nil {
+ log.Error("Error getting pending circle members:", err)
+ c.JSON(500, gin.H{
+ "error": "Error getting pending circle members",
+ })
+ return
+ }
+
+ c.JSON(200, gin.H{
+ "res": members,
+ })
+}
+
+func (h *Handler) AcceptJoinRequest(c *gin.Context) {
+ log := logging.FromContext(c)
+ currentUser, ok := auth.CurrentUser(c)
+ if !ok {
+ c.JSON(500, gin.H{
+ "error": "Error getting current user",
+ })
+ return
+ }
+
+ rawRequestID := c.Query("requestId")
+ requestID, err := strconv.Atoi(rawRequestID)
+ if err != nil {
+ c.JSON(400, gin.H{
+ "error": "Invalid request",
+ })
+ return
+ }
+
+ currentMemebers, err := h.circleRepo.GetCircleUsers(c, currentUser.CircleID)
+ if err != nil {
+ log.Error("Error getting circle members:", err)
+ c.JSON(500, gin.H{
+ "error": "Error getting circle members",
+ })
+ return
+ }
+
+ // confirm that the current user is an admin:
+ isAdmin := false
+ for _, member := range currentMemebers {
+ if member.UserID == currentUser.ID && member.Role == "admin" {
+ isAdmin = true
+ break
+ }
+ }
+ if !isAdmin {
+ c.JSON(403, gin.H{
+ "error": "You are not an admin of this circle",
+ })
+ return
+ }
+ pendingRequests, err := h.circleRepo.GetPendingJoinRequests(c, currentUser.CircleID)
+ if err != nil {
+ log.Error("Error getting pending circle members:", err)
+ c.JSON(500, gin.H{
+ "error": "Error getting pending circle members",
+ })
+ return
+ }
+ isActiveRequest := false
+ var requestedCircle *cModel.UserCircleDetail
+ for _, request := range pendingRequests {
+ if request.ID == requestID {
+ requestedCircle = request
+ isActiveRequest = true
+ break
+ }
+ }
+ if !isActiveRequest {
+ c.JSON(400, gin.H{
+ "error": "Invalid request",
+ })
+ return
+ }
+
+ err = h.circleRepo.AcceptJoinRequest(c, currentUser.CircleID, requestID)
+ if err != nil {
+ log.Error("Error accepting join request:", err)
+ c.JSON(500, gin.H{
+ "error": "Error accepting join request",
+ })
+ return
+ }
+
+ if err := h.userRepo.UpdateUserCircle(c, requestedCircle.UserID, currentUser.CircleID); err != nil {
+ log.Error("Error updating user circle:", err)
+ c.JSON(500, gin.H{
+ "error": "Error updating user circle",
+ })
+ return
+ }
+
+ c.JSON(200, gin.H{
+ "res": "Join request accepted successfully",
+ })
+}
+
+func Routes(router *gin.Engine, h *Handler, auth *jwt.GinJWTMiddleware) {
+ log.Println("Registering routes")
+
+ circleRoutes := router.Group("circles")
+ circleRoutes.Use(auth.MiddlewareFunc())
+ {
+ circleRoutes.GET("/members", h.GetCircleMembers)
+ circleRoutes.GET("/members/requests", h.GetPendingCircleMembers)
+ circleRoutes.PUT("/members/requests/accept", h.AcceptJoinRequest)
+ circleRoutes.GET("/", h.GetUserCircles)
+ circleRoutes.POST("/join", h.JoinCircle)
+ circleRoutes.DELETE("/leave", h.LeaveCircle)
+ circleRoutes.DELETE("/:id/members/delete", h.DeleteCircleMember)
+
+ }
+
+}
diff --git a/internal/circle/model/model.go b/internal/circle/model/model.go
new file mode 100644
index 0000000..bf26b34
--- /dev/null
+++ b/internal/circle/model/model.go
@@ -0,0 +1,35 @@
+package circle
+
+import "time"
+
+type Circle struct {
+ ID int `json:"id" gorm:"primary_key"` // Unique identifier
+ Name string `json:"name" gorm:"column:name"` // Full name
+ CreatedBy int `json:"created_by" gorm:"column:created_by"` // Created by
+ CreatedAt time.Time `json:"created_at" gorm:"column:created_at"` // Created at
+ UpdatedAt time.Time `json:"updated_at" gorm:"column:updated_at"` // Updated at
+ InviteCode string `json:"invite_code" gorm:"column:invite_code"` // Invite code
+ Disabled bool `json:"disabled" gorm:"column:disabled"` // Disabled
+}
+
+type CircleDetail struct {
+ Circle
+ UserRole string `json:"userRole" gorm:"column:role"`
+}
+
+type UserCircle struct {
+ ID int `json:"id" gorm:"primary_key"` // Unique identifier
+ UserID int `json:"userId" gorm:"column:user_id"` // User ID
+ CircleID int `json:"circleId" gorm:"column:circle_id"` // Circle ID
+ Role string `json:"role" gorm:"column:role"` // Role
+ IsActive bool `json:"isActive" gorm:"column:is_active;default:false"`
+ CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"` // Created at
+ UpdatedAt time.Time `json:"updatedAt" gorm:"column:updated_at"` // Updated at
+}
+
+type UserCircleDetail struct {
+ UserCircle
+ Username string `json:"username" gorm:"column:username"`
+ DisplayName string `json:"displayName" gorm:"column:display_name"`
+ ChatID int `json:"chatID" gorm:"column:chat_id"`
+}
diff --git a/internal/circle/repo/repository.go b/internal/circle/repo/repository.go
new file mode 100644
index 0000000..712cc99
--- /dev/null
+++ b/internal/circle/repo/repository.go
@@ -0,0 +1,117 @@
+package repo
+
+import (
+ "context"
+
+ cModel "donetick.com/core/internal/circle/model"
+ "gorm.io/gorm"
+)
+
+type ICircleRepository interface {
+ CreateCircle(circle *cModel.Circle) error
+ AddUserToCircle(circleUser *cModel.UserCircle) error
+ GetCircleUsers(circleID int) ([]*cModel.UserCircle, error)
+ GetUserCircles(userID int) ([]*cModel.Circle, error)
+ DeleteUserFromCircle(circleID, userID int) error
+ ChangeUserRole(circleID, userID int, role string) error
+ GetCircleByInviteCode(inviteCode string) (*cModel.Circle, error)
+ GetCircleByID(circleID int) (*cModel.Circle, error)
+}
+
+type CircleRepository struct {
+ db *gorm.DB
+}
+
+func NewCircleRepository(db *gorm.DB) *CircleRepository {
+ return &CircleRepository{db}
+}
+
+func (r *CircleRepository) CreateCircle(c context.Context, circle *cModel.Circle) (*cModel.Circle, error) {
+ if err := r.db.WithContext(c).Save(&circle).Error; err != nil {
+ return nil, err
+ }
+ return circle, nil
+
+}
+
+func (r *CircleRepository) AddUserToCircle(c context.Context, circleUser *cModel.UserCircle) error {
+ return r.db.WithContext(c).Save(circleUser).Error
+}
+
+func (r *CircleRepository) GetCircleUsers(c context.Context, circleID int) ([]*cModel.UserCircleDetail, error) {
+ var circleUsers []*cModel.UserCircleDetail
+ // join user table to get user details like username and display name:
+ if err := r.db.WithContext(c).Raw("SELECT * FROM user_circles LEFT JOIN users on users.id = user_circles.user_id WHERE user_circles.circle_id = ?", circleID).Scan(&circleUsers).Error; err != nil {
+ return nil, err
+ }
+ return circleUsers, nil
+}
+
+func (r *CircleRepository) GetPendingJoinRequests(c context.Context, circleID int) ([]*cModel.UserCircleDetail, error) {
+ var pendingRequests []*cModel.UserCircleDetail
+ if err := r.db.WithContext(c).Raw("SELECT *, user_circles.id as id FROM user_circles LEFT JOIN users on users.id = user_circles.user_id WHERE user_circles.circle_id = ? AND user_circles.is_active = false", circleID).Scan(&pendingRequests).Error; err != nil {
+ return nil, err
+ }
+ return pendingRequests, nil
+}
+
+func (r *CircleRepository) AcceptJoinRequest(c context.Context, circleID, requestID int) error {
+
+ return r.db.WithContext(c).Model(&cModel.UserCircle{}).Where("circle_id = ? AND id = ?", circleID, requestID).Update("is_active", true).Error
+}
+
+func (r *CircleRepository) GetUserCircles(c context.Context, userID int) ([]*cModel.CircleDetail, error) {
+ var circles []*cModel.CircleDetail
+ if err := r.db.WithContext(c).Raw("SELECT circles.*, user_circles.role as role, user_circles.created_at uc_created_at FROM circles Left JOIN user_circles on circles.id = user_circles.circle_id WHERE user_circles.user_id = ? ORDER BY uc_created_at desc", userID).Scan(&circles).Error; err != nil {
+ return nil, err
+ }
+ return circles, nil
+}
+
+func (r *CircleRepository) DeleteUserFromCircle(c context.Context, circleID, userID int) error {
+ return r.db.WithContext(c).Where("circle_id = ? AND user_id = ?", circleID, userID).Delete(&cModel.UserCircle{}).Error
+}
+
+func (r *CircleRepository) ChangeUserRole(c context.Context, circleID, userID int, role int) error {
+ return r.db.WithContext(c).Model(&cModel.UserCircle{}).Where("circle_id = ? AND user_id = ?", circleID, userID).Update("role", role).Error
+}
+
+func (r *CircleRepository) GetCircleByInviteCode(c context.Context, inviteCode string) (*cModel.Circle, error) {
+ var circle cModel.Circle
+ if err := r.db.WithContext(c).Where("invite_code = ?", inviteCode).First(&circle).Error; err != nil {
+ return nil, err
+ }
+ return &circle, nil
+}
+
+func (r *CircleRepository) GetCircleByID(c context.Context, circleID int) (*cModel.Circle, error) {
+ var circle cModel.Circle
+ if err := r.db.WithContext(c).First(&circle, circleID).Error; err != nil {
+ return nil, err
+ }
+ return &circle, nil
+}
+
+func (r *CircleRepository) LeaveCircleByUserID(c context.Context, circleID, userID int) error {
+ return r.db.WithContext(c).Where("circle_id = ? AND user_id = ? AND role != 'admin'", circleID, userID).Delete(&cModel.UserCircle{}).Error
+}
+
+func (r *CircleRepository) GetUserOriginalCircle(c context.Context, userID int) (int, error) {
+ var circleID int
+ if err := r.db.WithContext(c).Raw("SELECT circle_id FROM user_circles WHERE user_id = ? AND role = 'admin'", userID).Scan(&circleID).Error; err != nil {
+ return 0, err
+ }
+ return circleID, nil
+}
+
+func (r *CircleRepository) DeleteMemberByID(c context.Context, circleID, userID int) error {
+ return r.db.WithContext(c).Where("circle_id = ? AND user_id = ?", circleID, userID).Delete(&cModel.UserCircle{}).Error
+}
+
+func (r *CircleRepository) GetCircleAdmins(c context.Context, circleID int) ([]*cModel.UserCircleDetail, error) {
+ var circleAdmins []*cModel.UserCircleDetail
+ if err := r.db.WithContext(c).Raw("SELECT * FROM user_circles LEFT JOIN users on users.id = user_circles.user_id WHERE user_circles.circle_id = ? AND user_circles.role = 'admin'", circleID).Scan(&circleAdmins).Error; err != nil {
+ return nil, err
+ }
+ return circleAdmins, nil
+}
diff --git a/internal/database/database.go b/internal/database/database.go
new file mode 100644
index 0000000..8fc8a68
--- /dev/null
+++ b/internal/database/database.go
@@ -0,0 +1,44 @@
+package database
+
+import (
+ "fmt"
+ "time"
+
+ "gorm.io/driver/postgres"
+ "gorm.io/driver/sqlite" // Sqlite driver based on CGO
+ "gorm.io/gorm/logger"
+
+ // "github.com/glebarez/sqlite" // Pure go SQLite driver, checkout https://github.com/glebarez/sqlite for details
+ "donetick.com/core/config"
+ "donetick.com/core/logging"
+ "gorm.io/gorm"
+)
+
+func NewDatabase(cfg *config.Config) (*gorm.DB, error) {
+ var db *gorm.DB
+ var err error
+ switch cfg.Database.Type {
+ case "postgres":
+ dsn := fmt.Sprintf("host=%s port=%v user=%s password=%s dbname=%s sslmode=disable TimeZone=Asia/Shanghai", cfg.Database.Host, cfg.Database.Port, cfg.Database.User, cfg.Database.Password, cfg.Database.Name)
+ for i := 0; i <= 30; i++ {
+ db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Info),
+ })
+ if err == nil {
+ break
+ }
+ logging.DefaultLogger().Warnf("failed to open database: %v", err)
+ time.Sleep(500 * time.Millisecond)
+ }
+
+ default:
+
+ db, err = gorm.Open(sqlite.Open("donetick.db"), &gorm.Config{})
+
+ }
+
+ if err != nil {
+ return nil, err
+ }
+ return db, nil
+}
diff --git a/internal/email/sender.go b/internal/email/sender.go
new file mode 100644
index 0000000..540e54a
--- /dev/null
+++ b/internal/email/sender.go
@@ -0,0 +1,509 @@
+package email
+
+import (
+ "context"
+ "encoding/base64"
+ "fmt"
+ "strings"
+
+ "donetick.com/core/config"
+ gomail "gopkg.in/gomail.v2"
+)
+
+type EmailSender struct {
+ client *gomail.Dialer
+ appHost string
+}
+
+func NewEmailSender(conf *config.Config) *EmailSender {
+
+ client := gomail.NewDialer(conf.EmailConfig.Host, conf.EmailConfig.Port, conf.EmailConfig.Email, conf.EmailConfig.Key)
+
+ // format conf.EmailConfig.Host and port :
+
+ // auth := smtp.PlainAuth("", conf.EmailConfig.Email, conf.EmailConfig.Password, host)
+ return &EmailSender{
+
+ client: client,
+ appHost: conf.EmailConfig.AppHost,
+ }
+}
+
+func (es *EmailSender) SendVerificationEmail(to, code string) error {
+ // msg := []byte(fmt.Sprintf("To: %s\r\nSubject: %s\r\n\r\n%s\r\n", to, subject, body))
+ msg := gomail.NewMessage()
+ msg.SetHeader("From", "no-reply@donetick.com")
+ msg.SetHeader("To", to)
+ msg.SetHeader("Subject", "Welcome to Donetick! Verifiy you email")
+ // text/html for a html email
+ htmlBody := `
+ <!--
+ ********************************************************
+ * This email was built using Tabular.
+ * Create emails, that look perfect in every inbox.
+ * For more information, visit https://tabular.email
+ ********************************************************
+ -->
+ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" lang="en"><head>
+ <title></title>
+ <meta content="text/html; charset=utf-8" http-equiv="Content-Type">
+ <!--[if !mso]><!-->
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <!--<![endif]-->
+ <meta name="x-apple-disable-message-reformatting" content="">
+ <meta content="target-densitydpi=device-dpi" name="viewport">
+ <meta content="true" name="HandheldFriendly">
+ <meta content="width=device-width" name="viewport">
+ <meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
+ <style type="text/css">
+ table {
+ border-collapse: separate;
+ table-layout: fixed;
+ mso-table-lspace: 0pt;
+ mso-table-rspace: 0pt
+ }
+ table td {
+ border-collapse: collapse
+ }
+ .ExternalClass {
+ width: 100%
+ }
+ .ExternalClass,
+ .ExternalClass p,
+ .ExternalClass span,
+ .ExternalClass font,
+ .ExternalClass td,
+ .ExternalClass div {
+ line-height: 100%
+ }
+ body, a, li, p, h1, h2, h3 {
+ -ms-text-size-adjust: 100%;
+ -webkit-text-size-adjust: 100%;
+ }
+ html {
+ -webkit-text-size-adjust: none !important
+ }
+ body, #innerTable {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale
+ }
+ #innerTable img+div {
+ display: none;
+ display: none !important
+ }
+ img {
+ Margin: 0;
+ padding: 0;
+ -ms-interpolation-mode: bicubic
+ }
+ h1, h2, h3, p, a {
+ line-height: 1;
+ overflow-wrap: normal;
+ white-space: normal;
+ word-break: break-word
+ }
+ a {
+ text-decoration: none
+ }
+ h1, h2, h3, p {
+ min-width: 100%!important;
+ width: 100%!important;
+ max-width: 100%!important;
+ display: inline-block!important;
+ border: 0;
+ padding: 0;
+ margin: 0
+ }
+ a[x-apple-data-detectors] {
+ color: inherit !important;
+ text-decoration: none !important;
+ font-size: inherit !important;
+ font-family: inherit !important;
+ font-weight: inherit !important;
+ line-height: inherit !important
+ }
+ a[href^="mailto"],
+ a[href^="tel"],
+ a[href^="sms"] {
+ color: inherit;
+ text-decoration: none
+ }
+ @media (min-width: 481px) {
+ .hd { display: none!important }
+ }
+ @media (max-width: 480px) {
+ .hm { display: none!important }
+ }
+ [style*="Inter Tight"] {font-family: 'Inter Tight', BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif !important;} [style*="Albert Sans"] {font-family: 'Albert Sans', BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif !important;}
+ @media only screen and (min-width: 481px) {.t20{width:720px!important}.t27{padding:40px 60px 50px!important}.t29{padding:40px 60px 50px!important;width:680px!important}.t43{width:600px!important}.t53,.t61{width:580px!important}.t65{width:600px!important}.t78{padding-left:0!important;padding-right:0!important}.t80{padding-left:0!important;padding-right:0!important;width:400px!important}.t84,.t94{width:600px!important}}
+ </style>
+ <style type="text/css" media="screen and (min-width:481px)">.moz-text-html .t20{width:720px!important}.moz-text-html .t27{padding:40px 60px 50px!important}.moz-text-html .t29{padding:40px 60px 50px!important;width:680px!important}.moz-text-html .t43{width:600px!important}.moz-text-html .t53,.moz-text-html .t61{width:580px!important}.moz-text-html .t65{width:600px!important}.moz-text-html .t78{padding-left:0!important;padding-right:0!important}.moz-text-html .t80{padding-left:0!important;padding-right:0!important;width:400px!important}.moz-text-html .t84,.moz-text-html .t94{width:600px!important}</style>
+ <!--[if !mso]><!-->
+ <link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;600&amp;family=Albert+Sans:wght@800&amp;display=swap" rel="stylesheet" type="text/css">
+ <!--<![endif]-->
+ <!--[if mso]>
+ <style type="text/css">
+ td.t20{width:800px !important}td.t27{padding:40px 60px 50px !important}td.t29{padding:40px 60px 50px !important;width:800px !important}td.t43,td.t53{width:600px !important}td.t61{width:580px !important}td.t65{width:600px !important}td.t78,td.t80{padding-left:0 !important;padding-right:0 !important}td.t84,td.t94{width:600px !important}
+ </style>
+ <![endif]-->
+ <!--[if mso]>
+ <xml>
+ <o:OfficeDocumentSettings>
+ <o:AllowPNG/>
+ <o:PixelsPerInch>96</o:PixelsPerInch>
+ </o:OfficeDocumentSettings>
+ </xml>
+ <![endif]-->
+ </head>
+ <body class="t0" style="min-width:100%;Marg
+ if you did not sign up with Donetick please Ignore this email. in:0px;padding:0px;background-color:#FFFFFF;"><div class="t1" style="background-color:#FFFFFF;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" align="center"><tbody><tr><td class="t130" style="font-size:0;line-height:0;mso-line-height-rule:exactly;" valign="top" align="center">
+ <!--[if mso]>
+ <v:background xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false">
+ <v:fill color="#FFFFFF"/>
+ </v:background>
+ <![endif]-->
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" align="center" id="innerTable"><tbody><tr><td>
+ <table class="t118" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+
+ <!--<![endif]-->
+ <!--[if mso]><td class="t119" style="width:400px;padding:40px 40px 40px 40px;"><![endif]-->
+ </tr></tbody></table><table role="presentation" width="100%" cellpadding="0" cellspacing="0"></table></td>
+ </tr></tbody></table>
+ </td></tr><tr><td>
+ <table class="t10" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+ <!--[if !mso]><!--><td class="t11" style="background-color:#FFFFFF;width:400px;">
+ <!--<![endif]-->
+ <!--[if mso]><td class="t11" style="background-color:#FFFFFF;width:400px;"><![endif]-->
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
+ <table class="t19" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+ <!--[if !mso]><!--><td class="t20" style="background-color:#404040;width:400px;padding:40px 40px 40px 40px;">
+ <!--<![endif]-->
+ <!--[if mso]><td class="t20" style="background-color:#404040;width:480px;padding:40px 40px 40px 40px;"><![endif]-->
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
+ <table class="t103" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+ <!--[if !mso]><!--><td class="t104" style="width:200px;">
+ <!--<![endif]-->
+ <!--[if mso]><td class="t104" style="width:200px;"><![endif]-->
+ <div style="font-size:0px;"><img class="t110" style="display:block;border:0;height:auto;width:100%;Margin:0;max-width:100%;" width="200" height="179.5" alt="" src="https://835a1b8e-557a-4713-8f1c-104febdb8808.b-cdn.net/e/30b4288c-4e67-4e3b-9527-1fc4c4ec2fdf/df3f012a-c060-4d59-b5fd-54a57dae1916.png"></div></td>
+ </tr></tbody></table>
+ </td></tr></tbody></table></td>
+ </tr></tbody></table>
+ </td></tr><tr><td>
+ <table class="t28" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+ <!--[if !mso]><!--><td class="t29" style="width:420px;padding:30px 30px 40px 30px;">
+ <!--<![endif]-->
+ <!--[if mso]><td class="t29" style="width:480px;padding:30px 30px 40px 30px;"><![endif]-->
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
+ <table class="t42" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+ <!--[if !mso]><!--><td class="t43" style="width:480px;">
+ <!--<![endif]-->
+ <!--[if mso]><td class="t43" style="width:480px;"><![endif]-->
+ <h1 class="t49" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Albert Sans';line-height:35px;font-weight:800;font-style:normal;font-size:30px;text-decoration:none;text-transform:none;letter-spacing:-1.2px;direction:ltr;color:#333333;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">Welcome to Donetick!</h1></td>
+ </tr></tbody></table>
+ </td></tr><tr><td><div class="t41" style="mso-line-height-rule:exactly;mso-line-height-alt:16px;line-height:16px;font-size:1px;display:block;">&nbsp;</div></td></tr><tr><td><p class="t39" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:21px;font-weight:400;font-style:normal;font-size:14px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">Thank you for joining us. We're excited to have you on board.To complete your registration, click the button below</p></td></tr><tr><td><div class="t31" style="mso-line-height-rule:exactly;mso-line-height-alt:30px;line-height:30px;font-size:1px;display:block;">&nbsp;</div></td></tr><tr><td>
+ <table class="t52" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+ <!--[if !mso]><!--><td class="t53" style="background-color:#06b6d4;overflow:hidden;width:460px;text-align:center;line-height:24px;mso-line-height-rule:exactly;mso-text-raise:2px;padding:10px 10px 10px 10px;border-radius:10px 10px 10px 10px;">
+ <!--<![endif]-->
+ <!--[if mso]><td class="t53" style="background-color:#06b6d4;overflow:hidden;width:480px;text-align:center;line-height:24px;mso-line-height-rule:exactly;mso-text-raise:2px;padding:10px 10px 10px 10px;border-radius:10px 10px 10px 10px;"><![endif]-->
+ <a class="t59" href="{{verifyURL}}" style="display:block;margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:24px;font-weight:600;font-style:normal;font-size:16px;text-decoration:none;direction:ltr;color:#FFFFFF;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;" target="_blank">Complete your registration</a></td>
+ </tr></tbody></table>
+ </td></tr><tr><td><div class="t62" style="mso-line-height-rule:exactly;mso-line-height-alt:12px;line-height:12px;font-size:1px;display:block;">&nbsp;</div></td></tr><tr><td>
+ <table class="t64" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+ <!--[if !mso]><!--><td class="t65" style="width:480px;">
+ <!--<![endif]-->
+ <!--[if mso]><td class="t65" style="width:480px;"><![endif]-->
+ <p class="t71" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:21px;font-weight:400;font-style:normal;font-size:14px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">&nbsp;</p></td>
+ </tr></tbody></table>
+ </td></tr></tbody></table></td>
+ </tr></tbody></table>
+ </td></tr></tbody></table></td>
+ </tr></tbody></table>
+ </td></tr><tr><td><div class="t4" style="mso-line-height-rule:exactly;mso-line-height-alt:30px;line-height:30px;font-size:1px;display:block;">&nbsp;</div></td></tr><tr><td>
+ <table class="t79" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+ <!--[if !mso]><!--><td class="t80" style="width:320px;padding:0 40px 0 40px;">
+ <!--<![endif]-->
+ <!--[if mso]><td class="t80" style="width:400px;padding:0 40px 0 40px;"><![endif]-->
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
+ <table class="t93" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+ <!--[if !mso]><!--><td class="t94" style="width:480px;">
+ <!--<![endif]-->
+ <!--[if mso]><td class="t94" style="width:480px;"><![endif]-->
+ <p class="t100" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:18px;font-weight:400;font-style:normal;font-size:12px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">if you did not sign up with Donetick please Ignore this email. </p></td>
+ </tr></tbody></table>
+ </td></tr><tr><td><div class="t81" style="mso-line-height-rule:exactly;mso-line-height-alt:8px;line-height:8px;font-size:1px;display:block;">&nbsp;</div></td></tr><tr><td>
+ <table class="t83" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+ <!--[if !mso]><!--><td class="t84" style="width:480px;">
+ <!--<![endif]-->
+ <!--[if mso]><td class="t84" style="width:480px;"><![endif]-->
+ <p class="t90" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:18px;font-weight:400;font-style:normal;font-size:12px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">Favoro LLC. All rights reserved</p></td>
+ </tr></tbody></table>
+ </td></tr></tbody></table></td>
+ </tr></tbody></table>
+ </td></tr><tr><td><div class="t73" style="mso-line-height-rule:exactly;mso-line-height-alt:100px;line-height:100px;font-size:1px;display:block;">&nbsp;</div></td></tr></tbody></table></div>
+
+ </body></html>
+`
+ u := es.appHost + "/verify?c=" + encodeEmailAndCode(to, code)
+ htmlBody = strings.Replace(htmlBody, "{{verifyURL}}", u, 1)
+
+ msg.SetBody("text/html", htmlBody)
+
+ err := es.client.DialAndSend(msg)
+ if err != nil {
+ return err
+ }
+ return nil
+
+}
+
+func (es *EmailSender) SendResetPasswordEmail(c context.Context, to, code string) error {
+ msg := gomail.NewMessage()
+ msg.SetHeader("From", "no-reply@donetick.com")
+ msg.SetHeader("To", to)
+ msg.SetHeader("Subject", "Donetick! Password Reset")
+ htmlBody := `
+ <!--
+ ********************************************************
+ * This email was built using Tabular.
+ * Create emails, that look perfect in every inbox.
+ * For more information, visit https://tabular.email
+ ********************************************************
+ -->
+ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+ <html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" lang="en"><head>
+ <title></title>
+ <meta content="text/html; charset=utf-8" http-equiv="Content-Type">
+ <!--[if !mso]><!-->
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <!--<![endif]-->
+ <meta name="x-apple-disable-message-reformatting" content="">
+ <meta content="target-densitydpi=device-dpi" name="viewport">
+ <meta content="true" name="HandheldFriendly">
+ <meta content="width=device-width" name="viewport">
+ <meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
+ <style type="text/css">
+ table {
+ border-collapse: separate;
+ table-layout: fixed;
+ mso-table-lspace: 0pt;
+ mso-table-rspace: 0pt
+ }
+ table td {
+ border-collapse: collapse
+ }
+ .ExternalClass {
+ width: 100%
+ }
+ .ExternalClass,
+ .ExternalClass p,
+ .ExternalClass span,
+ .ExternalClass font,
+ .ExternalClass td,
+ .ExternalClass div {
+ line-height: 100%
+ }
+ body, a, li, p, h1, h2, h3 {
+ -ms-text-size-adjust: 100%;
+ -webkit-text-size-adjust: 100%;
+ }
+ html {
+ -webkit-text-size-adjust: none !important
+ }
+ body, #innerTable {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale
+ }
+ #innerTable img+div {
+ display: none;
+ display: none !important
+ }
+ img {
+ Margin: 0;
+ padding: 0;
+ -ms-interpolation-mode: bicubic
+ }
+ h1, h2, h3, p, a {
+ line-height: 1;
+ overflow-wrap: normal;
+ white-space: normal;
+ word-break: break-word
+ }
+ a {
+ text-decoration: none
+ }
+ h1, h2, h3, p {
+ min-width: 100%!important;
+ width: 100%!important;
+ max-width: 100%!important;
+ display: inline-block!important;
+ border: 0;
+ padding: 0;
+ margin: 0
+ }
+ a[x-apple-data-detectors] {
+ color: inherit !important;
+ text-decoration: none !important;
+ font-size: inherit !important;
+ font-family: inherit !important;
+ font-weight: inherit !important;
+ line-height: inherit !important
+ }
+ a[href^="mailto"],
+ a[href^="tel"],
+ a[href^="sms"] {
+ color: inherit;
+ text-decoration: none
+ }
+ @media (min-width: 481px) {
+ .hd { display: none!important }
+ }
+ @media (max-width: 480px) {
+ .hm { display: none!important }
+ }
+ [style*="Inter Tight"] {font-family: 'Inter Tight', BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif !important;} [style*="Albert Sans"] {font-family: 'Albert Sans', BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif !important;}
+ @media only screen and (min-width: 481px) {.t20{width:720px!important}.t27{padding:40px 60px 50px!important}.t29{padding:40px 60px 50px!important;width:680px!important}.t43{width:600px!important}.t53,.t61{width:580px!important}.t65{width:600px!important}.t78{padding-left:0!important;padding-right:0!important}.t80{padding-left:0!important;padding-right:0!important;width:400px!important}.t84,.t94{width:600px!important}}
+ </style>
+ <style type="text/css" media="screen and (min-width:481px)">.moz-text-html .t20{width:720px!important}.moz-text-html .t27{padding:40px 60px 50px!important}.moz-text-html .t29{padding:40px 60px 50px!important;width:680px!important}.moz-text-html .t43{width:600px!important}.moz-text-html .t53,.moz-text-html .t61{width:580px!important}.moz-text-html .t65{width:600px!important}.moz-text-html .t78{padding-left:0!important;padding-right:0!important}.moz-text-html .t80{padding-left:0!important;padding-right:0!important;width:400px!important}.moz-text-html .t84,.moz-text-html .t94{width:600px!important}</style>
+ <!--[if !mso]><!-->
+ <link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;600&amp;family=Albert+Sans:wght@800&amp;display=swap" rel="stylesheet" type="text/css">
+ <!--<![endif]-->
+ <!--[if mso]>
+ <style type="text/css">
+ td.t20{width:800px !important}td.t27{padding:40px 60px 50px !important}td.t29{padding:40px 60px 50px !important;width:800px !important}td.t43,td.t53{width:600px !important}td.t61{width:580px !important}td.t65{width:600px !important}td.t78,td.t80{padding-left:0 !important;padding-right:0 !important}td.t84,td.t94{width:600px !important}
+ </style>
+ <![endif]-->
+ <!--[if mso]>
+ <xml>
+ <o:OfficeDocumentSettings>
+ <o:AllowPNG/>
+ <o:PixelsPerInch>96</o:PixelsPerInch>
+ </o:OfficeDocumentSettings>
+ </xml>
+ <![endif]-->
+ </head>
+ <body class="t0" style="min-width:100%;Marg
+ if you did not sign up with Donetick please Ignore this email. in:0px;padding:0px;background-color:#FFFFFF;"><div class="t1" style="background-color:#FFFFFF;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" align="center"><tbody><tr><td class="t130" style="font-size:0;line-height:0;mso-line-height-rule:exactly;" valign="top" align="center">
+ <!--[if mso]>
+ <v:background xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false">
+ <v:fill color="#FFFFFF"/>
+ </v:background>
+ <![endif]-->
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" align="center" id="innerTable"><tbody><tr><td>
+ <table class="t118" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+
+ <!--<![endif]-->
+ <!--[if mso]><td class="t119" style="width:400px;padding:40px 40px 40px 40px;"><![endif]-->
+ </tr></tbody></table><table role="presentation" width="100%" cellpadding="0" cellspacing="0"></table></td>
+ </tr></tbody></table>
+ </td></tr><tr><td>
+ <table class="t10" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+ <!--[if !mso]><!--><td class="t11" style="background-color:#FFFFFF;width:400px;">
+ <!--<![endif]-->
+ <!--[if mso]><td class="t11" style="background-color:#FFFFFF;width:400px;"><![endif]-->
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
+ <table class="t19" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+ <!--[if !mso]><!--><td class="t20" style="background-color:#404040;width:400px;padding:40px 40px 40px 40px;">
+ <!--<![endif]-->
+ <!--[if mso]><td class="t20" style="background-color:#404040;width:480px;padding:40px 40px 40px 40px;"><![endif]-->
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
+ <table class="t103" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+ <!--[if !mso]><!--><td class="t104" style="width:200px;">
+ <!--<![endif]-->
+ <!--[if mso]><td class="t104" style="width:200px;"><![endif]-->
+ <div style="font-size:0px;"><img class="t110" style="display:block;border:0;height:auto;width:100%;Margin:0;max-width:100%;" width="200" height="179.5" alt="" src="https://835a1b8e-557a-4713-8f1c-104febdb8808.b-cdn.net/e/30b4288c-4e67-4e3b-9527-1fc4c4ec2fdf/df3f012a-c060-4d59-b5fd-54a57dae1916.png"></div></td>
+ </tr></tbody></table>
+ </td></tr></tbody></table></td>
+ </tr></tbody></table>
+ </td></tr><tr><td>
+ <table class="t28" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+ <!--[if !mso]><!--><td class="t29" style="width:420px;padding:30px 30px 40px 30px;">
+ <!--<![endif]-->
+ <!--[if mso]><td class="t29" style="width:480px;padding:30px 30px 40px 30px;"><![endif]-->
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
+ <table class="t42" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+ <!--[if !mso]><!--><td class="t43" style="width:480px;">
+ <!--<![endif]-->
+ <!--[if mso]><td class="t43" style="width:480px;"><![endif]-->
+ <h1 class="t49" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Albert Sans';line-height:35px;font-weight:800;font-style:normal;font-size:30px;text-decoration:none;text-transform:none;letter-spacing:-1.2px;direction:ltr;color:#333333;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">Someone forgot their password 😔</h1></td>
+ </tr></tbody></table>
+ </td></tr><tr><td><div class="t41" style="mso-line-height-rule:exactly;mso-line-height-alt:16px;line-height:16px;font-size:1px;display:block;">&nbsp;</div></td></tr><tr><td><p class="t39" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:21px;font-weight:400;font-style:normal;font-size:14px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">We have received a password reset request for this email address. If you initiated this request, please click the button below to reset your password. Otherwise, you may safely ignore this email.</p></td></tr><tr><td><div class="t31" style="mso-line-height-rule:exactly;mso-line-height-alt:30px;line-height:30px;font-size:1px;display:block;">&nbsp;</div></td></tr><tr><td>
+ <table class="t52" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+ <!--[if !mso]><!--><td class="t53" style="background-color:#06b6d4;overflow:hidden;width:460px;text-align:center;line-height:24px;mso-line-height-rule:exactly;mso-text-raise:2px;padding:10px 10px 10px 10px;border-radius:10px 10px 10px 10px;">
+ <!--<![endif]-->
+ <!--[if mso]><td class="t53" style="background-color:#06b6d4;overflow:hidden;width:480px;text-align:center;line-height:24px;mso-line-height-rule:exactly;mso-text-raise:2px;padding:10px 10px 10px 10px;border-radius:10px 10px 10px 10px;"><![endif]-->
+ <a class="t59" href="{{verifyURL}}" style="display:block;margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:24px;font-weight:600;font-style:normal;font-size:16px;text-decoration:none;direction:ltr;color:#FFFFFF;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;" target="_blank">Reset your Password</a></td>
+ </tr></tbody></table>
+ </td></tr><tr><td><div class="t62" style="mso-line-height-rule:exactly;mso-line-height-alt:12px;line-height:12px;font-size:1px;display:block;">&nbsp;</div></td></tr><tr><td>
+ <table class="t64" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+ <!--[if !mso]><!--><td class="t65" style="width:480px;">
+ <!--<![endif]-->
+ <!--[if mso]><td class="t65" style="width:480px;"><![endif]-->
+ <p class="t71" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:21px;font-weight:400;font-style:normal;font-size:14px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">&nbsp;</p></td>
+ </tr></tbody></table>
+ </td></tr></tbody></table></td>
+ </tr></tbody></table>
+ </td></tr></tbody></table></td>
+ </tr></tbody></table>
+ </td></tr><tr><td><div class="t4" style="mso-line-height-rule:exactly;mso-line-height-alt:30px;line-height:30px;font-size:1px;display:block;">&nbsp;</div></td></tr><tr><td>
+ <table class="t79" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+ <!--[if !mso]><!--><td class="t80" style="width:320px;padding:0 40px 0 40px;">
+ <!--<![endif]-->
+ <!--[if mso]><td class="t80" style="width:400px;padding:0 40px 0 40px;"><![endif]-->
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
+ <table class="t93" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+ <!--[if !mso]><!--><td class="t94" style="width:480px;">
+ <!--<![endif]-->
+ <!--[if mso]><td class="t94" style="width:480px;"><![endif]-->
+ <p class="t100" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:18px;font-weight:400;font-style:normal;font-size:12px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">if you did not sign up with Donetick please Ignore this email. </p></td>
+ </tr></tbody></table>
+ </td></tr><tr><td><div class="t81" style="mso-line-height-rule:exactly;mso-line-height-alt:8px;line-height:8px;font-size:1px;display:block;">&nbsp;</div></td></tr><tr><td>
+ <table class="t83" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
+ <!--[if !mso]><!--><td class="t84" style="width:480px;">
+ <!--<![endif]-->
+ <!--[if mso]><td class="t84" style="width:480px;"><![endif]-->
+ <p class="t90" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:18px;font-weight:400;font-style:normal;font-size:12px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">Favoro LLC. All rights reserved</p></td>
+ </tr></tbody></table>
+ </td></tr></tbody></table></td>
+ </tr></tbody></table>
+ </td></tr><tr><td><div class="t73" style="mso-line-height-rule:exactly;mso-line-height-alt:100px;line-height:100px;font-size:1px;display:block;">&nbsp;</div></td></tr></tbody></table></div>
+
+ </body></html>
+`
+ u := es.appHost + "/password/update?c=" + encodeEmailAndCode(to, code)
+
+ // logging.FromContext(c).Infof("Reset password URL: %s", u)
+ htmlBody = strings.Replace(htmlBody, "{{verifyURL}}", u, 1)
+
+ msg.SetBody("text/html", htmlBody)
+
+ err := es.client.DialAndSend(msg)
+ if err != nil {
+ return err
+ }
+ return nil
+
+}
+
+// func (es *EmailSender) SendFeedbackRequestEmail(to, code string) error {
+// // msg := []byte(fmt.Sprintf("To: %s\r\nSubject: %s\r\n\r\n%s\r\n", to, subject, body))
+// msg := gomail.NewMessage()
+
+// }
+func encodeEmailAndCode(email, code string) string {
+ data := email + ":" + code
+ return base64.StdEncoding.EncodeToString([]byte(data))
+}
+
+func DecodeEmailAndCode(encoded string) (string, string, error) {
+ data, err := base64.StdEncoding.DecodeString(encoded)
+ if err != nil {
+ return "", "", err
+ }
+ parts := string(data)
+ split := strings.Split(parts, ":")
+ if len(split) != 2 {
+ return "", "", fmt.Errorf("Invalid format")
+ }
+ return split[0], split[1], nil
+}
diff --git a/internal/notifier/model/model.go b/internal/notifier/model/model.go
new file mode 100644
index 0000000..47c81df
--- /dev/null
+++ b/internal/notifier/model/model.go
@@ -0,0 +1,15 @@
+package model
+
+import "time"
+
+type Notification struct {
+ ID int `json:"id" gorm:"primaryKey"`
+ ChoreID int `json:"chore_id" gorm:"column:chore_id"`
+ UserID int `json:"user_id" gorm:"column:user_id"`
+ TargetID string `json:"target_id" gorm:"column:target_id"`
+ Text string `json:"text" gorm:"column:text"`
+ IsSent bool `json:"is_sent" gorm:"column:is_sent;index;default:false"`
+ TypeID int `json:"type" gorm:"column:type"`
+ ScheduledFor time.Time `json:"scheduled_for" gorm:"column:scheduled_for;index"`
+ CreatedAt time.Time `json:"created_at" gorm:"column:created_at"`
+}
diff --git a/internal/notifier/repo/repository.go b/internal/notifier/repo/repository.go
new file mode 100644
index 0000000..576a3f0
--- /dev/null
+++ b/internal/notifier/repo/repository.go
@@ -0,0 +1,43 @@
+package user
+
+import (
+ "context"
+ "time"
+
+ nModel "donetick.com/core/internal/notifier/model"
+ "gorm.io/gorm"
+)
+
+type NotificationRepository struct {
+ db *gorm.DB
+}
+
+func NewNotificationRepository(db *gorm.DB) *NotificationRepository {
+ return &NotificationRepository{db}
+}
+
+func (r *NotificationRepository) DeleteAllChoreNotifications(choreID int) error {
+ return r.db.Where("chore_id = ?", choreID).Delete(&nModel.Notification{}).Error
+}
+
+func (r *NotificationRepository) BatchInsertNotifications(notifications []*nModel.Notification) error {
+ return r.db.Create(&notifications).Error
+}
+func (r *NotificationRepository) MarkNotificationsAsSent(notifications []*nModel.Notification) error {
+ // Extract IDs from notifications
+ var ids []int
+ for _, notification := range notifications {
+ ids = append(ids, notification.ID)
+ }
+ // Use the extracted IDs in the Where clause
+ return r.db.Model(&nModel.Notification{}).Where("id IN (?)", ids).Update("is_sent", true).Error
+}
+func (r *NotificationRepository) GetPendingNotificaiton(c context.Context, lookback time.Duration) ([]*nModel.Notification, error) {
+ var notifications []*nModel.Notification
+ start := time.Now().UTC().Add(-lookback)
+ end := time.Now().UTC()
+ if err := r.db.Debug().Where("is_sent = ? AND scheduled_for < ? AND scheduled_for > ?", false, end, start).Find(&notifications).Error; err != nil {
+ return nil, err
+ }
+ return notifications, nil
+}
diff --git a/internal/notifier/scheduler.go b/internal/notifier/scheduler.go
new file mode 100644
index 0000000..69470d2
--- /dev/null
+++ b/internal/notifier/scheduler.go
@@ -0,0 +1,89 @@
+package notifier
+
+import (
+ "context"
+ "log"
+ "time"
+
+ "donetick.com/core/config"
+ chRepo "donetick.com/core/internal/chore/repo"
+ nRepo "donetick.com/core/internal/notifier/repo"
+ notifier "donetick.com/core/internal/notifier/telegram"
+ uRepo "donetick.com/core/internal/user/repo"
+ "donetick.com/core/logging"
+)
+
+type keyType string
+
+const (
+ SchedulerKey keyType = "scheduler"
+)
+
+type Scheduler struct {
+ choreRepo *chRepo.ChoreRepository
+ userRepo *uRepo.UserRepository
+ stopChan chan bool
+ notifier *notifier.TelegramNotifier
+ notificationRepo *nRepo.NotificationRepository
+ SchedulerJobs config.SchedulerConfig
+}
+
+func NewScheduler(cfg *config.Config, ur *uRepo.UserRepository, cr *chRepo.ChoreRepository, n *notifier.TelegramNotifier, nr *nRepo.NotificationRepository) *Scheduler {
+ return &Scheduler{
+ choreRepo: cr,
+ userRepo: ur,
+ stopChan: make(chan bool),
+ notifier: n,
+ notificationRepo: nr,
+ SchedulerJobs: cfg.SchedulerJobs,
+ }
+}
+
+func (s *Scheduler) Start(c context.Context) {
+ log := logging.FromContext(c)
+ log.Debug("Scheduler started")
+ go s.runScheduler(c, " NOTIFICATION_SCHEDULER ", s.loadAndSendNotificationJob, 3*time.Minute)
+}
+
+func (s *Scheduler) loadAndSendNotificationJob(c context.Context) (time.Duration, error) {
+ log := logging.FromContext(c)
+ startTime := time.Now()
+ getAllPendingNotifications, err := s.notificationRepo.GetPendingNotificaiton(c, time.Minute*15)
+ log.Debug("Getting pending notifications", " count ", len(getAllPendingNotifications))
+
+ if err != nil {
+ log.Error("Error getting pending notifications")
+ return time.Since(startTime), err
+ }
+
+ for _, notification := range getAllPendingNotifications {
+ s.notifier.SendNotification(c, notification)
+ notification.IsSent = true
+ }
+
+ s.notificationRepo.MarkNotificationsAsSent(getAllPendingNotifications)
+ return time.Since(startTime), nil
+}
+func (s *Scheduler) runScheduler(c context.Context, jobName string, job func(c context.Context) (time.Duration, error), interval time.Duration) {
+
+ for {
+ logging.FromContext(c).Debug("Scheduler running ", jobName, " time", time.Now().String())
+
+ select {
+ case <-s.stopChan:
+ log.Println("Scheduler stopped")
+ return
+ default:
+ elapsedTime, err := job(c)
+ if err != nil {
+ logging.FromContext(c).Error("Error running scheduler job", err)
+ }
+ logging.FromContext(c).Debug("Scheduler job completed", jobName, " time", elapsedTime.String())
+ }
+ time.Sleep(interval)
+ }
+}
+
+func (s *Scheduler) Stop() {
+ s.stopChan <- true
+}
diff --git a/internal/notifier/service/planner.go b/internal/notifier/service/planner.go
new file mode 100644
index 0000000..22502ab
--- /dev/null
+++ b/internal/notifier/service/planner.go
@@ -0,0 +1,149 @@
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ chModel "donetick.com/core/internal/chore/model"
+ cModel "donetick.com/core/internal/circle/model"
+ cRepo "donetick.com/core/internal/circle/repo"
+ nModel "donetick.com/core/internal/notifier/model"
+ nRepo "donetick.com/core/internal/notifier/repo"
+ "donetick.com/core/logging"
+)
+
+type NotificationPlanner struct {
+ nRepo *nRepo.NotificationRepository
+ cRepo *cRepo.CircleRepository
+}
+
+func NewNotificationPlanner(nr *nRepo.NotificationRepository, cr *cRepo.CircleRepository) *NotificationPlanner {
+ return &NotificationPlanner{nRepo: nr,
+ cRepo: cr,
+ }
+}
+
+func (n *NotificationPlanner) GenerateNotifications(c context.Context, chore *chModel.Chore) bool {
+ log := logging.FromContext(c)
+ circleMembers, err := n.cRepo.GetCircleUsers(c, chore.CircleID)
+ assignees := make([]*cModel.UserCircleDetail, 0)
+ for _, member := range circleMembers {
+ if member.ID == chore.AssignedTo {
+ assignees = append(assignees, member)
+ }
+ }
+
+ if err != nil {
+ log.Error("Error getting circle members", err)
+ return false
+ }
+ n.nRepo.DeleteAllChoreNotifications(chore.ID)
+ notifications := make([]*nModel.Notification, 0)
+ if !chore.Notification || chore.FrequencyType == "trigger" {
+
+ return true
+ }
+ var mt *chModel.NotificationMetadata
+ if err := json.Unmarshal([]byte(*chore.NotificationMetadata), &mt); err != nil {
+ log.Error("Error unmarshalling notification metadata", err)
+ return true
+ }
+ if mt.DueDate {
+ notifications = append(notifications, generateDueNotifications(chore, assignees)...)
+ }
+ if mt.PreDue {
+ notifications = append(notifications, generatePreDueNotifications(chore, assignees)...)
+ }
+ if mt.Nagging {
+ notifications = append(notifications, generateOverdueNotifications(chore, assignees)...)
+ }
+
+ n.nRepo.BatchInsertNotifications(notifications)
+ return true
+}
+
+func generateDueNotifications(chore *chModel.Chore, users []*cModel.UserCircleDetail) []*nModel.Notification {
+ var assignee *cModel.UserCircleDetail
+ notifications := make([]*nModel.Notification, 0)
+ for _, user := range users {
+ if user.ID == chore.AssignedTo {
+ assignee = user
+ break
+ }
+ }
+ for _, user := range users {
+
+ notification := &nModel.Notification{
+ ChoreID: chore.ID,
+ IsSent: false,
+ ScheduledFor: *chore.NextDueDate,
+ CreatedAt: time.Now().UTC(),
+ 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),
+ }
+ notifications = append(notifications, notification)
+ }
+
+ return notifications
+}
+
+func generatePreDueNotifications(chore *chModel.Chore, users []*cModel.UserCircleDetail) []*nModel.Notification {
+ var assignee *cModel.UserCircleDetail
+ for _, user := range users {
+ if user.ID == chore.AssignedTo {
+ assignee = user
+ break
+ }
+ }
+ notifications := make([]*nModel.Notification, 0)
+ for _, user := range users {
+ notification := &nModel.Notification{
+ ChoreID: chore.ID,
+ IsSent: false,
+ ScheduledFor: *chore.NextDueDate,
+ CreatedAt: time.Now().UTC().Add(-time.Hour * 3),
+ 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),
+ }
+ notifications = append(notifications, notification)
+
+ }
+ return notifications
+
+}
+
+func generateOverdueNotifications(chore *chModel.Chore, users []*cModel.UserCircleDetail) []*nModel.Notification {
+ var assignee *cModel.UserCircleDetail
+ for _, user := range users {
+ if user.ID == chore.AssignedTo {
+ assignee = user
+ break
+ }
+ }
+ notifications := make([]*nModel.Notification, 0)
+ for _, hours := range []int{24, 48, 72} {
+ scheduleTime := chore.NextDueDate.Add(time.Hour * time.Duration(hours))
+ for _, user := range users {
+ notification := &nModel.Notification{
+ ChoreID: chore.ID,
+ IsSent: false,
+ ScheduledFor: scheduleTime,
+ CreatedAt: time.Now().UTC(),
+ 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),
+ }
+ notifications = append(notifications, notification)
+ }
+ }
+
+ return notifications
+
+}
diff --git a/internal/notifier/telegram/telegram.go b/internal/notifier/telegram/telegram.go
new file mode 100644
index 0000000..e35f0c8
--- /dev/null
+++ b/internal/notifier/telegram/telegram.go
@@ -0,0 +1,127 @@
+package telegram
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+
+ "donetick.com/core/config"
+ chModel "donetick.com/core/internal/chore/model"
+ nModel "donetick.com/core/internal/notifier/model"
+ uModel "donetick.com/core/internal/user/model"
+ "donetick.com/core/logging"
+ tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
+)
+
+type TelegramNotifier struct {
+ bot *tgbotapi.BotAPI
+}
+
+func NewTelegramNotifier(config *config.Config) *TelegramNotifier {
+ bot, err := tgbotapi.NewBotAPI(config.Telegram.Token)
+ if err != nil {
+ fmt.Println("Error creating bot: ", err)
+ return nil
+ }
+
+ return &TelegramNotifier{
+ bot: bot,
+ }
+}
+
+func (tn *TelegramNotifier) SendChoreReminder(c context.Context, chore *chModel.Chore, users []*uModel.User) {
+ for _, user := range users {
+ var assignee *uModel.User
+ if user.ID == chore.AssignedTo {
+ if user.ChatID == 0 {
+ continue
+ }
+ assignee = user
+ text := fmt.Sprintf("*%s* is due today and assigned to *%s*", chore.Name, assignee.DisplayName)
+ msg := tgbotapi.NewMessage(user.ChatID, text)
+ msg.ParseMode = "Markdown"
+ _, err := tn.bot.Send(msg)
+ if err != nil {
+ fmt.Println("Error sending message to user: ", err)
+ }
+ break
+ }
+ }
+}
+
+func (tn *TelegramNotifier) SendChoreCompletion(c context.Context, chore *chModel.Chore, users []*uModel.User) {
+ log := logging.FromContext(c)
+ for _, user := range users {
+ if user.ChatID == 0 {
+ continue
+ }
+ 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)
+ if err != nil {
+ log.Error("Error sending message to user: ", err)
+ log.Debug("Error sending message, chore: ", chore.Name, " user: ", user.DisplayName, " chatID: ", user.ChatID, " user id: ", user.ID)
+ }
+
+ }
+}
+
+func (tn *TelegramNotifier) SendChoreOverdue(c context.Context, chore *chModel.Chore, users []*uModel.User) {
+ log := logging.FromContext(c)
+ for _, user := range users {
+ if user.ChatID == 0 {
+ continue
+ }
+ text := fmt.Sprintf("*%s* is overdue and assigned to *%s*", chore.Name, user.DisplayName)
+ msg := tgbotapi.NewMessage(user.ChatID, text)
+ msg.ParseMode = "Markdown"
+ _, err := tn.bot.Send(msg)
+ if err != nil {
+ log.Error("Error sending message to user: ", err)
+ log.Debug("Error sending message, chore: ", chore.Name, " user: ", user.DisplayName, " chatID: ", user.ChatID, " user id: ", user.ID)
+ }
+ }
+}
+
+func (tn *TelegramNotifier) SendChorePreDue(c context.Context, chore *chModel.Chore, users []*uModel.User) {
+ log := logging.FromContext(c)
+ for _, user := range users {
+ if user.ID != chore.AssignedTo {
+ continue
+ }
+ if user.ChatID == 0 {
+ continue
+ }
+ text := fmt.Sprintf("*%s* is due tomorrow and assigned to *%s*", chore.Name, user.DisplayName)
+ msg := tgbotapi.NewMessage(user.ChatID, text)
+ msg.ParseMode = "Markdown"
+ _, err := tn.bot.Send(msg)
+ if err != nil {
+ log.Error("Error sending message to user: ", err)
+ log.Debug("Error sending message, chore: ", chore.Name, " user: ", user.DisplayName, " chatID: ", user.ChatID, " user id: ", user.ID)
+ }
+ }
+}
+
+func (tn *TelegramNotifier) SendNotification(c context.Context, notification *nModel.Notification) {
+
+ log := logging.FromContext(c)
+ if notification.TargetID == "" {
+ log.Error("Notification target ID is empty")
+ return
+ }
+ chatID, err := strconv.ParseInt(notification.TargetID, 10, 64)
+ if err != nil {
+ log.Error("Error parsing chatID: ", err)
+ return
+ }
+
+ msg := tgbotapi.NewMessage(chatID, notification.Text)
+ msg.ParseMode = "Markdown"
+ _, err = tn.bot.Send(msg)
+ if err != nil {
+ log.Error("Error sending message to user: ", err)
+ log.Debug("Error sending message, notification: ", notification.Text, " chatID: ", chatID)
+ }
+}
diff --git a/internal/thing/handler.go b/internal/thing/handler.go
new file mode 100644
index 0000000..5166157
--- /dev/null
+++ b/internal/thing/handler.go
@@ -0,0 +1,281 @@
+package thing
+
+import (
+ "strconv"
+ "time"
+
+ auth "donetick.com/core/internal/authorization"
+ 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"
+ tModel "donetick.com/core/internal/thing/model"
+ tRepo "donetick.com/core/internal/thing/repo"
+ "donetick.com/core/logging"
+ jwt "github.com/appleboy/gin-jwt/v2"
+ "github.com/gin-gonic/gin"
+)
+
+type Handler struct {
+ choreRepo *chRepo.ChoreRepository
+ circleRepo *cRepo.CircleRepository
+ nPlanner *nps.NotificationPlanner
+ nRepo *nRepo.NotificationRepository
+ tRepo *tRepo.ThingRepository
+}
+
+type ThingRequest struct {
+ ID int `json:"id"`
+ Name string `json:"name" binding:"required"`
+ Type string `json:"type" binding:"required"`
+ State string `json:"state"`
+}
+
+func NewHandler(cr *chRepo.ChoreRepository, circleRepo *cRepo.CircleRepository,
+ np *nps.NotificationPlanner, nRepo *nRepo.NotificationRepository, tRepo *tRepo.ThingRepository) *Handler {
+ return &Handler{
+ choreRepo: cr,
+ circleRepo: circleRepo,
+ nPlanner: np,
+ nRepo: nRepo,
+ tRepo: tRepo,
+ }
+}
+
+func (h *Handler) CreateThing(c *gin.Context) {
+ log := logging.FromContext(c)
+ currentUser, ok := auth.CurrentUser(c)
+ if !ok {
+ c.JSON(401, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ var req ThingRequest
+ if err := c.BindJSON(&req); err != nil {
+ c.JSON(400, gin.H{"error": err.Error()})
+ return
+ }
+ thing := &tModel.Thing{
+ Name: req.Name,
+ UserID: currentUser.ID,
+ Type: req.Type,
+ State: req.State,
+ }
+ if !isValidThingState(thing) {
+ c.JSON(400, gin.H{"error": "Invalid state"})
+ return
+ }
+ log.Debug("Creating thing", thing)
+ if err := h.tRepo.UpsertThing(c, thing); err != nil {
+ c.JSON(500, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(201, gin.H{
+ "res": thing,
+ })
+}
+
+func (h *Handler) UpdateThingState(c *gin.Context) {
+ currentUser, ok := auth.CurrentUser(c)
+ if !ok {
+ c.JSON(401, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ thingIDRaw := c.Param("id")
+ thingID, err := strconv.Atoi(thingIDRaw)
+ if err != nil {
+ c.JSON(400, gin.H{"error": "Invalid thing id"})
+ return
+ }
+
+ val := c.Query("value")
+ if val == "" {
+ c.JSON(400, gin.H{"error": "state or increment query param is required"})
+ return
+ }
+ thing, err := h.tRepo.GetThingByID(c, thingID)
+ if thing.UserID != currentUser.ID {
+ c.JSON(403, gin.H{"error": "Forbidden"})
+ return
+ }
+ if err != nil {
+ c.JSON(500, gin.H{"error": "Unable to find thing"})
+ return
+ }
+ thing.State = val
+ if !isValidThingState(thing) {
+ c.JSON(400, gin.H{"error": "Invalid state"})
+ return
+ }
+
+ if err := h.tRepo.UpdateThingState(c, thing); err != nil {
+ c.JSON(500, gin.H{"error": err.Error()})
+ return
+ }
+
+ shouldReturn := EvaluateTriggerAndScheduleDueDate(h, c, thing)
+ if shouldReturn {
+ return
+ }
+
+ c.JSON(200, gin.H{
+ "res": thing,
+ })
+}
+
+func EvaluateTriggerAndScheduleDueDate(h *Handler, c *gin.Context, thing *tModel.Thing) bool {
+ thingChores, err := h.tRepo.GetThingChoresByThingId(c, thing.ID)
+ if err != nil {
+ c.JSON(500, gin.H{"error": err.Error()})
+ return true
+ }
+ for _, tc := range thingChores {
+ triggered := EvaluateThingChore(tc, thing.State)
+ if triggered {
+ h.choreRepo.SetDueDateIfNotExisted(c, tc.ChoreID, time.Now().UTC())
+ }
+ }
+ return false
+}
+
+func (h *Handler) UpdateThing(c *gin.Context) {
+ currentUser, ok := auth.CurrentUser(c)
+ if !ok {
+ c.JSON(401, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ var req ThingRequest
+ if err := c.BindJSON(&req); err != nil {
+ c.JSON(400, gin.H{"error": err.Error()})
+ return
+ }
+
+ thing, err := h.tRepo.GetThingByID(c, req.ID)
+
+ if err != nil {
+ c.JSON(500, gin.H{"error": "Unable to find thing"})
+ return
+ }
+ if thing.UserID != currentUser.ID {
+ c.JSON(403, gin.H{"error": "Forbidden"})
+ return
+ }
+ thing.Name = req.Name
+ thing.Type = req.Type
+ if req.State != "" {
+ thing.State = req.State
+ if !isValidThingState(thing) {
+ c.JSON(400, gin.H{"error": "Invalid state"})
+ return
+ }
+ }
+
+ if err := h.tRepo.UpsertThing(c, thing); err != nil {
+ c.JSON(500, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(200, gin.H{
+ "res": thing,
+ })
+}
+
+func (h *Handler) GetAllThings(c *gin.Context) {
+ currentUser, ok := auth.CurrentUser(c)
+ if !ok {
+ c.JSON(401, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ things, err := h.tRepo.GetUserThings(c, currentUser.ID)
+ if err != nil {
+ c.JSON(500, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(200, gin.H{
+ "res": things,
+ })
+}
+
+func (h *Handler) GetThingHistory(c *gin.Context) {
+ currentUser, ok := auth.CurrentUser(c)
+ if !ok {
+ c.JSON(401, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ thingIDRaw := c.Param("id")
+ thingID, err := strconv.Atoi(thingIDRaw)
+ if err != nil {
+ c.JSON(400, gin.H{"error": "Invalid thing id"})
+ return
+ }
+
+ thing, err := h.tRepo.GetThingByID(c, thingID)
+ if err != nil {
+ c.JSON(500, gin.H{"error": "Unable to find thing"})
+ return
+ }
+ if thing.UserID != currentUser.ID {
+ c.JSON(403, gin.H{"error": "Forbidden"})
+ return
+ }
+ offsetRaw := c.Query("offset")
+ offset, err := strconv.Atoi(offsetRaw)
+ if err != nil {
+ c.JSON(400, gin.H{"error": "Invalid offset"})
+ return
+ }
+ history, err := h.tRepo.GetThingHistoryWithOffset(c, thingID, offset, 10)
+ if err != nil {
+ c.JSON(500, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(200, gin.H{
+ "res": history,
+ })
+}
+
+func (h *Handler) DeleteThing(c *gin.Context) {
+ currentUser, ok := auth.CurrentUser(c)
+ if !ok {
+ c.JSON(401, gin.H{"error": "Unauthorized"})
+ return
+ }
+
+ thingIDRaw := c.Param("id")
+ thingID, err := strconv.Atoi(thingIDRaw)
+ if err != nil {
+ c.JSON(400, gin.H{"error": "Invalid thing id"})
+ return
+ }
+
+ thing, err := h.tRepo.GetThingByID(c, thingID)
+ if err != nil {
+ c.JSON(500, gin.H{"error": "Unable to find thing"})
+ return
+ }
+ if thing.UserID != currentUser.ID {
+ c.JSON(403, gin.H{"error": "Forbidden"})
+ return
+ }
+ if err := h.tRepo.DeleteThing(c, thingID); err != nil {
+ c.JSON(500, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(200, gin.H{})
+}
+func Routes(r *gin.Engine, h *Handler, auth *jwt.GinJWTMiddleware) {
+
+ thingRoutes := r.Group("things")
+ thingRoutes.Use(auth.MiddlewareFunc())
+ {
+ thingRoutes.POST("", h.CreateThing)
+ thingRoutes.PUT("/:id/state", h.UpdateThingState)
+ thingRoutes.PUT("", h.UpdateThing)
+ thingRoutes.GET("", h.GetAllThings)
+ thingRoutes.GET("/:id/history", h.GetThingHistory)
+ thingRoutes.DELETE("/:id", h.DeleteThing)
+ }
+}
diff --git a/internal/thing/helper.go b/internal/thing/helper.go
new file mode 100644
index 0000000..c3941ee
--- /dev/null
+++ b/internal/thing/helper.go
@@ -0,0 +1,57 @@
+package thing
+
+import (
+ "strconv"
+
+ tModel "donetick.com/core/internal/thing/model"
+)
+
+func isValidThingState(thing *tModel.Thing) bool {
+ switch thing.Type {
+ case "number":
+ _, err := strconv.Atoi(thing.State)
+ return err == nil
+ case "text":
+ return true
+ case "boolean":
+ return thing.State == "true" || thing.State == "false"
+ default:
+ return false
+ }
+}
+
+func EvaluateThingChore(tchore *tModel.ThingChore, newState string) bool {
+ if tchore.Condition == "" {
+ return newState == tchore.TriggerState
+ }
+
+ switch tchore.Condition {
+ case "eq":
+ return newState == tchore.TriggerState
+ case "neq":
+ return newState != tchore.TriggerState
+ }
+
+ newStateInt, err := strconv.Atoi(newState)
+ if err != nil {
+ return false
+ }
+ TargetStateInt, err := strconv.Atoi(tchore.TriggerState)
+ if err != nil {
+ return false
+ }
+
+ switch tchore.Condition {
+ case "gt":
+ return newStateInt > TargetStateInt
+ case "lt":
+ return newStateInt < TargetStateInt
+ case "gte":
+ return newStateInt >= TargetStateInt
+ case "lte":
+ return newStateInt <= TargetStateInt
+ default:
+ return newState == tchore.TriggerState
+ }
+
+}
diff --git a/internal/thing/model/model.go b/internal/thing/model/model.go
new file mode 100644
index 0000000..1780c64
--- /dev/null
+++ b/internal/thing/model/model.go
@@ -0,0 +1,30 @@
+package model
+
+import "time"
+
+type Thing struct {
+ ID int `json:"id" gorm:"primary_key"`
+ UserID int `json:"userID" gorm:"column:user_id"`
+ CircleID int `json:"circleId" gorm:"column:circle_id"`
+ Name string `json:"name" gorm:"column:name"`
+ State string `json:"state" gorm:"column:state"`
+ Type string `json:"type" gorm:"column:type"`
+ ThingChores []ThingChore `json:"thingChores" gorm:"foreignkey:ThingID;references:ID"`
+ UpdatedAt *time.Time `json:"updatedAt" gorm:"column:updated_at"`
+ CreatedAt *time.Time `json:"createdAt" gorm:"column:created_at"`
+}
+
+type ThingHistory struct {
+ ID int `json:"id" gorm:"primary_key"`
+ ThingID int `json:"thingId" gorm:"column:thing_id"`
+ State string `json:"state" gorm:"column:state"`
+ UpdatedAt *time.Time `json:"updatedAt" gorm:"column:updated_at"`
+ CreatedAt *time.Time `json:"createdAt" gorm:"column:created_at"`
+}
+
+type ThingChore struct {
+ ThingID int `json:"thingId" gorm:"column:thing_id;primaryKey;uniqueIndex:idx_thing_user"`
+ ChoreID int `json:"choreId" gorm:"column:chore_id;primaryKey;uniqueIndex:idx_thing_user"`
+ TriggerState string `json:"triggerState" gorm:"column:trigger_state"`
+ Condition string `json:"condition" gorm:"column:condition"`
+}
diff --git a/internal/thing/repo/repository.go b/internal/thing/repo/repository.go
new file mode 100644
index 0000000..8b2dbaa
--- /dev/null
+++ b/internal/thing/repo/repository.go
@@ -0,0 +1,117 @@
+package chore
+
+import (
+ "context"
+ "time"
+
+ config "donetick.com/core/config"
+ tModel "donetick.com/core/internal/thing/model"
+ "gorm.io/gorm"
+)
+
+type ThingRepository struct {
+ db *gorm.DB
+ dbType string
+}
+
+func NewThingRepository(db *gorm.DB, cfg *config.Config) *ThingRepository {
+ return &ThingRepository{db: db, dbType: cfg.Database.Type}
+}
+
+func (r *ThingRepository) UpsertThing(c context.Context, thing *tModel.Thing) error {
+ return r.db.WithContext(c).Model(&thing).Save(thing).Error
+}
+
+func (r *ThingRepository) UpdateThingState(c context.Context, thing *tModel.Thing) error {
+ // update the state of the thing where the id is the same:
+ if err := r.db.WithContext(c).Model(&thing).Where("id = ?", thing.ID).Updates(map[string]interface{}{
+ "state": thing.State,
+ "updated_at": time.Now().UTC(),
+ }).Error; err != nil {
+ return err
+ }
+ // Create history Record of the thing :
+ createdAt := time.Now().UTC()
+ thingHistory := &tModel.ThingHistory{
+ ThingID: thing.ID,
+ State: thing.State,
+ CreatedAt: &createdAt,
+ UpdatedAt: &createdAt,
+ }
+
+ if err := r.db.WithContext(c).Create(thingHistory).Error; err != nil {
+ return err
+ }
+
+ return nil
+}
+func (r *ThingRepository) GetThingByID(c context.Context, thingID int) (*tModel.Thing, error) {
+ var thing tModel.Thing
+ if err := r.db.WithContext(c).Model(&tModel.Thing{}).Preload("ThingChores").First(&thing, thingID).Error; err != nil {
+ return nil, err
+ }
+ return &thing, nil
+}
+
+func (r *ThingRepository) GetThingByChoreID(c context.Context, choreID int) (*tModel.Thing, error) {
+ var thing tModel.Thing
+ if err := r.db.WithContext(c).Model(&tModel.Thing{}).Joins("left join thing_chores on things.id = thing_chores.thing_id").First(&thing, "thing_chores.chore_id = ?", choreID).Error; err != nil {
+ return nil, err
+ }
+ return &thing, nil
+}
+
+func (r *ThingRepository) AssociateThingWithChore(c context.Context, thingID int, choreID int, triggerState string, condition string) error {
+
+ return r.db.WithContext(c).Save(&tModel.ThingChore{ThingID: thingID, ChoreID: choreID, TriggerState: triggerState, Condition: condition}).Error
+}
+
+func (r *ThingRepository) DissociateThingWithChore(c context.Context, thingID int, choreID int) error {
+ return r.db.WithContext(c).Where("thing_id = ? AND chore_id = ?", thingID, choreID).Delete(&tModel.ThingChore{}).Error
+}
+
+func (r *ThingRepository) GetThingHistoryWithOffset(c context.Context, thingID int, offset int, limit 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(limit).Find(&thingHistory).Error; err != nil {
+ return nil, err
+ }
+ return thingHistory, nil
+}
+
+func (r *ThingRepository) GetUserThings(c context.Context, userID int) ([]*tModel.Thing, error) {
+ var things []*tModel.Thing
+ if err := r.db.WithContext(c).Model(&tModel.Thing{}).Where("user_id = ?", userID).Find(&things).Error; err != nil {
+ return nil, err
+ }
+ return things, nil
+}
+
+func (r *ThingRepository) DeleteThing(c context.Context, thingID int) error {
+ // one transaction to delete the thing and its history :
+ return r.db.WithContext(c).Transaction(func(tx *gorm.DB) error {
+ if err := r.db.WithContext(c).Where("thing_id = ?", thingID).Delete(&tModel.ThingHistory{}).Error; err != nil {
+ return err
+ }
+ if err := r.db.WithContext(c).Delete(&tModel.Thing{}, thingID).Error; err != nil {
+ return err
+ }
+ return nil
+ })
+}
+
+// get ThingChores by thingID:
+func (r *ThingRepository) GetThingChoresByThingId(c context.Context, thingID int) ([]*tModel.ThingChore, error) {
+ var thingChores []*tModel.ThingChore
+ if err := r.db.WithContext(c).Model(&tModel.ThingChore{}).Where("thing_id = ?", thingID).Find(&thingChores).Error; err != nil {
+ return nil, err
+ }
+ return thingChores, nil
+}
+
+// func (r *ThingRepository) GetChoresByThingId(c context.Context, thingID int) ([]*chModel.Chore, error) {
+// var chores []*chModel.Chore
+// if err := r.db.WithContext(c).Model(&chModel.Chore{}).Joins("left join thing_chores on chores.id = thing_chores.chore_id").Where("thing_chores.thing_id = ?", thingID).Find(&chores).Error; err != nil {
+// return nil, err
+// }
+// return chores, nil
+// }
diff --git a/internal/thing/webhook.go b/internal/thing/webhook.go
new file mode 100644
index 0000000..0d110ab
--- /dev/null
+++ b/internal/thing/webhook.go
@@ -0,0 +1,175 @@
+package thing
+
+import (
+ "strconv"
+ "time"
+
+ "donetick.com/core/config"
+ chRepo "donetick.com/core/internal/chore/repo"
+ cRepo "donetick.com/core/internal/circle/repo"
+ tModel "donetick.com/core/internal/thing/model"
+ tRepo "donetick.com/core/internal/thing/repo"
+ uRepo "donetick.com/core/internal/user/repo"
+ "donetick.com/core/internal/utils"
+ "donetick.com/core/logging"
+ jwt "github.com/appleboy/gin-jwt/v2"
+ "github.com/gin-gonic/gin"
+)
+
+type Webhook struct {
+ choreRepo *chRepo.ChoreRepository
+ circleRepo *cRepo.CircleRepository
+ thingRepo *tRepo.ThingRepository
+ userRepo *uRepo.UserRepository
+ tRepo *tRepo.ThingRepository
+}
+
+func NewWebhook(cr *chRepo.ChoreRepository, circleRepo *cRepo.CircleRepository,
+ thingRepo *tRepo.ThingRepository, userRepo *uRepo.UserRepository, tRepo *tRepo.ThingRepository) *Webhook {
+ return &Webhook{
+ choreRepo: cr,
+ circleRepo: circleRepo,
+ thingRepo: thingRepo,
+ userRepo: userRepo,
+ tRepo: tRepo,
+ }
+}
+
+func (h *Webhook) UpdateThingState(c *gin.Context) {
+ thing, shouldReturn := validateUserAndThing(c, h)
+ if shouldReturn {
+ return
+ }
+
+ state := c.Query("state")
+ if state == "" {
+ c.JSON(400, gin.H{"error": "Invalid state value"})
+ return
+ }
+
+ thing.State = state
+ if !isValidThingState(thing) {
+ c.JSON(400, gin.H{"error": "Invalid state for thing"})
+ return
+ }
+
+ if err := h.thingRepo.UpdateThingState(c, thing); err != nil {
+ c.JSON(500, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(200, gin.H{})
+}
+
+func (h *Webhook) ChangeThingState(c *gin.Context) {
+ thing, shouldReturn := validateUserAndThing(c, h)
+ if shouldReturn {
+ return
+ }
+ addRemoveRaw := c.Query("op")
+ setRaw := c.Query("set")
+
+ if addRemoveRaw == "" && setRaw == "" {
+ c.JSON(400, gin.H{"error": "Invalid increment value"})
+ return
+ }
+ var xValue int
+ var err error
+ if addRemoveRaw != "" {
+ xValue, err = strconv.Atoi(addRemoveRaw)
+ if err != nil {
+ c.JSON(400, gin.H{"error": "Invalid increment value"})
+ return
+ }
+ currentState, err := strconv.Atoi(thing.State)
+ if err != nil {
+ c.JSON(400, gin.H{"error": "Invalid state for thing"})
+ return
+ }
+ newState := currentState + xValue
+ thing.State = strconv.Itoa(newState)
+ }
+ if setRaw != "" {
+ thing.State = setRaw
+ }
+
+ if !isValidThingState(thing) {
+ c.JSON(400, gin.H{"error": "Invalid state for thing"})
+ return
+ }
+ if err := h.thingRepo.UpdateThingState(c, thing); err != nil {
+ c.JSON(500, gin.H{"error": err.Error()})
+ return
+ }
+
+ shouldReturn1 := WebhookEvaluateTriggerAndScheduleDueDate(h, c, thing)
+ if shouldReturn1 {
+ return
+ }
+
+ c.JSON(200, gin.H{"state": thing.State})
+}
+
+func WebhookEvaluateTriggerAndScheduleDueDate(h *Webhook, c *gin.Context, thing *tModel.Thing) bool {
+ // handler should be interface to not duplicate both WebhookEvaluateTriggerAndScheduleDueDate and EvaluateTriggerAndScheduleDueDate
+ // this is bad code written Saturday at 2:25 AM
+
+ log := logging.FromContext(c)
+
+ thingChores, err := h.tRepo.GetThingChoresByThingId(c, thing.ID)
+ if err != nil {
+ c.JSON(500, gin.H{"error": err.Error()})
+ return true
+ }
+ for _, tc := range thingChores {
+ triggered := EvaluateThingChore(tc, thing.State)
+ if triggered {
+ errSave := h.choreRepo.SetDueDate(c, tc.ChoreID, time.Now().UTC())
+ if errSave != nil {
+ log.Error("Error setting due date for chore ", errSave)
+ log.Error("Chore ID ", tc.ChoreID, " Thing ID ", thing.ID, " State ", thing.State)
+ }
+ }
+
+ }
+ return false
+}
+
+func validateUserAndThing(c *gin.Context, h *Webhook) (*tModel.Thing, bool) {
+ apiToken := c.GetHeader("secretkey")
+ if apiToken == "" {
+ c.JSON(401, gin.H{"error": "Unauthorized"})
+ return nil, true
+ }
+ thingID, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ c.JSON(400, gin.H{"error": err.Error()})
+ return nil, true
+ }
+ user, err := h.userRepo.GetUserByToken(c, apiToken)
+ if err != nil {
+ c.JSON(401, gin.H{"error": "Unauthorized"})
+ return nil, true
+ }
+ thing, err := h.thingRepo.GetThingByID(c, thingID)
+ if err != nil {
+ c.JSON(400, gin.H{"error": "Invalid thing id"})
+ return nil, true
+ }
+ if thing.UserID != user.ID {
+ c.JSON(401, gin.H{"error": "Unauthorized"})
+ return nil, true
+ }
+ return thing, false
+}
+
+func Webhooks(cfg *config.Config, w *Webhook, r *gin.Engine, auth *jwt.GinJWTMiddleware) {
+
+ thingsAPI := r.Group("webhooks/things")
+
+ thingsAPI.Use(utils.TimeoutMiddleware(cfg.Server.WriteTimeout))
+ {
+ thingsAPI.GET("/:id/state/change", w.ChangeThingState)
+ thingsAPI.GET("/:id/state", w.UpdateThingState)
+ }
+
+}
diff --git a/internal/user/handler.go b/internal/user/handler.go
new file mode 100644
index 0000000..0eee6f2
--- /dev/null
+++ b/internal/user/handler.go
@@ -0,0 +1,511 @@
+package user
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "html"
+ "net/http"
+ "time"
+
+ auth "donetick.com/core/internal/authorization"
+ cModel "donetick.com/core/internal/circle/model"
+ cRepo "donetick.com/core/internal/circle/repo"
+ "donetick.com/core/internal/email"
+ uModel "donetick.com/core/internal/user/model"
+ uRepo "donetick.com/core/internal/user/repo"
+ "donetick.com/core/internal/utils"
+ "donetick.com/core/logging"
+ jwt "github.com/appleboy/gin-jwt/v2"
+ "github.com/gin-gonic/gin"
+ limiter "github.com/ulule/limiter/v3"
+ "google.golang.org/api/googleapi"
+ "google.golang.org/api/oauth2/v1"
+)
+
+type Handler struct {
+ userRepo *uRepo.UserRepository
+ circleRepo *cRepo.CircleRepository
+ jwtAuth *jwt.GinJWTMiddleware
+ email *email.EmailSender
+}
+
+func NewHandler(ur *uRepo.UserRepository, cr *cRepo.CircleRepository, jwtAuth *jwt.GinJWTMiddleware, email *email.EmailSender) *Handler {
+ return &Handler{
+ userRepo: ur,
+ circleRepo: cr,
+ jwtAuth: jwtAuth,
+ email: email,
+ }
+}
+
+func (h *Handler) GetAllUsers() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ currentUser, ok := auth.CurrentUser(c)
+ if !ok {
+ c.JSON(500, gin.H{
+ "error": "Error getting current user",
+ })
+ return
+ }
+
+ users, err := h.userRepo.GetAllUsers(c, currentUser.CircleID)
+ if err != nil {
+ c.JSON(500, gin.H{
+ "error": "Error getting users",
+ })
+ return
+ }
+
+ c.JSON(200, gin.H{
+ "res": users,
+ })
+ }
+}
+
+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"`
+ DisplayName string `json:"displayName"`
+ }
+ var signupReq SignUpReq
+ if err := c.BindJSON(&signupReq); err != nil {
+ c.JSON(400, gin.H{
+ "error": "Invalid request",
+ })
+ return
+ }
+ if signupReq.DisplayName == "" {
+ signupReq.DisplayName = signupReq.Username
+ }
+ password, err := auth.EncodePassword(signupReq.Password)
+ signupReq.Username = html.EscapeString(signupReq.Username)
+ signupReq.DisplayName = html.EscapeString(signupReq.DisplayName)
+
+ if err != nil {
+ c.JSON(500, gin.H{
+ "error": "Error encoding password",
+ })
+ return
+ }
+ var insertedUser *uModel.User
+ if insertedUser, err = h.userRepo.CreateUser(c, &uModel.User{
+ Username: signupReq.Username,
+ Password: password,
+ DisplayName: signupReq.DisplayName,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }); err != nil {
+ c.JSON(500, gin.H{
+ "error": "Error creating user",
+ })
+ return
+ }
+ // var userCircle *circle.Circle
+ // var userRole string
+ userCircle, err := h.circleRepo.CreateCircle(c, &cModel.Circle{
+ Name: signupReq.DisplayName + "'s circle",
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ InviteCode: utils.GenerateInviteCode(c),
+ })
+
+ if err != nil {
+ c.JSON(500, gin.H{
+ "error": "Error creating circle",
+ })
+ return
+ }
+
+ if err := h.circleRepo.AddUserToCircle(c, &cModel.UserCircle{
+ UserID: insertedUser.ID,
+ CircleID: userCircle.ID,
+ Role: "admin",
+ IsActive: true,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }); err != nil {
+ c.JSON(500, gin.H{
+ "error": "Error adding user to circle",
+ })
+ return
+ }
+ insertedUser.CircleID = userCircle.ID
+ if err := h.userRepo.UpdateUser(c, insertedUser); err != nil {
+ c.JSON(500, gin.H{
+ "error": "Error updating user",
+ })
+ return
+ }
+
+ c.JSON(201, gin.H{})
+}
+
+func (h *Handler) GetUserProfile(c *gin.Context) {
+ user, ok := auth.CurrentUser(c)
+ if !ok {
+ c.JSON(500, gin.H{
+ "error": "Error getting user",
+ })
+ return
+ }
+ c.JSON(200, gin.H{
+ "res": user,
+ })
+}
+
+func (h *Handler) thirdPartyAuthCallback(c *gin.Context) {
+
+ // read :provider from path param, if param is google check the token with google if it's valid and fetch the user details:
+ logger := logging.FromContext(c)
+ provider := c.Param("provider")
+ logger.Infow("account.handler.thirdPartyAuthCallback", "provider", provider)
+
+ if provider == "google" {
+ c.Set("auth_provider", "3rdPartyAuth")
+ type OAuthRequest struct {
+ Token string `json:"token" binding:"required"`
+ Provider string `json:"provider" binding:"required"`
+ }
+ var body OAuthRequest
+ if err := c.ShouldBindJSON(&body); err != nil {
+ logger.Errorw("account.handler.thirdPartyAuthCallback failed to bind", "err", err)
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": "Invalid request",
+ })
+ return
+ }
+
+ // logger.Infow("account.handler.thirdPartyAuthCallback", "token", token)
+ service, err := oauth2.New(http.DefaultClient)
+
+ // tokenInfo, err := service.Tokeninfo().AccessToken(token).Do()
+ userinfo, err := service.Userinfo.Get().Do(googleapi.QueryParameter("access_token", body.Token))
+ logger.Infow("account.handler.thirdPartyAuthCallback", "tokenInfo", userinfo)
+ if err != nil {
+ logger.Errorw("account.handler.thirdPartyAuthCallback failed to get token info", "err", err)
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": "Invalid token",
+ })
+ return
+ }
+
+ acc, err := h.userRepo.FindByEmail(c, userinfo.Email)
+
+ if err != nil {
+ // create a random password for the user using crypto/rand:
+ password := auth.GenerateRandomPassword(12)
+ encodedPassword, err := auth.EncodePassword(password)
+ acc = &uModel.User{
+ Username: userinfo.Id,
+ Email: userinfo.Email,
+ Image: userinfo.Picture,
+ Password: encodedPassword,
+ DisplayName: userinfo.GivenName,
+ Provider: 2,
+ }
+ createdUser, err := h.userRepo.CreateUser(c, acc)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Unable to create user",
+ })
+ return
+
+ }
+ // Create Circle for the user:
+ userCircle, err := h.circleRepo.CreateCircle(c, &cModel.Circle{
+ Name: userinfo.GivenName + "'s circle",
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ InviteCode: utils.GenerateInviteCode(c),
+ })
+
+ if err != nil {
+ c.JSON(500, gin.H{
+ "error": "Error creating circle",
+ })
+ return
+ }
+
+ if err := h.circleRepo.AddUserToCircle(c, &cModel.UserCircle{
+ UserID: createdUser.ID,
+ CircleID: userCircle.ID,
+ Role: "admin",
+ IsActive: true,
+ CreatedAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }); err != nil {
+ c.JSON(500, gin.H{
+ "error": "Error adding user to circle",
+ })
+ return
+ }
+ createdUser.CircleID = userCircle.ID
+ if err := h.userRepo.UpdateUser(c, createdUser); err != nil {
+ c.JSON(500, gin.H{
+ "error": "Error updating user",
+ })
+ return
+ }
+ }
+ // use auth to generate a token for the user:
+ c.Set("user_account", acc)
+ h.jwtAuth.Authenticator(c)
+ tokenString, expire, err := h.jwtAuth.TokenGenerator(acc)
+ if err != nil {
+ logger.Errorw("Unable to Generate a Token")
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Unable to Generate a Token",
+ })
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"token": tokenString, "expire": expire})
+ return
+ }
+}
+
+func (h *Handler) resetPassword(c *gin.Context) {
+ log := logging.FromContext(c)
+ type ResetPasswordReq struct {
+ Email string `json:"email" binding:"required,email"`
+ }
+ var req ResetPasswordReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": "Invalid request",
+ })
+ return
+ }
+ user, err := h.userRepo.FindByEmail(c, req.Email)
+ if err != nil {
+ c.JSON(http.StatusNotFound, gin.H{
+ "error": "User not found",
+ })
+ return
+ }
+ if user.Provider != 0 {
+ // user create account thought login with Gmail. they can reset the password they just need to login with google again
+ c.JSON(
+ http.StatusForbidden,
+ gin.H{
+ "error": "User account created with google login. Please login with google",
+ },
+ )
+ return
+ }
+ // generate a random password:
+ token, err := auth.GenerateEmailResetToken(c)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Unable to generate token",
+ })
+ return
+ }
+
+ err = h.userRepo.SetPasswordResetToken(c, req.Email, token)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Unable to generate password",
+ })
+ return
+ }
+ // send an email to the user with the new password:
+ err = h.email.SendResetPasswordEmail(c, req.Email, token)
+ if err != nil {
+ log.Errorw("account.handler.resetPassword failed to send email", "err", err)
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Unable to send email",
+ })
+ return
+ }
+
+ // send an email to the user with the new password:
+ c.JSON(http.StatusOK, gin.H{})
+}
+
+func (h *Handler) updateUserPassword(c *gin.Context) {
+ logger := logging.FromContext(c)
+ // read the code from query param:
+ code := c.Query("c")
+ email, code, err := email.DecodeEmailAndCode(code)
+ if err != nil {
+ logger.Errorw("account.handler.verify failed to decode email and code", "err", err)
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": "Invalid code",
+ })
+ return
+
+ }
+ // read password from body:
+ type RequestBody struct {
+ Password string `json:"password" binding:"required,min=8,max=32"`
+ }
+ var body RequestBody
+ if err := c.ShouldBindJSON(&body); err != nil {
+ logger.Errorw("user.handler.resetAccountPassword failed to bind", "err", err)
+ c.JSON(http.StatusBadRequest, gin.H{
+ "error": "Invalid request",
+ })
+ return
+
+ }
+ password, err := auth.EncodePassword(body.Password)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Unable to process password",
+ })
+ return
+ }
+
+ err = h.userRepo.UpdatePasswordByToken(c.Request.Context(), email, code, password)
+ if err != nil {
+ logger.Errorw("account.handler.resetAccountPassword failed to reset password", "err", err)
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Unable to reset password",
+ })
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{})
+
+}
+
+func (h *Handler) UpdateUserDetails(c *gin.Context) {
+ type UpdateUserReq struct {
+ DisplayName *string `json:"displayName" binding:"omitempty"`
+ ChatID *int64 `json:"chatID" binding:"omitempty"`
+ Image *string `json:"image" binding:"omitempty"`
+ }
+ user, ok := auth.CurrentUser(c)
+ if !ok {
+ c.JSON(500, gin.H{
+ "error": "Error getting user",
+ })
+ return
+ }
+ var req UpdateUserReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(400, gin.H{
+ "error": "Invalid request",
+ })
+ return
+ }
+ // update non-nil fields:
+ if req.DisplayName != nil {
+ user.DisplayName = *req.DisplayName
+ }
+ if req.ChatID != nil {
+ user.ChatID = *req.ChatID
+ }
+ if req.Image != nil {
+ user.Image = *req.Image
+ }
+
+ if err := h.userRepo.UpdateUser(c, user); err != nil {
+ c.JSON(500, gin.H{
+ "error": "Error updating user",
+ })
+ return
+ }
+ c.JSON(200, user)
+}
+
+func (h *Handler) CreateLongLivedToken(c *gin.Context) {
+ currentUser, ok := auth.CurrentUser(c)
+ if !ok {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get current user"})
+ return
+ }
+ type TokenRequest struct {
+ Name string `json:"name" binding:"required"`
+ }
+ var req TokenRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
+ return
+ }
+
+ // Step 1: Generate a secure random number
+ randomBytes := make([]byte, 16) // 128 bits are enough for strong randomness
+ _, err := rand.Read(randomBytes)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate random part of the token"})
+ return
+ }
+
+ timestamp := time.Now().Unix()
+ hashInput := fmt.Sprintf("%s:%d:%x", currentUser.Username, timestamp, randomBytes)
+ hash := sha256.Sum256([]byte(hashInput))
+
+ token := hex.EncodeToString(hash[:])
+
+ tokenModel, err := h.userRepo.StoreAPIToken(c, currentUser.ID, req.Name, token)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store the token"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"res": tokenModel})
+}
+
+func (h *Handler) GetAllUserToken(c *gin.Context) {
+ currentUser, ok := auth.CurrentUser(c)
+ if !ok {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get current user"})
+ return
+ }
+
+ tokens, err := h.userRepo.GetAllUserTokens(c, currentUser.ID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user tokens"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"res": tokens})
+
+}
+
+func (h *Handler) DeleteUserToken(c *gin.Context) {
+ currentUser, ok := auth.CurrentUser(c)
+ if !ok {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get current user"})
+ return
+ }
+
+ tokenID := c.Param("id")
+
+ err := h.userRepo.DeleteAPIToken(c, currentUser.ID, tokenID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete the token"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{})
+}
+
+func Routes(router *gin.Engine, h *Handler, auth *jwt.GinJWTMiddleware, limiter *limiter.Limiter) {
+
+ userRoutes := router.Group("users")
+ userRoutes.Use(auth.MiddlewareFunc(), utils.RateLimitMiddleware(limiter))
+ {
+ userRoutes.GET("/", h.GetAllUsers())
+ userRoutes.GET("/profile", h.GetUserProfile)
+ userRoutes.PUT("", h.UpdateUserDetails)
+ userRoutes.POST("/tokens", h.CreateLongLivedToken)
+ userRoutes.GET("/tokens", h.GetAllUserToken)
+ userRoutes.DELETE("/tokens/:id", h.DeleteUserToken)
+ }
+
+ authRoutes := router.Group("auth")
+ authRoutes.Use(utils.RateLimitMiddleware(limiter))
+ {
+ authRoutes.POST("/", h.signUp)
+ authRoutes.POST("login", auth.LoginHandler)
+ authRoutes.GET("refresh", auth.RefreshHandler)
+ authRoutes.POST("/:provider/callback", h.thirdPartyAuthCallback)
+ authRoutes.POST("reset", h.resetPassword)
+ authRoutes.POST("password", h.updateUserPassword)
+ }
+}
diff --git a/internal/user/model/model.go b/internal/user/model/model.go
new file mode 100644
index 0000000..4874ac1
--- /dev/null
+++ b/internal/user/model/model.go
@@ -0,0 +1,38 @@
+package user
+
+import "time"
+
+type User struct {
+ ID int `json:"id" gorm:"primary_key"` // Unique identifier
+ DisplayName string `json:"displayName" gorm:"column:display_name"` // Display name
+ Username string `json:"username" gorm:"column:username;unique"` // Username (unique)
+ Email string `json:"email" gorm:"column:email;unique"` // Email (unique)
+ Provider int `json:"provider" gorm:"column:provider"` // Provider
+ Password string `json:"-" gorm:"column:password"` // Password
+ CircleID int `json:"circleID" gorm:"column:circle_id"` // Circle ID
+ ChatID int64 `json:"chatID" gorm:"column:chat_id"` // Telegram chat ID
+ Image string `json:"image" gorm:"column:image"` // Image
+ CreatedAt time.Time `json:"created_at" gorm:"column:created_at"` // Created at
+ UpdatedAt time.Time `json:"updated_at" gorm:"column:updated_at"` // Updated at
+ Disabled bool `json:"disabled" gorm:"column:disabled"` // Disabled
+ // Email string `json:"email" gorm:"column:email"` // Email
+ CustomerID *string `gorm:"column:customer_id;<-:false"` // read one column
+ Subscription *string `json:"subscription" gorm:"column:subscription;<-:false"` // read one column
+ Expiration *string `json:"expiration" gorm:"column:expiration;<-:false"` // read one column
+}
+
+type UserPasswordReset struct {
+ ID int `gorm:"column:id"`
+ UserID int `gorm:"column:user_id"`
+ Email string `gorm:"column:email"`
+ Token string `gorm:"column:token"`
+ ExpirationDate time.Time `gorm:"column:expiration_date"`
+}
+
+type APIToken struct {
+ ID int `json:"id" gorm:"primary_key"` // Unique identifier
+ Name string `json:"name" gorm:"column:name;unique"` // Name (unique)
+ UserID int `json:"userId" gorm:"column:user_id;index"` // Index on userID
+ Token string `json:"token" gorm:"column:token;index"` // Index on token
+ CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"`
+}
diff --git a/internal/user/repo/repository.go b/internal/user/repo/repository.go
new file mode 100644
index 0000000..fa53753
--- /dev/null
+++ b/internal/user/repo/repository.go
@@ -0,0 +1,160 @@
+package user
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "donetick.com/core/config"
+ uModel "donetick.com/core/internal/user/model"
+ "donetick.com/core/logging"
+ "gorm.io/gorm"
+)
+
+type IUserRepository interface {
+ GetUserByUsername(username string) (*uModel.User, error)
+ GetUser(id int) (*uModel.User, error)
+ GetAllUsers() ([]*uModel.User, error)
+ CreateUser(user *uModel.User) error
+ UpdateUser(user *uModel.User) error
+ UpdateUserCircle(userID, circleID int) error
+ FindByEmail(email string) (*uModel.User, error)
+}
+
+type UserRepository struct {
+ db *gorm.DB
+ isDonetickDotCom bool
+}
+
+func NewUserRepository(db *gorm.DB, cfg *config.Config) *UserRepository {
+ return &UserRepository{db, cfg.IsDoneTickDotCom}
+}
+
+func (r *UserRepository) GetAllUsers(c context.Context, circleID int) ([]*uModel.User, error) {
+ var users []*uModel.User
+ if err := r.db.WithContext(c).Where("circle_id = ?", circleID).Find(&users).Error; err != nil {
+ return nil, err
+ }
+ return users, nil
+}
+
+func (r *UserRepository) GetAllUsersForSystemOnly(c context.Context) ([]*uModel.User, error) {
+ var users []*uModel.User
+ if err := r.db.WithContext(c).Find(&users).Error; err != nil {
+ return nil, err
+ }
+ return users, nil
+}
+func (r *UserRepository) CreateUser(c context.Context, user *uModel.User) (*uModel.User, error) {
+ if err := r.db.WithContext(c).Save(user).Error; err != nil {
+ return nil, err
+ }
+ return user, nil
+}
+func (r *UserRepository) GetUserByUsername(c context.Context, username string) (*uModel.User, error) {
+ var user *uModel.User
+ if r.isDonetickDotCom {
+ if err := r.db.WithContext(c).Table("users u").Select("u.*, ss.status as subscription, ss.expired_at as expiration").Joins("left join stripe_customers sc on sc.user_id = u.id ").Joins("left join stripe_subscriptions ss on sc.customer_id = ss.customer_id").Where("username = ?", username).First(&user).Error; err != nil {
+ return nil, err
+ }
+ } else {
+ if err := r.db.WithContext(c).Where("username = ?", username).First(&user).Error; err != nil {
+ return nil, err
+ }
+ }
+
+ return user, nil
+}
+
+func (r *UserRepository) UpdateUser(c context.Context, user *uModel.User) error {
+ return r.db.WithContext(c).Save(user).Error
+}
+
+func (r *UserRepository) UpdateUserCircle(c context.Context, userID, circleID int) error {
+ return r.db.WithContext(c).Model(&uModel.User{}).Where("id = ?", userID).Update("circle_id", circleID).Error
+}
+
+func (r *UserRepository) FindByEmail(c context.Context, email string) (*uModel.User, error) {
+ var user *uModel.User
+ if err := r.db.WithContext(c).Where("email = ?", email).First(&user).Error; err != nil {
+ return nil, err
+ }
+ return user, nil
+}
+
+func (r *UserRepository) SetPasswordResetToken(c context.Context, email, token string) error {
+ // confirm user exists with email:
+ user, err := r.FindByEmail(c, email)
+ if err != nil {
+ return err
+ }
+ // save new token:
+ if err := r.db.WithContext(c).Model(&uModel.UserPasswordReset{}).Save(&uModel.UserPasswordReset{
+ UserID: user.ID,
+ Token: token,
+ Email: email,
+ ExpirationDate: time.Now().UTC().Add(time.Hour * 24),
+ }).Error; err != nil {
+ return err
+ }
+ return nil
+}
+
+func (r *UserRepository) UpdatePasswordByToken(ctx context.Context, email string, token string, password string) error {
+ logger := logging.FromContext(ctx)
+
+ logger.Debugw("account.db.UpdatePasswordByToken", "email", email)
+ upr := &uModel.UserPasswordReset{
+ Email: email,
+ Token: token,
+ }
+ result := r.db.WithContext(ctx).Where("email = ?", email).Where("token = ?", token).Delete(upr)
+ if result.RowsAffected <= 0 {
+ return fmt.Errorf("invalid token")
+ }
+ // find account by email and update password:
+ chain := r.db.WithContext(ctx).Model(&uModel.User{}).Where("email = ?", email).UpdateColumns(map[string]interface{}{"password": password})
+ if chain.Error != nil {
+ return chain.Error
+ }
+ if chain.RowsAffected == 0 {
+ return fmt.Errorf("account not found")
+ }
+
+ return nil
+}
+
+func (r *UserRepository) StoreAPIToken(c context.Context, userID int, name string, tokenCode string) (*uModel.APIToken, error) {
+ token := &uModel.APIToken{
+ UserID: userID,
+ Name: name,
+ Token: tokenCode,
+ CreatedAt: time.Now().UTC(),
+ }
+ if err := r.db.WithContext(c).Model(&uModel.APIToken{}).Save(
+ token).Error; err != nil {
+ return nil, err
+
+ }
+ return token, nil
+}
+
+func (r *UserRepository) GetUserByToken(c context.Context, token string) (*uModel.User, error) {
+ var user *uModel.User
+ if err := r.db.WithContext(c).Table("users u").Select("u.*").Joins("left join api_tokens at on at.user_id = u.id").Where("at.token = ?", token).First(&user).Error; err != nil {
+ return nil, err
+ }
+ return user, nil
+}
+
+func (r *UserRepository) GetAllUserTokens(c context.Context, userID int) ([]*uModel.APIToken, error) {
+ var tokens []*uModel.APIToken
+ if err := r.db.WithContext(c).Where("user_id = ?", userID).Find(&tokens).Error; err != nil {
+ return nil, err
+ }
+ return tokens, nil
+}
+
+func (r *UserRepository) DeleteAPIToken(c context.Context, userID int, tokenID string) error {
+ return r.db.WithContext(c).Where("id = ? AND user_id = ?", tokenID, userID).Delete(&uModel.APIToken{}).Error
+}
diff --git a/internal/utils/key_generator.go b/internal/utils/key_generator.go
new file mode 100644
index 0000000..1f88be9
--- /dev/null
+++ b/internal/utils/key_generator.go
@@ -0,0 +1,28 @@
+package utils
+
+import (
+ "encoding/base64"
+
+ crand "crypto/rand"
+
+ "donetick.com/core/logging"
+ "github.com/gin-gonic/gin"
+)
+
+func GenerateInviteCode(c *gin.Context) string {
+ logger := logging.FromContext(c)
+ // Define the length of the token (in bytes). For example, 32 bytes will result in a 44-character base64-encoded token.
+ tokenLength := 12
+
+ // Generate a random byte slice.
+ tokenBytes := make([]byte, tokenLength)
+ _, err := crand.Read(tokenBytes)
+ if err != nil {
+ logger.Errorw("utility.GenerateEmailResetToken failed to generate random bytes", "err", err)
+ }
+
+ // Encode the byte slice to a base64 string.
+ token := base64.URLEncoding.EncodeToString(tokenBytes)
+
+ return token
+}
diff --git a/internal/utils/middleware.go b/internal/utils/middleware.go
new file mode 100644
index 0000000..f31184b
--- /dev/null
+++ b/internal/utils/middleware.go
@@ -0,0 +1,72 @@
+package utils
+
+import (
+ "context"
+ "net/http"
+ "strconv"
+ "time"
+
+ "donetick.com/core/config"
+ "github.com/gin-gonic/gin"
+ "github.com/ulule/limiter/v3"
+ "github.com/ulule/limiter/v3/drivers/store/memory"
+)
+
+const (
+ XRequestIdKey = "X-Request-ID" // request id header key
+)
+
+func NewRateLimiter(cfg *config.Config) *limiter.Limiter {
+
+ store := memory.NewStore()
+
+ // rate, err := limiter.NewRateFromFormatted("10-H")
+ rate := limiter.Rate{
+ Period: cfg.Server.RatePeriod,
+ Limit: int64(cfg.Server.RateLimit),
+ }
+
+ // Then, create the limiter instance which takes the store and the rate as arguments.
+ // Now, you can give this instance to any supported middleware.
+ return limiter.New(store, rate)
+
+}
+
+// wrapper ratelimiter and have it as a middkewatr function:
+func RateLimitMiddleware(limiter *limiter.Limiter) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Use the IP as the key, which is the client IP.
+ // And set the expiration time to 10 seconds.
+ context, err := limiter.Get(c.Request.Context(), c.ClientIP())
+ if err != nil {
+ panic(err) // perhaps handle this nicer
+ }
+ // Check if the client is ratelimited.
+ if context.Reached {
+ c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"message": "Too many requests"})
+ return
+ }
+ // Add a header in response to inform the current quota.
+ c.Header("X-RateLimit-Limit", strconv.FormatInt(context.Limit, 10))
+ // Add a header in response to inform the remaining quota.
+ c.Header("X-RateLimit-Remaining", strconv.FormatInt(context.Remaining, 10))
+ // Add a header in response to inform the time to wait before retry.
+ c.Header("X-RateLimit-Reset", strconv.FormatInt(context.Reset, 10))
+ c.Next()
+ }
+}
+
+func TimeoutMiddleware(timeout time.Duration) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ ctx, cancel := context.WithTimeout(c.Request.Context(), timeout)
+
+ defer func() {
+ if ctx.Err() == context.DeadlineExceeded {
+ c.AbortWithStatus(http.StatusGatewayTimeout)
+ }
+ cancel()
+ }()
+ c.Request = c.Request.WithContext(ctx)
+ c.Next()
+ }
+}