Move to Donetick Org, first commit

This commit is contained in:
Mo Tarbin 2024-06-30 21:41:41 -04:00
commit c13dd9addb
42 changed files with 7463 additions and 0 deletions

View file

@ -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,
})
}

View file

@ -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
}

974
internal/chore/handler.go Normal file
View file

@ -0,0 +1,974 @@
package chore
import (
"encoding/json"
"fmt"
"html"
"log"
"math"
"math/rand"
"strconv"
"strings"
"time"
auth "donetick.com/core/internal/authorization"
chModel "donetick.com/core/internal/chore/model"
chRepo "donetick.com/core/internal/chore/repo"
cRepo "donetick.com/core/internal/circle/repo"
nRepo "donetick.com/core/internal/notifier/repo"
nps "donetick.com/core/internal/notifier/service"
telegram "donetick.com/core/internal/notifier/telegram"
tRepo "donetick.com/core/internal/thing/repo"
uModel "donetick.com/core/internal/user/model"
"donetick.com/core/logging"
jwt "github.com/appleboy/gin-jwt/v2"
"github.com/gin-gonic/gin"
)
type ThingTrigger struct {
ID int `json:"thingID" binding:"required"`
TriggerState string `json:"triggerState" binding:"required"`
Condition string `json:"condition"`
}
type ChoreReq struct {
Name string `json:"name" binding:"required"`
FrequencyType string `json:"frequencyType"`
ID int `json:"id"`
DueDate string `json:"dueDate"`
Assignees []chModel.ChoreAssignees `json:"assignees"`
AssignStrategy string `json:"assignStrategy" binding:"required"`
AssignedTo int `json:"assignedTo"`
IsRolling bool `json:"isRolling"`
IsActive bool `json:"isActive"`
Frequency int `json:"frequency"`
FrequencyMetadata *chModel.FrequencyMetadata `json:"frequencyMetadata"`
Notification bool `json:"notification"`
NotificationMetadata *chModel.NotificationMetadata `json:"notificationMetadata"`
Labels []string `json:"labels"`
ThingTrigger *ThingTrigger `json:"thingTrigger"`
}
type Handler struct {
choreRepo *chRepo.ChoreRepository
circleRepo *cRepo.CircleRepository
notifier *telegram.TelegramNotifier
nPlanner *nps.NotificationPlanner
nRepo *nRepo.NotificationRepository
tRepo *tRepo.ThingRepository
}
func NewHandler(cr *chRepo.ChoreRepository, circleRepo *cRepo.CircleRepository, nt *telegram.TelegramNotifier,
np *nps.NotificationPlanner, nRepo *nRepo.NotificationRepository, tRepo *tRepo.ThingRepository) *Handler {
return &Handler{
choreRepo: cr,
circleRepo: circleRepo,
notifier: nt,
nPlanner: np,
nRepo: nRepo,
tRepo: tRepo,
}
}
func (h *Handler) getChores(c *gin.Context) {
u, ok := auth.CurrentUser(c)
if !ok {
c.JSON(500, gin.H{
"error": "Error getting current circle",
})
return
}
chores, err := h.choreRepo.GetChores(c, u.CircleID, u.ID)
if err != nil {
c.JSON(500, gin.H{
"error": "Error getting chores",
})
return
}
c.JSON(200, gin.H{
"res": chores,
})
}
func (h *Handler) getChore(c *gin.Context) {
currentUser, ok := auth.CurrentUser(c)
if !ok {
c.JSON(500, gin.H{
"error": "Error getting current user",
})
return
}
rawID := c.Param("id")
id, err := strconv.Atoi(rawID)
if err != nil {
c.JSON(400, gin.H{
"error": "Invalid ID",
})
return
}
chore, err := h.choreRepo.GetChore(c, id)
if err != nil {
c.JSON(500, gin.H{
"error": "Error getting chore",
})
return
}
isAssignee := false
for _, assignee := range chore.Assignees {
if assignee.UserID == currentUser.ID {
isAssignee = true
break
}
}
if currentUser.ID != chore.CreatedBy && !isAssignee {
c.JSON(403, gin.H{
"error": "You are not allowed to view this chore",
})
return
}
c.JSON(200, gin.H{
"res": chore,
})
}
func (h *Handler) createChore(c *gin.Context) {
logger := logging.FromContext(c)
currentUser, ok := auth.CurrentUser(c)
logger.Debug("Create chore", "currentUser", currentUser)
if !ok {
c.JSON(500, gin.H{
"error": "Error getting current user",
})
return
}
// Validate chore:
var choreReq ChoreReq
if err := c.ShouldBindJSON(&choreReq); err != nil {
log.Print(err)
c.JSON(400, gin.H{
"error": "Invalid request",
})
return
}
circleUsers, err := h.circleRepo.GetCircleUsers(c, currentUser.CircleID)
for _, assignee := range choreReq.Assignees {
userFound := false
for _, circleUser := range circleUsers {
if assignee.UserID == circleUser.UserID {
userFound = true
break
}
}
if !userFound {
c.JSON(400, gin.H{
"error": "Assignee not found in circle",
})
return
}
}
if choreReq.AssignedTo <= 0 && len(choreReq.Assignees) > 0 {
// if the assigned to field is not set, randomly assign the chore to one of the assignees
choreReq.AssignedTo = choreReq.Assignees[rand.Intn(len(choreReq.Assignees))].UserID
}
var dueDate *time.Time
if choreReq.DueDate != "" {
rawDueDate, err := time.Parse(time.RFC3339, choreReq.DueDate)
rawDueDate = rawDueDate.UTC()
dueDate = &rawDueDate
if err != nil {
c.JSON(400, gin.H{
"error": "Invalid date",
})
return
}
} else {
c.JSON(400, gin.H{
"error": "Due date is required",
})
return
}
freqencyMetadataBytes, err := json.Marshal(choreReq.FrequencyMetadata)
if err != nil {
c.JSON(500, gin.H{
"error": "Error marshalling frequency metadata",
})
return
}
stringFrequencyMetadata := string(freqencyMetadataBytes)
notificationMetadataBytes, err := json.Marshal(choreReq.NotificationMetadata)
if err != nil {
c.JSON(500, gin.H{
"error": "Error marshalling notification metadata",
})
return
}
stringNotificationMetadata := string(notificationMetadataBytes)
var stringLabels *string
if len(choreReq.Labels) > 0 {
var escapedLabels []string
for _, label := range choreReq.Labels {
escapedLabels = append(escapedLabels, html.EscapeString(label))
}
labels := strings.Join(escapedLabels, ",")
stringLabels = &labels
}
createdChore := &chModel.Chore{
Name: choreReq.Name,
FrequencyType: choreReq.FrequencyType,
Frequency: choreReq.Frequency,
FrequencyMetadata: &stringFrequencyMetadata,
NextDueDate: dueDate,
AssignStrategy: choreReq.AssignStrategy,
AssignedTo: choreReq.AssignedTo,
IsRolling: choreReq.IsRolling,
UpdatedBy: currentUser.ID,
IsActive: true,
Notification: choreReq.Notification,
NotificationMetadata: &stringNotificationMetadata,
Labels: stringLabels,
CreatedBy: currentUser.ID,
CreatedAt: time.Now().UTC(),
CircleID: currentUser.CircleID,
}
id, err := h.choreRepo.CreateChore(c, createdChore)
createdChore.ID = id
if err != nil {
c.JSON(500, gin.H{
"error": "Error creating chore",
})
return
}
var choreAssignees []*chModel.ChoreAssignees
for _, assignee := range choreReq.Assignees {
choreAssignees = append(choreAssignees, &chModel.ChoreAssignees{
ChoreID: id,
UserID: assignee.UserID,
})
}
if err := h.choreRepo.UpdateChoreAssignees(c, choreAssignees); err != nil {
c.JSON(500, gin.H{
"error": "Error adding chore assignees",
})
return
}
go func() {
h.nPlanner.GenerateNotifications(c, createdChore)
}()
shouldReturn := HandleThingAssociation(choreReq, h, c, currentUser)
if shouldReturn {
return
}
c.JSON(200, gin.H{
"res": id,
})
}
func (h *Handler) editChore(c *gin.Context) {
// logger := logging.FromContext(c)
currentUser, ok := auth.CurrentUser(c)
if !ok {
c.JSON(500, gin.H{
"error": "Error getting current user",
})
return
}
var choreReq ChoreReq
if err := c.ShouldBindJSON(&choreReq); err != nil {
log.Print(err)
c.JSON(400, gin.H{
"error": "Invalid request",
})
return
}
circleUsers, err := h.circleRepo.GetCircleUsers(c, currentUser.CircleID)
if err != nil {
c.JSON(500, gin.H{
"error": "Error getting circle users",
})
return
}
existedChoreAssignees, err := h.choreRepo.GetChoreAssignees(c, choreReq.ID)
if err != nil {
c.JSON(500, gin.H{
"error": "Error getting chore assignees",
})
return
}
var choreAssigneesToAdd []*chModel.ChoreAssignees
var choreAssigneesToDelete []*chModel.ChoreAssignees
// filter assignees that not in the circle
for _, assignee := range choreReq.Assignees {
userFound := false
for _, circleUser := range circleUsers {
if assignee.UserID == circleUser.UserID {
userFound = true
break
}
}
if !userFound {
c.JSON(400, gin.H{
"error": "Assignee not found in circle",
})
return
}
userAlreadyAssignee := false
for _, existedChoreAssignee := range existedChoreAssignees {
if existedChoreAssignee.UserID == assignee.UserID {
userAlreadyAssignee = true
break
}
}
if !userAlreadyAssignee {
choreAssigneesToAdd = append(choreAssigneesToAdd, &chModel.ChoreAssignees{
ChoreID: choreReq.ID,
UserID: assignee.UserID,
})
}
}
// remove assignees if they are not in the assignees list anymore
for _, existedChoreAssignee := range existedChoreAssignees {
userFound := false
for _, assignee := range choreReq.Assignees {
if existedChoreAssignee.UserID == assignee.UserID {
userFound = true
break
}
}
if !userFound {
choreAssigneesToDelete = append(choreAssigneesToDelete, existedChoreAssignee)
}
}
var dueDate *time.Time
if choreReq.DueDate != "" {
rawDueDate, err := time.Parse(time.RFC3339, choreReq.DueDate)
rawDueDate = rawDueDate.UTC()
dueDate = &rawDueDate
if err != nil {
c.JSON(400, gin.H{
"error": "Invalid date",
})
return
}
}
// validate assignedTo part of the assignees:
assigneeFound := false
for _, assignee := range choreReq.Assignees {
if assignee.UserID == choreReq.AssignedTo {
assigneeFound = true
break
}
}
if !assigneeFound {
c.JSON(400, gin.H{
"error": "Assigned to not found in assignees",
})
return
}
if choreReq.AssignedTo <= 0 && len(choreReq.Assignees) > 0 {
// if the assigned to field is not set, randomly assign the chore to one of the assignees
choreReq.AssignedTo = choreReq.Assignees[rand.Intn(len(choreReq.Assignees))].UserID
}
oldChore, err := h.choreRepo.GetChore(c, choreReq.ID)
if err != nil {
c.JSON(500, gin.H{
"error": "Error getting chore",
})
return
}
if currentUser.ID != oldChore.CreatedBy {
c.JSON(403, gin.H{
"error": "You are not allowed to edit this chore",
})
return
}
freqencyMetadataBytes, err := json.Marshal(choreReq.FrequencyMetadata)
if err != nil {
c.JSON(500, gin.H{
"error": "Error marshalling frequency metadata",
})
return
}
stringFrequencyMetadata := string(freqencyMetadataBytes)
notificationMetadataBytes, err := json.Marshal(choreReq.NotificationMetadata)
if err != nil {
c.JSON(500, gin.H{
"error": "Error marshalling notification metadata",
})
return
}
stringNotificationMetadata := string(notificationMetadataBytes)
// escape special characters in labels and store them as a string :
var stringLabels *string
if len(choreReq.Labels) > 0 {
var escapedLabels []string
for _, label := range choreReq.Labels {
escapedLabels = append(escapedLabels, html.EscapeString(label))
}
labels := strings.Join(escapedLabels, ",")
stringLabels = &labels
}
updatedChore := &chModel.Chore{
ID: choreReq.ID,
Name: choreReq.Name,
FrequencyType: choreReq.FrequencyType,
Frequency: choreReq.Frequency,
FrequencyMetadata: &stringFrequencyMetadata,
// Assignees: &assignees,
NextDueDate: dueDate,
AssignStrategy: choreReq.AssignStrategy,
AssignedTo: choreReq.AssignedTo,
IsRolling: choreReq.IsRolling,
IsActive: choreReq.IsActive,
Notification: choreReq.Notification,
NotificationMetadata: &stringNotificationMetadata,
Labels: stringLabels,
CircleID: oldChore.CircleID,
UpdatedBy: currentUser.ID,
CreatedBy: oldChore.CreatedBy,
CreatedAt: oldChore.CreatedAt,
}
if err := h.choreRepo.UpsertChore(c, updatedChore); err != nil {
c.JSON(500, gin.H{
"error": "Error adding chore",
})
return
}
if len(choreAssigneesToAdd) > 0 {
err = h.choreRepo.UpdateChoreAssignees(c, choreAssigneesToAdd)
if err != nil {
c.JSON(500, gin.H{
"error": "Error updating chore assignees",
})
return
}
}
if len(choreAssigneesToDelete) > 0 {
err = h.choreRepo.DeleteChoreAssignees(c, choreAssigneesToDelete)
if err != nil {
c.JSON(500, gin.H{
"error": "Error deleting chore assignees",
})
return
}
}
go func() {
h.nPlanner.GenerateNotifications(c, updatedChore)
}()
if oldChore.ThingChore.ThingID != 0 {
// TODO: Add check to see if dissociation is necessary
h.tRepo.DissociateThingWithChore(c, oldChore.ThingChore.ThingID, oldChore.ID)
}
shouldReturn := HandleThingAssociation(choreReq, h, c, currentUser)
if shouldReturn {
return
}
c.JSON(200, gin.H{
"message": "Chore added successfully",
})
}
func HandleThingAssociation(choreReq ChoreReq, h *Handler, c *gin.Context, currentUser *uModel.User) bool {
if choreReq.ThingTrigger != nil {
thing, err := h.tRepo.GetThingByID(c, choreReq.ThingTrigger.ID)
if err != nil {
c.JSON(500, gin.H{
"error": "Error getting thing",
})
return true
}
if thing.UserID != currentUser.ID {
c.JSON(403, gin.H{
"error": "You are not allowed to trigger this thing",
})
return true
}
if err := h.tRepo.AssociateThingWithChore(c, choreReq.ThingTrigger.ID, choreReq.ID, choreReq.ThingTrigger.TriggerState, choreReq.ThingTrigger.Condition); err != nil {
c.JSON(500, gin.H{
"error": "Error associating thing with chore",
})
return true
}
}
return false
}
func (h *Handler) deleteChore(c *gin.Context) {
// logger := logging.FromContext(c)
currentUser, ok := auth.CurrentUser(c)
if !ok {
c.JSON(500, gin.H{
"error": "Error getting current user",
})
return
}
rawID := c.Param("id")
id, err := strconv.Atoi(rawID)
if err != nil {
c.JSON(400, gin.H{
"error": "Invalid ID",
})
return
}
// check if the user is the owner of the chore before deleting
if err := h.choreRepo.IsChoreOwner(c, id, currentUser.ID); err != nil {
c.JSON(403, gin.H{
"error": "You are not allowed to delete this chore",
})
return
}
if err := h.choreRepo.DeleteChore(c, id); err != nil {
c.JSON(500, gin.H{
"error": "Error deleting chore",
})
return
}
h.nRepo.DeleteAllChoreNotifications(id)
c.JSON(200, gin.H{
"message": "Chore deleted successfully",
})
}
// func (h *Handler) createChore(c *gin.Context) {
// logger := logging.FromContext(c)
// currentUser, ok := auth.CurrentUser(c)
// logger.Debug("Create chore", "currentUser", currentUser)
// if !ok {
// c.JSON(500, gin.H{
// "error": "Error getting current user",
// })
// return
// }
// id, err := h.choreRepo.CreateChore(currentUser.ID, currentUser.CircleID)
// if err != nil {
// c.JSON(500, gin.H{
// "error": "Error creating chore",
// })
// return
// }
// c.JSON(200, gin.H{
// "res": id,
// })
// }
func (h *Handler) updateAssignee(c *gin.Context) {
rawID := c.Param("id")
id, err := strconv.Atoi(rawID)
if err != nil {
c.JSON(400, gin.H{
"error": "Invalid ID",
})
return
}
type AssigneeReq struct {
AssignedTo int `json:"assignedTo" binding:"required"`
UpdatedBy int `json:"updatedBy" binding:"required"`
}
var assigneeReq AssigneeReq
if err := c.ShouldBindJSON(&assigneeReq); err != nil {
log.Print(err)
c.JSON(400, gin.H{
"error": "Invalid request",
})
return
}
chore, err := h.choreRepo.GetChore(c, id)
if err != nil {
c.JSON(500, gin.H{
"error": "Error getting chore",
})
return
}
// confirm that the assignee is one of the assignees:
assigneeFound := false
for _, assignee := range chore.Assignees {
if assignee.UserID == assigneeReq.AssignedTo {
assigneeFound = true
break
}
}
if !assigneeFound {
c.JSON(400, gin.H{
"error": "Assignee not found in assignees",
})
return
}
chore.UpdatedBy = assigneeReq.UpdatedBy
chore.AssignedTo = assigneeReq.AssignedTo
if err := h.choreRepo.UpsertChore(c, chore); err != nil {
c.JSON(500, gin.H{
"error": "Error updating assignee",
})
return
}
c.JSON(200, gin.H{
"res": chore,
})
}
func (h *Handler) skipChore(c *gin.Context) {
rawID := c.Param("id")
id, err := strconv.Atoi(rawID)
if err != nil {
c.JSON(400, gin.H{
"error": "Invalid ID",
})
return
}
chore, err := h.choreRepo.GetChore(c, id)
if err != nil {
c.JSON(500, gin.H{
"error": "Error getting chore",
})
return
}
newDueDate, err := scheduleNextDueDate(chore, chore.NextDueDate.UTC())
if err != nil {
c.JSON(500, gin.H{
"error": "Error scheduling next due date",
})
return
}
chore.NextDueDate = newDueDate
if err := h.choreRepo.UpsertChore(c, chore); err != nil {
c.JSON(500, gin.H{
"error": "Error skipping chore",
})
return
}
if err := h.choreRepo.UpsertChore(c, chore); err != nil {
c.JSON(500, gin.H{
"error": "Error skipping chore",
})
return
}
c.JSON(200, gin.H{
"res": chore,
})
}
func (h *Handler) updateDueDate(c *gin.Context) {
currentUser, ok := auth.CurrentUser(c)
if !ok {
c.JSON(500, gin.H{
"error": "Error getting current user",
})
return
}
type DueDateReq struct {
DueDate string `json:"dueDate" binding:"required"`
}
var dueDateReq DueDateReq
if err := c.ShouldBindJSON(&dueDateReq); err != nil {
log.Print(err)
c.JSON(400, gin.H{
"error": "Invalid request",
})
return
}
rawID := c.Param("id")
id, err := strconv.Atoi(rawID)
if err != nil {
c.JSON(400, gin.H{
"error": "Invalid ID",
})
return
}
rawDueDate, err := time.Parse(time.RFC3339, dueDateReq.DueDate)
if err != nil {
c.JSON(400, gin.H{
"error": "Invalid date",
})
return
}
dueDate := rawDueDate.UTC()
chore, err := h.choreRepo.GetChore(c, id)
if err != nil {
c.JSON(500, gin.H{
"error": "Error getting chore",
})
return
}
chore.NextDueDate = &dueDate
chore.UpdatedBy = currentUser.ID
if err := h.choreRepo.UpsertChore(c, chore); err != nil {
c.JSON(500, gin.H{
"error": "Error updating due date",
})
return
}
c.JSON(200, gin.H{
"res": chore,
})
}
func (h *Handler) completeChore(c *gin.Context) {
type CompleteChoreReq struct {
Note string `json:"note"`
}
var req CompleteChoreReq
currentUser, ok := auth.CurrentUser(c)
if !ok {
c.JSON(500, gin.H{
"error": "Error getting current user",
})
return
}
completeChoreID := c.Param("id")
var completedDate time.Time
rawCompletedDate := c.Query("completedDate")
if rawCompletedDate == "" {
completedDate = time.Now().UTC()
} else {
var err error
completedDate, err = time.Parse(time.RFC3339, rawCompletedDate)
if err != nil {
c.JSON(400, gin.H{
"error": "Invalid date",
})
return
}
}
var additionalNotes *string
_ = c.ShouldBind(&req)
if req.Note != "" {
additionalNotes = &req.Note
}
id, err := strconv.Atoi(completeChoreID)
if err != nil {
c.JSON(400, gin.H{
"error": "Invalid ID",
})
return
}
chore, err := h.choreRepo.GetChore(c, id)
if err != nil {
c.JSON(500, gin.H{
"error": "Error getting chore",
})
return
}
nextDueDate, err := scheduleNextDueDate(chore, completedDate)
if err != nil {
log.Printf("Error scheduling next due date: %s", err)
c.JSON(500, gin.H{
"error": "Error scheduling next due date",
})
return
}
choreHistory, err := h.choreRepo.GetChoreHistory(c, chore.ID)
if err != nil {
c.JSON(500, gin.H{
"error": "Error getting chore history",
})
return
}
nextAssignedTo, err := checkNextAssignee(chore, choreHistory, currentUser.ID)
if err != nil {
log.Printf("Error checking next assignee: %s", err)
c.JSON(500, gin.H{
"error": "Error checking next assignee",
})
return
}
if err := h.choreRepo.CompleteChore(c, chore, additionalNotes, currentUser.ID, nextDueDate, completedDate, nextAssignedTo); err != nil {
c.JSON(500, gin.H{
"error": "Error completing chore",
})
return
}
updatedChore, err := h.choreRepo.GetChore(c, id)
if err != nil {
c.JSON(500, gin.H{
"error": "Error getting chore",
})
return
}
go func() {
h.notifier.SendChoreCompletion(c, chore, []*uModel.User{currentUser})
h.nPlanner.GenerateNotifications(c, updatedChore)
}()
c.JSON(200, gin.H{
"res": updatedChore,
})
}
func (h *Handler) GetChoreHistory(c *gin.Context) {
rawID := c.Param("id")
id, err := strconv.Atoi(rawID)
if err != nil {
c.JSON(400, gin.H{
"error": "Invalid ID",
})
return
}
choreHistory, err := h.choreRepo.GetChoreHistory(c, id)
if err != nil {
c.JSON(500, gin.H{
"error": "Error getting chore history",
})
return
}
c.JSON(200, gin.H{
"res": choreHistory,
})
}
func checkNextAssignee(chore *chModel.Chore, choresHistory []*chModel.ChoreHistory, performerID int) (int, error) {
// copy the history to avoid modifying the original:
history := make([]*chModel.ChoreHistory, len(choresHistory))
copy(history, choresHistory)
assigneesMap := map[int]bool{}
for _, assignee := range chore.Assignees {
assigneesMap[assignee.UserID] = true
}
var nextAssignee int
if len(history) == 0 {
// if there is no history, just assume the current operation as the first
history = append(history, &chModel.ChoreHistory{
AssignedTo: performerID,
})
}
switch chore.AssignStrategy {
case "least_assigned":
// find the assignee with the least number of chores
assigneeChores := map[int]int{}
for _, performer := range chore.Assignees {
assigneeChores[performer.UserID] = 0
}
for _, history := range history {
if ok := assigneesMap[history.AssignedTo]; !ok {
// calculate the number of chores assigned to each assignee
assigneeChores[history.AssignedTo]++
}
}
minChores := math.MaxInt64
for assignee, numChores := range assigneeChores {
// if this is the first assignee or if the number of
// chores assigned to this assignee is less than the current minimum
if numChores < minChores {
minChores = numChores
// set the next assignee to this assignee
nextAssignee = assignee
}
}
case "least_completed":
// find the assignee who has completed the least number of chores
assigneeChores := map[int]int{}
for _, performer := range chore.Assignees {
assigneeChores[performer.UserID] = 0
}
for _, history := range history {
// calculate the number of chores completed by each assignee
assigneeChores[history.CompletedBy]++
}
// max Int value
minChores := math.MaxInt64
for assignee, numChores := range assigneeChores {
// if this is the first assignee or if the number of
// chores completed by this assignee is less than the current minimum
if numChores < minChores {
minChores = numChores
// set the next assignee to this assignee
nextAssignee = assignee
}
}
case "random":
nextAssignee = chore.Assignees[rand.Intn(len(chore.Assignees))].UserID
case "keep_last_assigned":
// keep the last assignee
nextAssignee = history[len(history)-1].AssignedTo
default:
return chore.AssignedTo, fmt.Errorf("invalid assign strategy")
}
return nextAssignee, nil
}
func Routes(router *gin.Engine, h *Handler, auth *jwt.GinJWTMiddleware) {
choresRoutes := router.Group("chores")
choresRoutes.Use(auth.MiddlewareFunc())
{
choresRoutes.GET("/", h.getChores)
choresRoutes.PUT("/", h.editChore)
choresRoutes.POST("/", h.createChore)
choresRoutes.GET("/:id", h.getChore)
choresRoutes.GET("/:id/history", h.GetChoreHistory)
choresRoutes.POST("/:id/do", h.completeChore)
choresRoutes.POST("/:id/skip", h.skipChore)
choresRoutes.PUT("/:id/assignee", h.updateAssignee)
choresRoutes.PUT("/:id/dueDate", h.updateDueDate)
choresRoutes.DELETE("/:id", h.deleteChore)
}
}

