Move to Donetick Org, first commit
This commit is contained in:
commit
c13dd9addb
42 changed files with 7463 additions and 0 deletions
137
internal/authorization/middleware.go
Normal file
137
internal/authorization/middleware.go
Normal 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,
|
||||
})
|
||||
}
|
60
internal/authorization/password.go
Normal file
60
internal/authorization/password.go
Normal 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
974
internal/chore/handler.go
Normal 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)
|
||||
}
|
||||
|
||||
}
|
72
internal/chore/model/model.go
Normal file
72
internal/chore/model/model.go
Normal 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"`
|
||||
// }
|
216
internal/chore/repo/repository.go
Normal file
216
internal/chore/repo/repository.go
Normal 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
145
internal/chore/scheduler.go
Normal 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
442
internal/circle/handler.go
Normal 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)
|
||||
|
||||
}
|
||||
|
||||
}
|
35
internal/circle/model/model.go
Normal file
35
internal/circle/model/model.go
Normal 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"`
|
||||
}
|
117
internal/circle/repo/repository.go
Normal file
117
internal/circle/repo/repository.go
Normal 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
|
||||
}
|
44
internal/database/database.go
Normal file
44
internal/database/database.go
Normal 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
509
internal/email/sender.go
Normal 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&family=Albert+Sans:wght@800&display=swap" rel="stylesheet" type="text/css">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
td.t20{width:800px !important}td.t27{padding:40px 60px 50px !important}td.t29{padding:40px 60px 50px !important;width:800px !important}td.t43,td.t53{width:600px !important}td.t61{width:580px !important}td.t65{width:600px !important}td.t78,td.t80{padding-left:0 !important;padding-right:0 !important}td.t84,td.t94{width:600px !important}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if mso]>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body class="t0" style="min-width:100%;Marg
|
||||
if you did not sign up with Donetick please Ignore this email. in:0px;padding:0px;background-color:#FFFFFF;"><div class="t1" style="background-color:#FFFFFF;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" align="center"><tbody><tr><td class="t130" style="font-size:0;line-height:0;mso-line-height-rule:exactly;" valign="top" align="center">
|
||||
<!--[if mso]>
|
||||
<v:background xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false">
|
||||
<v:fill color="#FFFFFF"/>
|
||||
</v:background>
|
||||
<![endif]-->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" align="center" id="innerTable"><tbody><tr><td>
|
||||
<table class="t118" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t119" style="width:400px;padding:40px 40px 40px 40px;"><![endif]-->
|
||||
</tr></tbody></table><table role="presentation" width="100%" cellpadding="0" cellspacing="0"></table></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr><tr><td>
|
||||
<table class="t10" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
<!--[if !mso]><!--><td class="t11" style="background-color:#FFFFFF;width:400px;">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t11" style="background-color:#FFFFFF;width:400px;"><![endif]-->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
|
||||
<table class="t19" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
<!--[if !mso]><!--><td class="t20" style="background-color:#404040;width:400px;padding:40px 40px 40px 40px;">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t20" style="background-color:#404040;width:480px;padding:40px 40px 40px 40px;"><![endif]-->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
|
||||
<table class="t103" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
<!--[if !mso]><!--><td class="t104" style="width:200px;">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t104" style="width:200px;"><![endif]-->
|
||||
<div style="font-size:0px;"><img class="t110" style="display:block;border:0;height:auto;width:100%;Margin:0;max-width:100%;" width="200" height="179.5" alt="" src="https://835a1b8e-557a-4713-8f1c-104febdb8808.b-cdn.net/e/30b4288c-4e67-4e3b-9527-1fc4c4ec2fdf/df3f012a-c060-4d59-b5fd-54a57dae1916.png"></div></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr></tbody></table></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr><tr><td>
|
||||
<table class="t28" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
<!--[if !mso]><!--><td class="t29" style="width:420px;padding:30px 30px 40px 30px;">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t29" style="width:480px;padding:30px 30px 40px 30px;"><![endif]-->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
|
||||
<table class="t42" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
<!--[if !mso]><!--><td class="t43" style="width:480px;">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t43" style="width:480px;"><![endif]-->
|
||||
<h1 class="t49" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Albert Sans';line-height:35px;font-weight:800;font-style:normal;font-size:30px;text-decoration:none;text-transform:none;letter-spacing:-1.2px;direction:ltr;color:#333333;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">Welcome to Donetick!</h1></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr><tr><td><div class="t41" style="mso-line-height-rule:exactly;mso-line-height-alt:16px;line-height:16px;font-size:1px;display:block;"> </div></td></tr><tr><td><p class="t39" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:21px;font-weight:400;font-style:normal;font-size:14px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">Thank you for joining us. We're excited to have you on board.To complete your registration, click the button below</p></td></tr><tr><td><div class="t31" style="mso-line-height-rule:exactly;mso-line-height-alt:30px;line-height:30px;font-size:1px;display:block;"> </div></td></tr><tr><td>
|
||||
<table class="t52" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
<!--[if !mso]><!--><td class="t53" style="background-color:#06b6d4;overflow:hidden;width:460px;text-align:center;line-height:24px;mso-line-height-rule:exactly;mso-text-raise:2px;padding:10px 10px 10px 10px;border-radius:10px 10px 10px 10px;">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t53" style="background-color:#06b6d4;overflow:hidden;width:480px;text-align:center;line-height:24px;mso-line-height-rule:exactly;mso-text-raise:2px;padding:10px 10px 10px 10px;border-radius:10px 10px 10px 10px;"><![endif]-->
|
||||
<a class="t59" href="{{verifyURL}}" style="display:block;margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:24px;font-weight:600;font-style:normal;font-size:16px;text-decoration:none;direction:ltr;color:#FFFFFF;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;" target="_blank">Complete your registration</a></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr><tr><td><div class="t62" style="mso-line-height-rule:exactly;mso-line-height-alt:12px;line-height:12px;font-size:1px;display:block;"> </div></td></tr><tr><td>
|
||||
<table class="t64" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
<!--[if !mso]><!--><td class="t65" style="width:480px;">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t65" style="width:480px;"><![endif]-->
|
||||
<p class="t71" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:21px;font-weight:400;font-style:normal;font-size:14px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;"> </p></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr></tbody></table></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr></tbody></table></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr><tr><td><div class="t4" style="mso-line-height-rule:exactly;mso-line-height-alt:30px;line-height:30px;font-size:1px;display:block;"> </div></td></tr><tr><td>
|
||||
<table class="t79" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
<!--[if !mso]><!--><td class="t80" style="width:320px;padding:0 40px 0 40px;">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t80" style="width:400px;padding:0 40px 0 40px;"><![endif]-->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
|
||||
<table class="t93" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
<!--[if !mso]><!--><td class="t94" style="width:480px;">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t94" style="width:480px;"><![endif]-->
|
||||
<p class="t100" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:18px;font-weight:400;font-style:normal;font-size:12px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">if you did not sign up with Donetick please Ignore this email. </p></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr><tr><td><div class="t81" style="mso-line-height-rule:exactly;mso-line-height-alt:8px;line-height:8px;font-size:1px;display:block;"> </div></td></tr><tr><td>
|
||||
<table class="t83" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
<!--[if !mso]><!--><td class="t84" style="width:480px;">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t84" style="width:480px;"><![endif]-->
|
||||
<p class="t90" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:18px;font-weight:400;font-style:normal;font-size:12px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">Favoro LLC. All rights reserved</p></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr></tbody></table></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr><tr><td><div class="t73" style="mso-line-height-rule:exactly;mso-line-height-alt:100px;line-height:100px;font-size:1px;display:block;"> </div></td></tr></tbody></table></div>
|
||||
|
||||
</body></html>
|
||||
`
|
||||
u := es.appHost + "/verify?c=" + encodeEmailAndCode(to, code)
|
||||
htmlBody = strings.Replace(htmlBody, "{{verifyURL}}", u, 1)
|
||||
|
||||
msg.SetBody("text/html", htmlBody)
|
||||
|
||||
err := es.client.DialAndSend(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func (es *EmailSender) SendResetPasswordEmail(c context.Context, to, code string) error {
|
||||
msg := gomail.NewMessage()
|
||||
msg.SetHeader("From", "no-reply@donetick.com")
|
||||
msg.SetHeader("To", to)
|
||||
msg.SetHeader("Subject", "Donetick! Password Reset")
|
||||
htmlBody := `
|
||||
<!--
|
||||
********************************************************
|
||||
* This email was built using Tabular.
|
||||
* Create emails, that look perfect in every inbox.
|
||||
* For more information, visit https://tabular.email
|
||||
********************************************************
|
||||
-->
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" lang="en"><head>
|
||||
<title></title>
|
||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta name="x-apple-disable-message-reformatting" content="">
|
||||
<meta content="target-densitydpi=device-dpi" name="viewport">
|
||||
<meta content="true" name="HandheldFriendly">
|
||||
<meta content="width=device-width" name="viewport">
|
||||
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
|
||||
<style type="text/css">
|
||||
table {
|
||||
border-collapse: separate;
|
||||
table-layout: fixed;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt
|
||||
}
|
||||
table td {
|
||||
border-collapse: collapse
|
||||
}
|
||||
.ExternalClass {
|
||||
width: 100%
|
||||
}
|
||||
.ExternalClass,
|
||||
.ExternalClass p,
|
||||
.ExternalClass span,
|
||||
.ExternalClass font,
|
||||
.ExternalClass td,
|
||||
.ExternalClass div {
|
||||
line-height: 100%
|
||||
}
|
||||
body, a, li, p, h1, h2, h3 {
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
html {
|
||||
-webkit-text-size-adjust: none !important
|
||||
}
|
||||
body, #innerTable {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale
|
||||
}
|
||||
#innerTable img+div {
|
||||
display: none;
|
||||
display: none !important
|
||||
}
|
||||
img {
|
||||
Margin: 0;
|
||||
padding: 0;
|
||||
-ms-interpolation-mode: bicubic
|
||||
}
|
||||
h1, h2, h3, p, a {
|
||||
line-height: 1;
|
||||
overflow-wrap: normal;
|
||||
white-space: normal;
|
||||
word-break: break-word
|
||||
}
|
||||
a {
|
||||
text-decoration: none
|
||||
}
|
||||
h1, h2, h3, p {
|
||||
min-width: 100%!important;
|
||||
width: 100%!important;
|
||||
max-width: 100%!important;
|
||||
display: inline-block!important;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0
|
||||
}
|
||||
a[x-apple-data-detectors] {
|
||||
color: inherit !important;
|
||||
text-decoration: none !important;
|
||||
font-size: inherit !important;
|
||||
font-family: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
line-height: inherit !important
|
||||
}
|
||||
a[href^="mailto"],
|
||||
a[href^="tel"],
|
||||
a[href^="sms"] {
|
||||
color: inherit;
|
||||
text-decoration: none
|
||||
}
|
||||
@media (min-width: 481px) {
|
||||
.hd { display: none!important }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.hm { display: none!important }
|
||||
}
|
||||
[style*="Inter Tight"] {font-family: 'Inter Tight', BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif !important;} [style*="Albert Sans"] {font-family: 'Albert Sans', BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif !important;}
|
||||
@media only screen and (min-width: 481px) {.t20{width:720px!important}.t27{padding:40px 60px 50px!important}.t29{padding:40px 60px 50px!important;width:680px!important}.t43{width:600px!important}.t53,.t61{width:580px!important}.t65{width:600px!important}.t78{padding-left:0!important;padding-right:0!important}.t80{padding-left:0!important;padding-right:0!important;width:400px!important}.t84,.t94{width:600px!important}}
|
||||
</style>
|
||||
<style type="text/css" media="screen and (min-width:481px)">.moz-text-html .t20{width:720px!important}.moz-text-html .t27{padding:40px 60px 50px!important}.moz-text-html .t29{padding:40px 60px 50px!important;width:680px!important}.moz-text-html .t43{width:600px!important}.moz-text-html .t53,.moz-text-html .t61{width:580px!important}.moz-text-html .t65{width:600px!important}.moz-text-html .t78{padding-left:0!important;padding-right:0!important}.moz-text-html .t80{padding-left:0!important;padding-right:0!important;width:400px!important}.moz-text-html .t84,.moz-text-html .t94{width:600px!important}</style>
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;600&family=Albert+Sans:wght@800&display=swap" rel="stylesheet" type="text/css">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
td.t20{width:800px !important}td.t27{padding:40px 60px 50px !important}td.t29{padding:40px 60px 50px !important;width:800px !important}td.t43,td.t53{width:600px !important}td.t61{width:580px !important}td.t65{width:600px !important}td.t78,td.t80{padding-left:0 !important;padding-right:0 !important}td.t84,td.t94{width:600px !important}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if mso]>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body class="t0" style="min-width:100%;Marg
|
||||
if you did not sign up with Donetick please Ignore this email. in:0px;padding:0px;background-color:#FFFFFF;"><div class="t1" style="background-color:#FFFFFF;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" align="center"><tbody><tr><td class="t130" style="font-size:0;line-height:0;mso-line-height-rule:exactly;" valign="top" align="center">
|
||||
<!--[if mso]>
|
||||
<v:background xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false">
|
||||
<v:fill color="#FFFFFF"/>
|
||||
</v:background>
|
||||
<![endif]-->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" align="center" id="innerTable"><tbody><tr><td>
|
||||
<table class="t118" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t119" style="width:400px;padding:40px 40px 40px 40px;"><![endif]-->
|
||||
</tr></tbody></table><table role="presentation" width="100%" cellpadding="0" cellspacing="0"></table></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr><tr><td>
|
||||
<table class="t10" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
<!--[if !mso]><!--><td class="t11" style="background-color:#FFFFFF;width:400px;">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t11" style="background-color:#FFFFFF;width:400px;"><![endif]-->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
|
||||
<table class="t19" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
<!--[if !mso]><!--><td class="t20" style="background-color:#404040;width:400px;padding:40px 40px 40px 40px;">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t20" style="background-color:#404040;width:480px;padding:40px 40px 40px 40px;"><![endif]-->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
|
||||
<table class="t103" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
<!--[if !mso]><!--><td class="t104" style="width:200px;">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t104" style="width:200px;"><![endif]-->
|
||||
<div style="font-size:0px;"><img class="t110" style="display:block;border:0;height:auto;width:100%;Margin:0;max-width:100%;" width="200" height="179.5" alt="" src="https://835a1b8e-557a-4713-8f1c-104febdb8808.b-cdn.net/e/30b4288c-4e67-4e3b-9527-1fc4c4ec2fdf/df3f012a-c060-4d59-b5fd-54a57dae1916.png"></div></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr></tbody></table></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr><tr><td>
|
||||
<table class="t28" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
<!--[if !mso]><!--><td class="t29" style="width:420px;padding:30px 30px 40px 30px;">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t29" style="width:480px;padding:30px 30px 40px 30px;"><![endif]-->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
|
||||
<table class="t42" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
<!--[if !mso]><!--><td class="t43" style="width:480px;">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t43" style="width:480px;"><![endif]-->
|
||||
<h1 class="t49" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Albert Sans';line-height:35px;font-weight:800;font-style:normal;font-size:30px;text-decoration:none;text-transform:none;letter-spacing:-1.2px;direction:ltr;color:#333333;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">Someone forgot their password 😔</h1></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr><tr><td><div class="t41" style="mso-line-height-rule:exactly;mso-line-height-alt:16px;line-height:16px;font-size:1px;display:block;"> </div></td></tr><tr><td><p class="t39" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:21px;font-weight:400;font-style:normal;font-size:14px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">We have received a password reset request for this email address. If you initiated this request, please click the button below to reset your password. Otherwise, you may safely ignore this email.</p></td></tr><tr><td><div class="t31" style="mso-line-height-rule:exactly;mso-line-height-alt:30px;line-height:30px;font-size:1px;display:block;"> </div></td></tr><tr><td>
|
||||
<table class="t52" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
<!--[if !mso]><!--><td class="t53" style="background-color:#06b6d4;overflow:hidden;width:460px;text-align:center;line-height:24px;mso-line-height-rule:exactly;mso-text-raise:2px;padding:10px 10px 10px 10px;border-radius:10px 10px 10px 10px;">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t53" style="background-color:#06b6d4;overflow:hidden;width:480px;text-align:center;line-height:24px;mso-line-height-rule:exactly;mso-text-raise:2px;padding:10px 10px 10px 10px;border-radius:10px 10px 10px 10px;"><![endif]-->
|
||||
<a class="t59" href="{{verifyURL}}" style="display:block;margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:24px;font-weight:600;font-style:normal;font-size:16px;text-decoration:none;direction:ltr;color:#FFFFFF;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;" target="_blank">Reset your Password</a></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr><tr><td><div class="t62" style="mso-line-height-rule:exactly;mso-line-height-alt:12px;line-height:12px;font-size:1px;display:block;"> </div></td></tr><tr><td>
|
||||
<table class="t64" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
<!--[if !mso]><!--><td class="t65" style="width:480px;">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t65" style="width:480px;"><![endif]-->
|
||||
<p class="t71" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:21px;font-weight:400;font-style:normal;font-size:14px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;"> </p></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr></tbody></table></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr></tbody></table></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr><tr><td><div class="t4" style="mso-line-height-rule:exactly;mso-line-height-alt:30px;line-height:30px;font-size:1px;display:block;"> </div></td></tr><tr><td>
|
||||
<table class="t79" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
<!--[if !mso]><!--><td class="t80" style="width:320px;padding:0 40px 0 40px;">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t80" style="width:400px;padding:0 40px 0 40px;"><![endif]-->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tbody><tr><td>
|
||||
<table class="t93" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
<!--[if !mso]><!--><td class="t94" style="width:480px;">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t94" style="width:480px;"><![endif]-->
|
||||
<p class="t100" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:18px;font-weight:400;font-style:normal;font-size:12px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">if you did not sign up with Donetick please Ignore this email. </p></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr><tr><td><div class="t81" style="mso-line-height-rule:exactly;mso-line-height-alt:8px;line-height:8px;font-size:1px;display:block;"> </div></td></tr><tr><td>
|
||||
<table class="t83" role="presentation" cellpadding="0" cellspacing="0" align="center"><tbody><tr>
|
||||
<!--[if !mso]><!--><td class="t84" style="width:480px;">
|
||||
<!--<![endif]-->
|
||||
<!--[if mso]><td class="t84" style="width:480px;"><![endif]-->
|
||||
<p class="t90" style="margin:0;Margin:0;font-family:BlinkMacSystemFont,Segoe UI,Helvetica Neue,Arial,sans-serif,'Inter Tight';line-height:18px;font-weight:400;font-style:normal;font-size:12px;text-decoration:none;text-transform:none;direction:ltr;color:#555555;text-align:center;mso-line-height-rule:exactly;mso-text-raise:2px;">Favoro LLC. All rights reserved</p></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr></tbody></table></td>
|
||||
</tr></tbody></table>
|
||||
</td></tr><tr><td><div class="t73" style="mso-line-height-rule:exactly;mso-line-height-alt:100px;line-height:100px;font-size:1px;display:block;"> </div></td></tr></tbody></table></div>
|
||||
|
||||
</body></html>
|
||||
`
|
||||
u := es.appHost + "/password/update?c=" + encodeEmailAndCode(to, code)
|
||||
|
||||
// logging.FromContext(c).Infof("Reset password URL: %s", u)
|
||||
htmlBody = strings.Replace(htmlBody, "{{verifyURL}}", u, 1)
|
||||
|
||||
msg.SetBody("text/html", htmlBody)
|
||||
|
||||
err := es.client.DialAndSend(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
// func (es *EmailSender) SendFeedbackRequestEmail(to, code string) error {
|
||||
// // msg := []byte(fmt.Sprintf("To: %s\r\nSubject: %s\r\n\r\n%s\r\n", to, subject, body))
|
||||
// msg := gomail.NewMessage()
|
||||
|
||||
// }
|
||||
func encodeEmailAndCode(email, code string) string {
|
||||
data := email + ":" + code
|
||||
return base64.StdEncoding.EncodeToString([]byte(data))
|
||||
}
|
||||
|
||||
func DecodeEmailAndCode(encoded string) (string, string, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
parts := string(data)
|
||||
split := strings.Split(parts, ":")
|
||||
if len(split) != 2 {
|
||||
return "", "", fmt.Errorf("Invalid format")
|
||||
}
|
||||
return split[0], split[1], nil
|
||||
}
|
15
internal/notifier/model/model.go
Normal file
15
internal/notifier/model/model.go
Normal 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"`
|
||||
}
|
43
internal/notifier/repo/repository.go
Normal file
43
internal/notifier/repo/repository.go
Normal 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(¬ifications).Error
|
||||
}
|
||||
func (r *NotificationRepository) MarkNotificationsAsSent(notifications []*nModel.Notification) error {
|
||||
// Extract IDs from notifications
|
||||
var ids []int
|
||||
for _, notification := range notifications {
|
||||
ids = append(ids, notification.ID)
|
||||
}
|
||||
// Use the extracted IDs in the Where clause
|
||||
return r.db.Model(&nModel.Notification{}).Where("id IN (?)", ids).Update("is_sent", true).Error
|
||||
}
|
||||
func (r *NotificationRepository) GetPendingNotificaiton(c context.Context, lookback time.Duration) ([]*nModel.Notification, error) {
|
||||
var notifications []*nModel.Notification
|
||||
start := time.Now().UTC().Add(-lookback)
|
||||
end := time.Now().UTC()
|
||||
if err := r.db.Debug().Where("is_sent = ? AND scheduled_for < ? AND scheduled_for > ?", false, end, start).Find(¬ifications).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return notifications, nil
|
||||
}
|
89
internal/notifier/scheduler.go
Normal file
89
internal/notifier/scheduler.go
Normal 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
|
||||
}
|
149
internal/notifier/service/planner.go
Normal file
149
internal/notifier/service/planner.go
Normal 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
|
||||
|
||||
}
|
127
internal/notifier/telegram/telegram.go
Normal file
127
internal/notifier/telegram/telegram.go
Normal 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
281
internal/thing/handler.go
Normal 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
57
internal/thing/helper.go
Normal 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
|
||||
}
|
||||
|
||||
}
|
30
internal/thing/model/model.go
Normal file
30
internal/thing/model/model.go
Normal 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"`
|
||||
}
|
117
internal/thing/repo/repository.go
Normal file
117
internal/thing/repo/repository.go
Normal 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
175
internal/thing/webhook.go
Normal 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
511
internal/user/handler.go
Normal 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)
|
||||
}
|
||||
}
|
38
internal/user/model/model.go
Normal file
38
internal/user/model/model.go
Normal 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"`
|
||||
}
|
160
internal/user/repo/repository.go
Normal file
160
internal/user/repo/repository.go
Normal 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
|
||||
}
|
28
internal/utils/key_generator.go
Normal file
28
internal/utils/key_generator.go
Normal 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
|
||||
}
|
72
internal/utils/middleware.go
Normal file
72
internal/utils/middleware.go
Normal 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()
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue