diff options
author | Mo Tarbin <mhed.t91@gmail.com> | 2024-06-30 21:41:41 -0400 |
---|---|---|
committer | Mo Tarbin <mhed.t91@gmail.com> | 2024-06-30 21:41:41 -0400 |
commit | c13dd9addbf89f716e4ef5cfdf1d673139ffcb68 (patch) | |
tree | bc09646ce1d6d3a402abb4694e19da51b57204f6 /internal | |
download | donetick-c13dd9addbf89f716e4ef5cfdf1d673139ffcb68.tar.gz donetick-c13dd9addbf89f716e4ef5cfdf1d673139ffcb68.tar.bz2 donetick-c13dd9addbf89f716e4ef5cfdf1d673139ffcb68.zip |
Move to Donetick Org, first commit
Diffstat (limited to '')
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&family=Albert+Sans:wght@800&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;"> </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;"> </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;"> </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;"> </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;"> </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;"> </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;"> </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&family=Albert+Sans:wght@800&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;"> </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;"> </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;"> </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;"> </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;"> </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;"> </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;"> </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(¬ifications).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(¬ifications).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() + } +} |