View file

@ -0,0 +1,72 @@
package model
import (
"time"
tModel "donetick.com/core/internal/thing/model"
)
type Chore struct {
ID int `json:"id" gorm:"primary_key"`
Name string `json:"name" gorm:"column:name"` // Chore description
FrequencyType string `json:"frequencyType" gorm:"column:frequency_type"` // "daily", "weekly", "monthly", "yearly", "adaptive",or "custom"
Frequency int `json:"frequency" gorm:"column:frequency"` // Number of days, weeks, months, or years between chores
FrequencyMetadata *string `json:"frequencyMetadata" gorm:"column:frequency_meta"` // Additional frequency information
NextDueDate *time.Time `json:"nextDueDate" gorm:"column:next_due_date;index"` // When the chore is due
IsRolling bool `json:"isRolling" gorm:"column:is_rolling"` // Whether the chore is rolling
AssignedTo int `json:"assignedTo" gorm:"column:assigned_to"` // Who the chore is assigned to
Assignees []ChoreAssignees `json:"assignees" gorm:"foreignkey:ChoreID;references:ID"` // Assignees of the chore
AssignStrategy string `json:"assignStrategy" gorm:"column:assign_strategy"` // How the chore is assigned
IsActive bool `json:"isActive" gorm:"column:is_active"` // Whether the chore is active
Notification bool `json:"notification" gorm:"column:notification"` // Whether the chore has notification
NotificationMetadata *string `json:"notificationMetadata" gorm:"column:notification_meta"` // Additional notification information
Labels *string `json:"labels" gorm:"column:labels"` // Labels for the chore
CircleID int `json:"circleId" gorm:"column:circle_id;index"` // The circle this chore is in
CreatedAt time.Time `json:"createdAt" gorm:"column:created_at"` // When the chore was created
UpdatedAt time.Time `json:"updatedAt" gorm:"column:updated_at"` // When the chore was last updated
CreatedBy int `json:"createdBy" gorm:"column:created_by"` // Who created the chore
UpdatedBy int `json:"updatedBy" gorm:"column:updated_by"` // Who last updated the chore
ThingChore tModel.ThingChore `json:"thingChore" gorm:"foreignkey:chore_id;references:id;<-:false"` // ThingChore relationship
}
type ChoreAssignees struct {
ID int `json:"-" gorm:"primary_key"`
ChoreID int `json:"-" gorm:"column:chore_id;uniqueIndex:idx_chore_user"` // The chore this assignee is for
UserID int `json:"userId" gorm:"column:user_id;uniqueIndex:idx_chore_user"` // The user this assignee is for
}
type ChoreHistory struct {
ID int `json:"id" gorm:"primary_key"` // Unique identifier
ChoreID int `json:"choreId" gorm:"column:chore_id"` // The chore this history is for
CompletedAt time.Time `json:"completedAt" gorm:"column:completed_at"` // When the chore was completed
CompletedBy int `json:"completedBy" gorm:"column:completed_by"` // Who completed the chore
AssignedTo int `json:"assignedTo" gorm:"column:assigned_to"` // Who the chore was assigned to
Note *string `json:"notes" gorm:"column:notes"` // Notes about the chore
DueDate *time.Time `json:"dueDate" gorm:"column:due_date"` // When the chore was due
}
type FrequencyMetadata struct {
Days []*string `json:"days,omitempty"`
Months []*string `json:"months,omitempty"`
Unit *string `json:"unit,omitempty"`
}
type NotificationMetadata struct {
DueDate bool `json:"dueDate,omitempty"`
Completion bool `json:"completion,omitempty"`
Nagging bool `json:"nagging,omitempty"`
PreDue bool `json:"predue,omitempty"`
}
type Tag struct {
ID int `json:"-" gorm:"primary_key"`
Name string `json:"name" gorm:"column:name;unique"`
}
// type ChoreTag struct {
// ChoreID int `json:"choreId" gorm:"primaryKey;autoIncrement:false"`
// TagID int `json:"tagId" gorm:"primaryKey;autoIncrement:false"`
// }
// type CircleTag struct {
// CircleID int `json:"circleId" gorm:"primaryKey;autoIncrement:false"`
// TagID int `json:"tagId" gorm:"primaryKey;autoIncrement:false"`
// }

View file

@ -0,0 +1,216 @@
package chore
import (
"context"
"fmt"
"time"
config "donetick.com/core/config"
chModel "donetick.com/core/internal/chore/model"
"gorm.io/gorm"
)
type ChoreRepository struct {
db *gorm.DB
dbType string
}
func NewChoreRepository(db *gorm.DB, cfg *config.Config) *ChoreRepository {
return &ChoreRepository{db: db, dbType: cfg.Database.Type}
}
func (r *ChoreRepository) UpsertChore(c context.Context, chore *chModel.Chore) error {
return r.db.WithContext(c).Model(&chore).Save(chore).Error
}
func (r *ChoreRepository) UpdateChores(c context.Context, chores []*chModel.Chore) error {
return r.db.WithContext(c).Save(&chores).Error
}
func (r *ChoreRepository) CreateChore(c context.Context, chore *chModel.Chore) (int, error) {
if err := r.db.WithContext(c).Create(chore).Error; err != nil {
return 0, err
}
return chore.ID, nil
}
func (r *ChoreRepository) GetChore(c context.Context, choreID int) (*chModel.Chore, error) {
var chore chModel.Chore
if err := r.db.Debug().WithContext(c).Model(&chModel.Chore{}).Preload("Assignees").Preload("ThingChore").First(&chore, choreID).Error; err != nil {
return nil, err
}
return &chore, nil
}
func (r *ChoreRepository) GetChores(c context.Context, circleID int, userID int) ([]*chModel.Chore, error) {
var chores []*chModel.Chore
// if err := r.db.WithContext(c).Preload("Assignees").Where("is_active = ?", true).Order("next_due_date asc").Find(&chores, "circle_id = ?", circleID).Error; err != nil {
if err := r.db.WithContext(c).Preload("Assignees").Joins("left join chore_assignees on chores.id = chore_assignees.chore_id").Where("chores.circle_id = ? AND (chores.created_by = ? OR chore_assignees.user_id = ?)", circleID, userID, userID).Group("chores.id").Order("next_due_date asc").Find(&chores, "circle_id = ?", circleID).Error; err != nil {
return nil, err
}
return chores, nil
}
func (r *ChoreRepository) DeleteChore(c context.Context, id int) error {
r.db.WithContext(c).Where("chore_id = ?", id).Delete(&chModel.ChoreAssignees{})
return r.db.WithContext(c).Delete(&chModel.Chore{}, id).Error
}
func (r *ChoreRepository) SoftDelete(c context.Context, id int, userID int) error {
return r.db.WithContext(c).Model(&chModel.Chore{}).Where("id = ?", id).Where("created_by = ? ", userID).Update("is_active", false).Error
}
func (r *ChoreRepository) IsChoreOwner(c context.Context, choreID int, userID int) error {
var chore chModel.Chore
err := r.db.WithContext(c).Model(&chModel.Chore{}).Where("id = ? AND created_by = ?", choreID, userID).First(&chore).Error
return err
}
// func (r *ChoreRepository) ListChores(circleID int) ([]*chModel.Chore, error) {
// var chores []*Chore
// if err := r.db.WithContext(c).Find(&chores).Where("is_active = ?", true).Order("next_due_date").Error; err != nil {
// return nil, err
// }
// return chores, nil
// }
func (r *ChoreRepository) CompleteChore(c context.Context, chore *chModel.Chore, note *string, userID int, dueDate *time.Time, completedDate time.Time, nextAssignedTo int) error {
err := r.db.WithContext(c).Transaction(func(tx *gorm.DB) error {
ch := &chModel.ChoreHistory{
ChoreID: chore.ID,
CompletedAt: completedDate,
CompletedBy: userID,
AssignedTo: chore.AssignedTo,
DueDate: chore.NextDueDate,
Note: note,
}
if err := tx.Create(ch).Error; err != nil {
return err
}
updates := map[string]interface{}{}
updates["next_due_date"] = dueDate
if dueDate != nil {
updates["assigned_to"] = nextAssignedTo
} else {
updates["is_active"] = false
}
// Perform the update operation once, using the prepared updates map.
if err := tx.Model(&chModel.Chore{}).Where("id = ?", chore.ID).Updates(updates).Error; err != nil {
return err
}
return nil
})
return err
}
func (r *ChoreRepository) GetChoreHistory(c context.Context, choreID int) ([]*chModel.ChoreHistory, error) {
var histories []*chModel.ChoreHistory
if err := r.db.WithContext(c).Where("chore_id = ?", choreID).Order("completed_at desc").Find(&histories).Error; err != nil {
return nil, err
}
return histories, nil
}
func (r *ChoreRepository) UpdateChoreAssignees(c context.Context, assignees []*chModel.ChoreAssignees) error {
return r.db.WithContext(c).Save(&assignees).Error
}
func (r *ChoreRepository) DeleteChoreAssignees(c context.Context, choreAssignees []*chModel.ChoreAssignees) error {
return r.db.WithContext(c).Delete(&choreAssignees).Error
}
func (r *ChoreRepository) GetChoreAssignees(c context.Context, choreID int) ([]*chModel.ChoreAssignees, error) {
var assignees []*chModel.ChoreAssignees
if err := r.db.WithContext(c).Find(&assignees, "chore_id = ?", choreID).Error; err != nil {
return nil, err
}
return assignees, nil
}
func (r *ChoreRepository) RemoveChoreAssigneeByCircleID(c context.Context, userID int, circleID int) error {
return r.db.WithContext(c).Where("user_id = ? AND chore_id IN (SELECT id FROM chores WHERE circle_id = ? and created_by != ?)", userID, circleID, userID).Delete(&chModel.ChoreAssignees{}).Error
}
// func (r *ChoreRepository) getChoreDueToday(circleID int) ([]*chModel.Chore, error) {
// var chores []*Chore
// if err := r.db.WithContext(c).Where("next_due_date <= ?", time.Now().UTC()).Find(&chores).Error; err != nil {
// return nil, err
// }
// return chores, nil
// }
func (r *ChoreRepository) GetAllActiveChores(c context.Context) ([]*chModel.Chore, error) {
var chores []*chModel.Chore
// query := r.db.WithContext(c).Table("chores").Joins("left join notifications n on n.chore_id = chores.id and n.scheduled_for < chores.next_due_date")
// if err := query.Where("chores.is_active = ? and chores.notification = ? and (n.is_sent = ? or n.is_sent is null)", true, true, false).Find(&chores).Error; err != nil {
// return nil, err
// }
return chores, nil
}
func (r *ChoreRepository) GetChoresForNotification(c context.Context) ([]*chModel.Chore, error) {
var chores []*chModel.Chore
query := r.db.WithContext(c).Table("chores").Joins("left join notifications n on n.chore_id = chores.id and n.scheduled_for = chores.next_due_date and n.type = 1")
if err := query.Where("chores.is_active = ? and chores.notification = ? and n.id is null", true, true).Find(&chores).Error; err != nil {
return nil, err
}
return chores, nil
}
// func (r *ChoreReposity) GetOverdueChoresForNotification(c context.Context, overdueDuration time.Duration, everyDuration time.Duration, untilDuration time.Duration) ([]*chModel.Chore, error) {
// var chores []*chModel.Chore
// query := r.db.Debug().WithContext(c).Table("chores").Select("chores.*, MAX(n.created_at) as max_notification_created_at").Joins("left join notifications n on n.chore_id = chores.id and n.scheduled_for = chores.next_due_date and n.type = 2")
// if err := query.Where("chores.is_active = ? and chores.notification = ? and chores.next_due_date < ? and chores.next_due_date > ?", true, true, time.Now().Add(overdueDuration).UTC(), time.Now().Add(untilDuration).UTC()).Where(readJSONBooleanField(r.dbType, "chores.notification_meta", "nagging")).Having("MAX(n.created_at) is null or MAX(n.created_at) < ?", time.Now().Add(everyDuration).UTC()).Group("chores.id").Find(&chores).Error; err != nil {
// return nil, err
// }
// return chores, nil
// }
func (r *ChoreRepository) GetOverdueChoresForNotification(c context.Context, overdueFor time.Duration, everyDuration time.Duration, untilDuration time.Duration) ([]*chModel.Chore, error) {
var chores []*chModel.Chore
now := time.Now().UTC()
overdueTime := now.Add(-overdueFor)
everyTime := now.Add(-everyDuration)
untilTime := now.Add(-untilDuration)
query := r.db.Debug().WithContext(c).
Table("chores").
Select("chores.*, MAX(n.created_at) as max_notification_created_at").
Joins("left join notifications n on n.chore_id = chores.id and n.type = 2").
Where("chores.is_active = ? AND chores.notification = ? AND chores.next_due_date < ? AND chores.next_due_date > ?", true, true, overdueTime, untilTime).
Where(readJSONBooleanField(r.dbType, "chores.notification_meta", "nagging")).
Group("chores.id").
Having("MAX(n.created_at) IS NULL OR MAX(n.created_at) < ?", everyTime)
if err := query.Find(&chores).Error; err != nil {
return nil, err
}
return chores, nil
}
// a predue notfication is a notification send before the due date in 6 hours, 3 hours :
func (r *ChoreRepository) GetPreDueChoresForNotification(c context.Context, preDueDuration time.Duration, everyDuration time.Duration) ([]*chModel.Chore, error) {
var chores []*chModel.Chore
query := r.db.WithContext(c).Table("chores").Select("chores.*, MAX(n.created_at) as max_notification_created_at").Joins("left join notifications n on n.chore_id = chores.id and n.scheduled_for = chores.next_due_date and n.type = 3")
if err := query.Where("chores.is_active = ? and chores.notification = ? and chores.next_due_date > ? and chores.next_due_date < ?", true, true, time.Now().UTC(), time.Now().Add(everyDuration*2).UTC()).Where(readJSONBooleanField(r.dbType, "chores.notification_meta", "predue")).Having("MAX(n.created_at) is null or MAX(n.created_at) < ?", time.Now().Add(everyDuration).UTC()).Group("chores.id").Find(&chores).Error; err != nil {
return nil, err
}
return chores, nil
}
func readJSONBooleanField(dbType string, columnName string, fieldName string) string {
if dbType == "postgres" {
return fmt.Sprintf("(%s::json->>'%s')::boolean", columnName, fieldName)
}
return fmt.Sprintf("JSON_EXTRACT(%s, '$.%s')", columnName, fieldName)
}
func (r *ChoreRepository) SetDueDate(c context.Context, choreID int, dueDate time.Time) error {
return r.db.WithContext(c).Model(&chModel.Chore{}).Where("id = ?", choreID).Update("next_due_date", dueDate).Error
}
func (r *ChoreRepository) SetDueDateIfNotExisted(c context.Context, choreID int, dueDate time.Time) error {
return r.db.WithContext(c).Model(&chModel.Chore{}).Where("id = ? and next_due_date is null", choreID).Update("next_due_date", dueDate).Error
}

145
internal/chore/scheduler.go Normal file
View file

@ -0,0 +1,145 @@
package chore
import (
"encoding/json"
"fmt"
"math/rand"
"strings"
"time"
chModel "donetick.com/core/internal/chore/model"
)
func scheduleNextDueDate(chore *chModel.Chore, completedDate time.Time) (*time.Time, error) {
// if Chore is rolling then the next due date calculated from the completed date, otherwise it's calculated from the due date
var nextDueDate time.Time
var baseDate time.Time
var frequencyMetadata chModel.FrequencyMetadata
err := json.Unmarshal([]byte(*chore.FrequencyMetadata), &frequencyMetadata)
if err != nil {
return nil, fmt.Errorf("error unmarshalling frequency metadata")
}
if chore.FrequencyType == "once" {
return nil, nil
}
if chore.NextDueDate != nil {
// no due date set, use the current date
baseDate = chore.NextDueDate.UTC()
} else {
baseDate = completedDate.UTC()
}
if chore.IsRolling && chore.NextDueDate.Before(completedDate) {
// we need to check if chore due date is before the completed date to handle this senario:
// if user trying to complete chore due in future (multiple time for insance) due date will be calculated
// from the last completed date and due date change only in seconds.
// this make sure that the due date is always in future if the chore is rolling
baseDate = completedDate.UTC()
}
if chore.FrequencyType == "daily" {
nextDueDate = baseDate.AddDate(0, 0, 1)
} else if chore.FrequencyType == "weekly" {
nextDueDate = baseDate.AddDate(0, 0, 7)
} else if chore.FrequencyType == "monthly" {
nextDueDate = baseDate.AddDate(0, 1, 0)
} else if chore.FrequencyType == "yearly" {
nextDueDate = baseDate.AddDate(1, 0, 0)
} else if chore.FrequencyType == "adaptive" {
// TODO: calculate next due date based on the history of the chore
// calculate the difference between the due date and now in days:
diff := completedDate.UTC().Sub(chore.NextDueDate.UTC())
nextDueDate = completedDate.UTC().Add(diff)
} else if chore.FrequencyType == "once" {
// if the chore is a one-time chore, then the next due date is nil
} else if chore.FrequencyType == "interval" {
// calculate the difference between the due date and now in days:
if *frequencyMetadata.Unit == "hours" {
nextDueDate = baseDate.UTC().Add(time.Hour * time.Duration(chore.Frequency))
} else if *frequencyMetadata.Unit == "days" {
nextDueDate = baseDate.UTC().AddDate(0, 0, chore.Frequency)
} else if *frequencyMetadata.Unit == "weeks" {
nextDueDate = baseDate.UTC().AddDate(0, 0, chore.Frequency*7)
} else if *frequencyMetadata.Unit == "months" {
nextDueDate = baseDate.UTC().AddDate(0, chore.Frequency, 0)
} else if *frequencyMetadata.Unit == "years" {
nextDueDate = baseDate.UTC().AddDate(chore.Frequency, 0, 0)
} else {
return nil, fmt.Errorf("invalid frequency unit, cannot calculate next due date")
}
} else if chore.FrequencyType == "days_of_the_week" {
// TODO : this logic is bad, need to be refactored and be better.
// coding at night is almost always bad idea.
// calculate the difference between the due date and now in days:
var frequencyMetadata chModel.FrequencyMetadata
err := json.Unmarshal([]byte(*chore.FrequencyMetadata), &frequencyMetadata)
if err != nil {
return nil, fmt.Errorf("error unmarshalling frequency metadata")
}
//we can only assign to days of the week that part of the frequency metadata.days
//it's array of days of the week, for example ["monday", "tuesday", "wednesday"]
// we need to find the next day of the week in the frequency metadata.days that we can schedule
// if this the last or there is only one. will use same otherwise find the next one:
// find the index of the chore day in the frequency metadata.days
// loop for next 7 days from the base, if the day in the frequency metadata.days then we can schedule it:
for i := 1; i <= 7; i++ {
nextDueDate = baseDate.AddDate(0, 0, i)
nextDay := strings.ToLower(nextDueDate.Weekday().String())
for _, day := range frequencyMetadata.Days {
if strings.ToLower(*day) == nextDay {
nextDate := nextDueDate.UTC()
return &nextDate, nil
}
}
}
} else if chore.FrequencyType == "day_of_the_month" {
var frequencyMetadata chModel.FrequencyMetadata
err := json.Unmarshal([]byte(*chore.FrequencyMetadata), &frequencyMetadata)
if err != nil {
return nil, fmt.Errorf("error unmarshalling frequency metadata")
}
for i := 1; i <= 12; i++ {
nextDueDate = baseDate.AddDate(0, i, 0)
// set the date to the first day of the month:
nextDueDate = time.Date(nextDueDate.Year(), nextDueDate.Month(), chore.Frequency, nextDueDate.Hour(), nextDueDate.Minute(), 0, 0, nextDueDate.Location())
nextMonth := strings.ToLower(nextDueDate.Month().String())
for _, month := range frequencyMetadata.Months {
if *month == nextMonth {
nextDate := nextDueDate.UTC()
return &nextDate, nil
}
}
}
} else if chore.FrequencyType == "no_repeat" {
return nil, nil
} else if chore.FrequencyType == "trigger" {
// if the chore is a trigger chore, then the next due date is nil
return nil, nil
} else {
return nil, fmt.Errorf("invalid frequency type, cannot calculate next due date")
}
return &nextDueDate, nil
}
func RemoveAssigneeAndReassign(chore *chModel.Chore, userID int) {
for i, assignee := range chore.Assignees {
if assignee.UserID == userID {
chore.Assignees = append(chore.Assignees[:i], chore.Assignees[i+1:]...)
break
}
}
if len(chore.Assignees) == 0 {
chore.AssignedTo = chore.CreatedBy
} else {
chore.AssignedTo = chore.Assignees[rand.Intn(len(chore.Assignees))].UserID
}
chore.UpdatedAt = time.Now()
}

442
internal/circle/handler.go Normal file
View file

@ -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)
}
}

View file

@ -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"`
}

View file

@ -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
}

View file

@ -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
}

509
internal/email/sender.go Normal file
View file

@ -0,0 +1,509 @@
package email
import (
"context"
"encoding/base64"
"fmt"
"strings"
"donetick.com/core/config"
gomail "gopkg.in/gomail.v2"
)
type EmailSender struct {
client *gomail.Dialer
appHost string
}
func NewEmailSender(conf *config.Config) *EmailSender {
client := gomail.NewDialer(conf.EmailConfig.Host, conf.EmailConfig.Port, conf.EmailConfig.Email, conf.EmailConfig.Key)
// format conf.EmailConfig.Host and port :
// auth := smtp.PlainAuth("", conf.EmailConfig.Email, conf.EmailConfig.Password, host)
return &EmailSender{
client: client,
appHost: conf.EmailConfig.AppHost,
}
}
func (es *EmailSender) SendVerificationEmail(to, code string) error {
// msg := []byte(fmt.Sprintf("To: %s\r\nSubject: %s\r\n\r\n%s\r\n", to, subject, body))
msg := gomail.NewMessage()
msg.SetHeader("From", "no-reply@donetick.com")
msg.SetHeader("To", to)
msg.SetHeader("Subject", "Welcome to Donetick! Verifiy you email")
// text/html for a html email
htmlBody := `
<!--
********************************************************
* This email was built using Tabular.
* Create emails, that look perfect in every inbox.
* For more information, visit https://tabular.email
********************************************************
-->
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" lang="en"><head>
<title></title>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta name="x-apple-disable-message-reformatting" content="">
<meta content="target-densitydpi=device-dpi" name="viewport">
<meta content="true" name="HandheldFriendly">
<meta content="width=device-width" name="viewport">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
<style type="text/css">
table {
border-collapse: separate;
table-layout: fixed;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt
}
table td {
border-collapse: collapse
}
.ExternalClass {
width: 100%
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%
}
body, a, li, p, h1, h2, h3 {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
html {
-webkit-text-size-adjust: none !important
}
body, #innerTable {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale
}
#innerTable img+div {
display: none;
display: none !important
}
img {
Margin: 0;
padding: 0;
-ms-interpolation-mode: bicubic
}
h1, h2, h3, p, a {
line-height: 1;
overflow-wrap: normal;
white-space: normal;
word-break: break-word
}
a {
text-decoration: none
}
h1, h2, h3, p {
min-width: 100%!important;
width: 100%!important;
max-width: 100%!important;
display: inline-block!important;
border: 0;
padding: 0;
margin: 0
}
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important
}
a[href^="mailto"],
a[href^="tel"],
a[href^="sms"] {
color: inherit;
text-decoration: none
}
@media (min-width: 481px) {
.hd { display: none!important }
}
@media (max-width: 480px) {
.hm { display: none!important }
}
[style*="Inter Tight"] {font-family: 'Inter Tight', BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif !important;} [style*="Albert Sans"] {font-family: 'Albert Sans', BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif !important;}
@media only screen and (min-width: 481px) {.t20{width:720px!important}.t27{padding:40px 60px 50px!important}.t29{padding:40px 60px 50px!important;width:680px!important}.t43{width:600px!important}.t53,.t61{width:580px!important}.t65{width:600px!important}.t78{padding-left:0!important;padding-right:0!important}.t80{padding-left:0!important;padding-right:0!important;width:400px!important}.t84,.t94{width:600px!important}}
</style>
<style type="text/css" media="screen and (min-width:481px)">.moz-text-html .t20{width:720px!important}.moz-text-html .t27{padding:40px 60px 50px!important}.moz-text-html .t29{padding:40px 60px 50px!important;width:680px!important}.moz-text-html .t43{width:600px!important}.moz-text-html .t53,.moz-text-html .t61{width:580px!important}.moz-text-html .t65{width:600px!important}.moz-text-html .t78{padding-left:0!important;padding-right:0!important}.moz-text-html .t80{padding-left:0!important;padding-right:0!important;width:400px!important}.moz-text-html .t84,.moz-text-html .t94{width:600px!important}</style>
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;600&amp;family=Albert+Sans:wght@800&amp;display=swap" rel="stylesheet" type="text/css">
<!--<![endif]-->
<!--[if mso]>
<style type="text/css">
td.t20{width:800px !important}td.t27{padding:40px 60px 50px !important}td.t29{padding:40px 60px 50px !important;width:800px !important}td.t43,td.t53{width:600px !important}td.t61{width:580px !important}td.t65{width:600px !important}td.t78,td.t80{padding-left:0 !important;padding-right:0 !important}td.t84,td.t94{width:600px !important}
</style>
<![endif]-->
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
</head>
<body class="t0" style="min-width:100%;Marg
if you did not sign up with Donetick please Ignore this email. in:0px;padding:0px;background-color:#FFFFFF;"><div class="t1" style="background-color:#FFFFFF;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" align="center"><tbody><tr><td class="t130" style="font-size:0;line-height:0;mso-line-height-rule:exactly;" valign="top" align="center">
<!--[if mso]>
<v:background xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false">
<v:fill color="#FFFFFF"/>
</v:background>
<![endif]-->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" align="center" id="innerTable"><tbody><tr><td>
<table class="t118" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--<![endif]-->
<!--[if mso]><td class="t119" style="width:400px;padding:40px 40px 40px 40px;"><![endif]-->
</tr></tbody></table><table role="presentation" width="100%" cellpadding="0" cellspacing="0"></table></td>
</tr></tbody></table>
</td></tr><tr><td>
<table class="t10" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--[if !mso]><!--><td class="t11" style="background-color:#FFFFFF;width:400px;">
<!--<![endif]-->
<!--[if mso]><td class="t11" style="background-color:#FFFFFF;width:400px;"><![endif]-->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
<table class="t19" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--[if !mso]><!--><td class="t20" style="background-color:#404040;width:400px;padding:40px 40px 40px 40px;">
<!--<![endif]-->
<!--[if mso]><td class="t20" style="background-color:#404040;width:480px;padding:40px 40px 40px 40px;"><![endif]-->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
<table class="t103" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--[if !mso]><!--><td class="t104" style="width:200px;">
<!--<![endif]-->
<!--[if mso]><td class="t104" style="width:200px;"><![endif]-->
<div style="font-size:0px;"><img class="t110" style="display:block;border:0;height:auto;width:100%;Margin:0;max-width:100%;" width="200" height="179.5" alt="" src="https://835a1b8e-557a-4713-8f1c-104febdb8808.b-cdn.net/e/30b4288c-4e67-4e3b-9527-1fc4c4ec2fdf/df3f012a-c060-4d59-b5fd-54a57dae1916.png"></div></td>
</tr></tbody></table>
</td></tr></tbody></table></td>
</tr></tbody></table>
</td></tr><tr><td>
<table class="t28" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--[if !mso]><!--><td class="t29" style="width:420px;padding:30px 30px 40px 30px;">
<!--<![endif]-->
<!--[if mso]><td class="t29" style="width:480px;padding:30px 30px 40px 30px;"><![endif]-->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
<table class="t42" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--[if !mso]><!--><td class="t43" style="width:480px;">
<!--<![endif]-->
<!--[if mso]><td class="t43" style="width:480px;"><![endif]-->
<h1 class="t49" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Albert Sans';line-height:35px;font-weight:800;font-style:normal;font-size:30px;text-decoration:none;text-transform:none;letter-spacing:-1.2px;direction:ltr;color:#333333;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">Welcome to Donetick!</h1></td>
</tr></tbody></table>
</td></tr><tr><td><div class="t41" style="mso-line-height-rule:exactly;mso-line-height-alt:16px;line-height:16px;font-size:1px;display:block;">&nbsp;</div></td></tr><tr><td><p class="t39" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:21px;font-weight:400;font-style:normal;font-size:14px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">Thank you for joining us. We're excited to have you on board.To complete your registration, click the button below</p></td></tr><tr><td><div class="t31" style="mso-line-height-rule:exactly;mso-line-height-alt:30px;line-height:30px;font-size:1px;display:block;">&nbsp;</div></td></tr><tr><td>
<table class="t52" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--[if !mso]><!--><td class="t53" style="background-color:#06b6d4;overflow:hidden;width:460px;text-align:center;line-height:24px;mso-line-height-rule:exactly;mso-text-raise:2px;padding:10px 10px 10px 10px;border-radius:10px 10px 10px 10px;">
<!--<![endif]-->
<!--[if mso]><td class="t53" style="background-color:#06b6d4;overflow:hidden;width:480px;text-align:center;line-height:24px;mso-line-height-rule:exactly;mso-text-raise:2px;padding:10px 10px 10px 10px;border-radius:10px 10px 10px 10px;"><![endif]-->
<a class="t59" href="{{verifyURL}}" style="display:block;margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:24px;font-weight:600;font-style:normal;font-size:16px;text-decoration:none;direction:ltr;color:#FFFFFF;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;" target="_blank">Complete your registration</a></td>
</tr></tbody></table>
</td></tr><tr><td><div class="t62" style="mso-line-height-rule:exactly;mso-line-height-alt:12px;line-height:12px;font-size:1px;display:block;">&nbsp;</div></td></tr><tr><td>
<table class="t64" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--[if !mso]><!--><td class="t65" style="width:480px;">
<!--<![endif]-->
<!--[if mso]><td class="t65" style="width:480px;"><![endif]-->
<p class="t71" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:21px;font-weight:400;font-style:normal;font-size:14px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">&nbsp;</p></td>
</tr></tbody></table>
</td></tr></tbody></table></td>
</tr></tbody></table>
</td></tr></tbody></table></td>
</tr></tbody></table>
</td></tr><tr><td><div class="t4" style="mso-line-height-rule:exactly;mso-line-height-alt:30px;line-height:30px;font-size:1px;display:block;">&nbsp;</div></td></tr><tr><td>
<table class="t79" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--[if !mso]><!--><td class="t80" style="width:320px;padding:0 40px 0 40px;">
<!--<![endif]-->
<!--[if mso]><td class="t80" style="width:400px;padding:0 40px 0 40px;"><![endif]-->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
<table class="t93" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--[if !mso]><!--><td class="t94" style="width:480px;">
<!--<![endif]-->
<!--[if mso]><td class="t94" style="width:480px;"><![endif]-->
<p class="t100" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:18px;font-weight:400;font-style:normal;font-size:12px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">if you did not sign up with Donetick please Ignore this email. </p></td>
</tr></tbody></table>
</td></tr><tr><td><div class="t81" style="mso-line-height-rule:exactly;mso-line-height-alt:8px;line-height:8px;font-size:1px;display:block;">&nbsp;</div></td></tr><tr><td>
<table class="t83" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--[if !mso]><!--><td class="t84" style="width:480px;">
<!--<![endif]-->
<!--[if mso]><td class="t84" style="width:480px;"><![endif]-->
<p class="t90" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:18px;font-weight:400;font-style:normal;font-size:12px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">Favoro LLC. All rights reserved</p></td>
</tr></tbody></table>
</td></tr></tbody></table></td>
</tr></tbody></table>
</td></tr><tr><td><div class="t73" style="mso-line-height-rule:exactly;mso-line-height-alt:100px;line-height:100px;font-size:1px;display:block;">&nbsp;</div></td></tr></tbody></table></div>
</body></html>
`
u := es.appHost + "/verify?c=" + encodeEmailAndCode(to, code)
htmlBody = strings.Replace(htmlBody, "{{verifyURL}}", u, 1)
msg.SetBody("text/html", htmlBody)
err := es.client.DialAndSend(msg)
if err != nil {
return err
}
return nil
}
func (es *EmailSender) SendResetPasswordEmail(c context.Context, to, code string) error {
msg := gomail.NewMessage()
msg.SetHeader("From", "no-reply@donetick.com")
msg.SetHeader("To", to)
msg.SetHeader("Subject", "Donetick! Password Reset")
htmlBody := `
<!--
********************************************************
* This email was built using Tabular.
* Create emails, that look perfect in every inbox.
* For more information, visit https://tabular.email
********************************************************
-->
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" lang="en"><head>
<title></title>
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta name="x-apple-disable-message-reformatting" content="">
<meta content="target-densitydpi=device-dpi" name="viewport">
<meta content="true" name="HandheldFriendly">
<meta content="width=device-width" name="viewport">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
<style type="text/css">
table {
border-collapse: separate;
table-layout: fixed;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt
}
table td {
border-collapse: collapse
}
.ExternalClass {
width: 100%
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%
}
body, a, li, p, h1, h2, h3 {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
html {
-webkit-text-size-adjust: none !important
}
body, #innerTable {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale
}
#innerTable img+div {
display: none;
display: none !important
}
img {
Margin: 0;
padding: 0;
-ms-interpolation-mode: bicubic
}
h1, h2, h3, p, a {
line-height: 1;
overflow-wrap: normal;
white-space: normal;
word-break: break-word
}
a {
text-decoration: none
}
h1, h2, h3, p {
min-width: 100%!important;
width: 100%!important;
max-width: 100%!important;
display: inline-block!important;
border: 0;
padding: 0;
margin: 0
}
a[x-apple-data-detectors] {
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important
}
a[href^="mailto"],
a[href^="tel"],
a[href^="sms"] {
color: inherit;
text-decoration: none
}
@media (min-width: 481px) {
.hd { display: none!important }
}
@media (max-width: 480px) {
.hm { display: none!important }
}
[style*="Inter Tight"] {font-family: 'Inter Tight', BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif !important;} [style*="Albert Sans"] {font-family: 'Albert Sans', BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif !important;}
@media only screen and (min-width: 481px) {.t20{width:720px!important}.t27{padding:40px 60px 50px!important}.t29{padding:40px 60px 50px!important;width:680px!important}.t43{width:600px!important}.t53,.t61{width:580px!important}.t65{width:600px!important}.t78{padding-left:0!important;padding-right:0!important}.t80{padding-left:0!important;padding-right:0!important;width:400px!important}.t84,.t94{width:600px!important}}
</style>
<style type="text/css" media="screen and (min-width:481px)">.moz-text-html .t20{width:720px!important}.moz-text-html .t27{padding:40px 60px 50px!important}.moz-text-html .t29{padding:40px 60px 50px!important;width:680px!important}.moz-text-html .t43{width:600px!important}.moz-text-html .t53,.moz-text-html .t61{width:580px!important}.moz-text-html .t65{width:600px!important}.moz-text-html .t78{padding-left:0!important;padding-right:0!important}.moz-text-html .t80{padding-left:0!important;padding-right:0!important;width:400px!important}.moz-text-html .t84,.moz-text-html .t94{width:600px!important}</style>
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;600&amp;family=Albert+Sans:wght@800&amp;display=swap" rel="stylesheet" type="text/css">
<!--<![endif]-->
<!--[if mso]>
<style type="text/css">
td.t20{width:800px !important}td.t27{padding:40px 60px 50px !important}td.t29{padding:40px 60px 50px !important;width:800px !important}td.t43,td.t53{width:600px !important}td.t61{width:580px !important}td.t65{width:600px !important}td.t78,td.t80{padding-left:0 !important;padding-right:0 !important}td.t84,td.t94{width:600px !important}
</style>
<![endif]-->
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
</head>
<body class="t0" style="min-width:100%;Marg
if you did not sign up with Donetick please Ignore this email. in:0px;padding:0px;background-color:#FFFFFF;"><div class="t1" style="background-color:#FFFFFF;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" align="center"><tbody><tr><td class="t130" style="font-size:0;line-height:0;mso-line-height-rule:exactly;" valign="top" align="center">
<!--[if mso]>
<v:background xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false">
<v:fill color="#FFFFFF"/>
</v:background>
<![endif]-->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" align="center" id="innerTable"><tbody><tr><td>
<table class="t118" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--<![endif]-->
<!--[if mso]><td class="t119" style="width:400px;padding:40px 40px 40px 40px;"><![endif]-->
</tr></tbody></table><table role="presentation" width="100%" cellpadding="0" cellspacing="0"></table></td>
</tr></tbody></table>
</td></tr><tr><td>
<table class="t10" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--[if !mso]><!--><td class="t11" style="background-color:#FFFFFF;width:400px;">
<!--<![endif]-->
<!--[if mso]><td class="t11" style="background-color:#FFFFFF;width:400px;"><![endif]-->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
<table class="t19" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--[if !mso]><!--><td class="t20" style="background-color:#404040;width:400px;padding:40px 40px 40px 40px;">
<!--<![endif]-->
<!--[if mso]><td class="t20" style="background-color:#404040;width:480px;padding:40px 40px 40px 40px;"><![endif]-->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
<table class="t103" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--[if !mso]><!--><td class="t104" style="width:200px;">
<!--<![endif]-->
<!--[if mso]><td class="t104" style="width:200px;"><![endif]-->
<div style="font-size:0px;"><img class="t110" style="display:block;border:0;height:auto;width:100%;Margin:0;max-width:100%;" width="200" height="179.5" alt="" src="https://835a1b8e-557a-4713-8f1c-104febdb8808.b-cdn.net/e/30b4288c-4e67-4e3b-9527-1fc4c4ec2fdf/df3f012a-c060-4d59-b5fd-54a57dae1916.png"></div></td>
</tr></tbody></table>
</td></tr></tbody></table></td>
</tr></tbody></table>
</td></tr><tr><td>
<table class="t28" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--[if !mso]><!--><td class="t29" style="width:420px;padding:30px 30px 40px 30px;">
<!--<![endif]-->
<!--[if mso]><td class="t29" style="width:480px;padding:30px 30px 40px 30px;"><![endif]-->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
<table class="t42" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--[if !mso]><!--><td class="t43" style="width:480px;">
<!--<![endif]-->
<!--[if mso]><td class="t43" style="width:480px;"><![endif]-->
<h1 class="t49" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Albert Sans';line-height:35px;font-weight:800;font-style:normal;font-size:30px;text-decoration:none;text-transform:none;letter-spacing:-1.2px;direction:ltr;color:#333333;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">Someone forgot their password 😔</h1></td>
</tr></tbody></table>
</td></tr><tr><td><div class="t41" style="mso-line-height-rule:exactly;mso-line-height-alt:16px;line-height:16px;font-size:1px;display:block;">&nbsp;</div></td></tr><tr><td><p class="t39" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:21px;font-weight:400;font-style:normal;font-size:14px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">We have received a password reset request for this email address. If you initiated this request, please click the button below to reset your password. Otherwise, you may safely ignore this email.</p></td></tr><tr><td><div class="t31" style="mso-line-height-rule:exactly;mso-line-height-alt:30px;line-height:30px;font-size:1px;display:block;">&nbsp;</div></td></tr><tr><td>
<table class="t52" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--[if !mso]><!--><td class="t53" style="background-color:#06b6d4;overflow:hidden;width:460px;text-align:center;line-height:24px;mso-line-height-rule:exactly;mso-text-raise:2px;padding:10px 10px 10px 10px;border-radius:10px 10px 10px 10px;">
<!--<![endif]-->
<!--[if mso]><td class="t53" style="background-color:#06b6d4;overflow:hidden;width:480px;text-align:center;line-height:24px;mso-line-height-rule:exactly;mso-text-raise:2px;padding:10px 10px 10px 10px;border-radius:10px 10px 10px 10px;"><![endif]-->
<a class="t59" href="{{verifyURL}}" style="display:block;margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:24px;font-weight:600;font-style:normal;font-size:16px;text-decoration:none;direction:ltr;color:#FFFFFF;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;" target="_blank">Reset your Password</a></td>
</tr></tbody></table>
</td></tr><tr><td><div class="t62" style="mso-line-height-rule:exactly;mso-line-height-alt:12px;line-height:12px;font-size:1px;display:block;">&nbsp;</div></td></tr><tr><td>
<table class="t64" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--[if !mso]><!--><td class="t65" style="width:480px;">
<!--<![endif]-->
<!--[if mso]><td class="t65" style="width:480px;"><![endif]-->
<p class="t71" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:21px;font-weight:400;font-style:normal;font-size:14px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">&nbsp;</p></td>
</tr></tbody></table>
</td></tr></tbody></table></td>
</tr></tbody></table>
</td></tr></tbody></table></td>
</tr></tbody></table>
</td></tr><tr><td><div class="t4" style="mso-line-height-rule:exactly;mso-line-height-alt:30px;line-height:30px;font-size:1px;display:block;">&nbsp;</div></td></tr><tr><td>
<table class="t79" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--[if !mso]><!--><td class="t80" style="width:320px;padding:0 40px 0 40px;">
<!--<![endif]-->
<!--[if mso]><td class="t80" style="width:400px;padding:0 40px 0 40px;"><![endif]-->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
<table class="t93" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--[if !mso]><!--><td class="t94" style="width:480px;">
<!--<![endif]-->
<!--[if mso]><td class="t94" style="width:480px;"><![endif]-->
<p class="t100" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:18px;font-weight:400;font-style:normal;font-size:12px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">if you did not sign up with Donetick please Ignore this email. </p></td>
</tr></tbody></table>
</td></tr><tr><td><div class="t81" style="mso-line-height-rule:exactly;mso-line-height-alt:8px;line-height:8px;font-size:1px;display:block;">&nbsp;</div></td></tr><tr><td>
<table class="t83" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
<!--[if !mso]><!--><td class="t84" style="width:480px;">
<!--<![endif]-->
<!--[if mso]><td class="t84" style="width:480px;"><![endif]-->
<p class="t90" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:18px;font-weight:400;font-style:normal;font-size:12px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">Favoro LLC. All rights reserved</p></td>
</tr></tbody></table>
</td></tr></tbody></table></td>
</tr></tbody></table>
</td></tr><tr><td><div class="t73" style="mso-line-height-rule:exactly;mso-line-height-alt:100px;line-height:100px;font-size:1px;display:block;">&nbsp;</div></td></tr></tbody></table></div>
</body></html>
`
u := es.appHost + "/password/update?c=" + encodeEmailAndCode(to, code)
// logging.FromContext(c).Infof("Reset password URL: %s", u)
htmlBody = strings.Replace(htmlBody, "{{verifyURL}}", u, 1)
msg.SetBody("text/html", htmlBody)
err := es.client.DialAndSend(msg)
if err != nil {
return err
}
return nil
}
// func (es *EmailSender) SendFeedbackRequestEmail(to, code string) error {
// // msg := []byte(fmt.Sprintf("To: %s\r\nSubject: %s\r\n\r\n%s\r\n", to, subject, body))
// msg := gomail.NewMessage()
// }
func encodeEmailAndCode(email, code string) string {
data := email + ":" + code
return base64.StdEncoding.EncodeToString([]byte(data))
}
func DecodeEmailAndCode(encoded string) (string, string, error) {
data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", "", err
}
parts := string(data)
split := strings.Split(parts, ":")
if len(split) != 2 {
return "", "", fmt.Errorf("Invalid format")
}
return split[0], split[1], nil
}

View file

@ -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"`
}

View file

@ -0,0 +1,43 @@
package user
import (
"context"
"time"
nModel "donetick.com/core/internal/notifier/model"
"gorm.io/gorm"
)
type NotificationRepository struct {
db *gorm.DB
}
func NewNotificationRepository(db *gorm.DB) *NotificationRepository {
return &NotificationRepository{db}
}
func (r *NotificationRepository) DeleteAllChoreNotifications(choreID int) error {
return r.db.Where("chore_id = ?", choreID).Delete(&nModel.Notification{}).Error
}
func (r *NotificationRepository) BatchInsertNotifications(notifications []*nModel.Notification) error {
return r.db.Create(&notifications).Error
}
func (r *NotificationRepository) MarkNotificationsAsSent(notifications []*nModel.Notification) error {
// Extract IDs from notifications
var ids []int
for _, notification := range notifications {
ids = append(ids, notification.ID)
}
// Use the extracted IDs in the Where clause
return r.db.Model(&nModel.Notification{}).Where("id IN (?)", ids).Update("is_sent", true).Error
}
func (r *NotificationRepository) GetPendingNotificaiton(c context.Context, lookback time.Duration) ([]*nModel.Notification, error) {
var notifications []*nModel.Notification
start := time.Now().UTC().Add(-lookback)
end := time.Now().UTC()
if err := r.db.Debug().Where("is_sent = ? AND scheduled_for < ? AND scheduled_for > ?", false, end, start).Find(&notifications).Error; err != nil {
return nil, err
}
return notifications, nil
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}
}

281
internal/thing/handler.go Normal file
View file

@ -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)
}
}

57
internal/thing/helper.go Normal file
View file

@ -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
}
}

View file

@ -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"`
}

View file

@ -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
// }

175
internal/thing/webhook.go Normal file
View file

@ -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)
}
}

511
internal/user/handler.go Normal file
View file

@ -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)
}
}

View file

@ -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"`
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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()
}
}