diff --git a/config/config.go b/config/config.go index 8b1e368..656e44c 100644 --- a/config/config.go +++ b/config/config.go @@ -52,6 +52,12 @@ var ( ManageProjects string `yaml:"manage_projects"` ManageStore string `yaml:"manage_store"` } + + Redirects struct { + SecurityTab string `yaml:"security"` + Profile string `yaml:"profile"` + Project string `yaml:"project"` + } ) func Parse() { @@ -99,6 +105,10 @@ func Parse() { log.Fatalf("error: %v", err) } + if err := yaml.Unmarshal(read("redirects.yaml"), &Redirects); err != nil { + log.Fatalf("error: %v", err) + } + log.Print("Loaded config files") } diff --git a/config/redirects.yaml b/config/redirects.yaml new file mode 100644 index 0000000..658800e --- /dev/null +++ b/config/redirects.yaml @@ -0,0 +1,3 @@ +security: /settings?tab=security +profile: /profile/%s +project: /projects/%d diff --git a/internal/controller/http/v1/auth.go b/internal/controller/http/v1/auth.go index 711e291..9df37a8 100644 --- a/internal/controller/http/v1/auth.go +++ b/internal/controller/http/v1/auth.go @@ -12,6 +12,7 @@ import ( "github.com/swibly/swibly-api/internal/service" "github.com/swibly/swibly-api/pkg/aws" "github.com/swibly/swibly-api/pkg/middleware" + "github.com/swibly/swibly-api/pkg/notification" "github.com/swibly/swibly-api/pkg/utils" "github.com/swibly/swibly-api/translations" "golang.org/x/crypto/bcrypt" @@ -72,6 +73,12 @@ func RegisterHandler(ctx *gin.Context) { log.Print(err) ctx.JSON(http.StatusInternalServerError, gin.H{"error": dict.InternalServerError}) } else { + service.CreateNotification(dto.CreateNotification{ + Title: dict.CategoryAuth, + Message: dict.NotificationWelcomeUserRegister, + Type: notification.Information, + }, user.ID) + ctx.JSON(http.StatusOK, gin.H{"token": token}) } @@ -150,6 +157,13 @@ func LoginHandler(ctx *gin.Context) { ctx.JSON(http.StatusInternalServerError, gin.H{"error": dict.InternalServerError}) } else { + service.CreateNotification(dto.CreateNotification{ + Title: dict.CategoryAuth, + Message: dict.NotificationNewLoginDetected, + Type: notification.Warning, + Redirect: &config.Redirects.SecurityTab, + }, user.ID) + ctx.JSON(http.StatusOK, gin.H{"token": token}) } } diff --git a/internal/controller/http/v1/component.go b/internal/controller/http/v1/component.go index 04092f7..ea6feb6 100644 --- a/internal/controller/http/v1/component.go +++ b/internal/controller/http/v1/component.go @@ -2,6 +2,7 @@ package v1 import ( "errors" + "fmt" "log" "net/http" "strconv" @@ -12,6 +13,7 @@ import ( "github.com/swibly/swibly-api/internal/service" "github.com/swibly/swibly-api/internal/service/repository" "github.com/swibly/swibly-api/pkg/middleware" + "github.com/swibly/swibly-api/pkg/notification" "github.com/swibly/swibly-api/pkg/utils" "github.com/swibly/swibly-api/translations" ) @@ -145,6 +147,12 @@ func CreateComponentHandler(ctx *gin.Context) { return } + service.CreateNotification(dto.CreateNotification{ + Title: dict.CategoryComponent, + Message: fmt.Sprintf(dict.NotificationNewComponentCreated, component.Name), + Type: notification.Information, + }, issuer.ID) + ctx.JSON(http.StatusOK, gin.H{"message": dict.ComponentCreated}) } @@ -251,10 +259,10 @@ func UpdateComponentHandler(ctx *gin.Context) { func BuyComponentHandler(ctx *gin.Context) { dict := translations.GetTranslation(ctx) - issuerID := ctx.Keys["auth_user"].(*dto.UserProfile).ID - componentID := ctx.Keys["component_lookup"].(*dto.ComponentInfo).ID + issuer := ctx.Keys["auth_user"].(*dto.UserProfile) + component := ctx.Keys["component_lookup"].(*dto.ComponentInfo) - if err := service.Component.Buy(issuerID, componentID); err != nil { + if err := service.Component.Buy(issuer.ID, component.ID); err != nil { if errors.Is(err, repository.ErrInsufficientArkhoins) { ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": dict.InsufficientArkhoins}) return @@ -273,6 +281,18 @@ func BuyComponentHandler(ctx *gin.Context) { return } + service.CreateNotification(dto.CreateNotification{ + Title: dict.CategoryComponent, + Message: fmt.Sprintf(dict.NotificationYourComponentBought, component.Name, issuer.FirstName+" "+issuer.LastName), + Type: notification.Information, + }, component.OwnerID) + + service.CreateNotification(dto.CreateNotification{ + Title: dict.CategoryComponent, + Message: fmt.Sprintf(dict.NotificationYouBoughtComponent, component.Name), + Type: notification.Information, + }, issuer.ID) + ctx.JSON(http.StatusOK, gin.H{"message": dict.ComponentBought}) } @@ -316,6 +336,12 @@ func PublishComponentHandler(ctx *gin.Context) { return } + service.CreateNotification(dto.CreateNotification{ + Title: dict.CategoryComponent, + Message: fmt.Sprintf(dict.NotificationYourComponentPublished, component.Name), + Type: notification.Warning, + }, component.OwnerID) + ctx.JSON(http.StatusOK, gin.H{"message": dict.ComponentPublished}) } @@ -373,6 +399,12 @@ func DeleteComponenttForceHandler(ctx *gin.Context) { return } + service.CreateNotification(dto.CreateNotification{ + Title: dict.CategoryComponent, + Message: fmt.Sprintf(dict.NotificationDeletedComponentFromTrash, component.Name), + Type: notification.Danger, + }, component.OwnerID) + ctx.JSON(http.StatusOK, gin.H{"message": dict.ComponentDeleted}) } @@ -392,5 +424,11 @@ func RestoreComponentHandler(ctx *gin.Context) { return } + service.CreateNotification(dto.CreateNotification{ + Title: dict.CategoryComponent, + Message: fmt.Sprintf(dict.NotificationRestoredComponentFromTrash, component.Name), + Type: notification.Warning, + }, component.OwnerID) + ctx.JSON(http.StatusOK, gin.H{"message": dict.ComponentRestored}) } diff --git a/internal/controller/http/v1/notification.go b/internal/controller/http/v1/notification.go new file mode 100644 index 0000000..c581867 --- /dev/null +++ b/internal/controller/http/v1/notification.go @@ -0,0 +1,116 @@ +package v1 + +import ( + "errors" + "log" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/swibly/swibly-api/internal/model/dto" + "github.com/swibly/swibly-api/internal/service" + "github.com/swibly/swibly-api/internal/service/repository" + "github.com/swibly/swibly-api/pkg/middleware" + "github.com/swibly/swibly-api/translations" +) + +func newNotificationRoutes(handler *gin.RouterGroup) { + h := handler.Group("/notification") + h.Use(middleware.APIKeyHasEnabledUserFetch, middleware.Auth) + { + h.GET("", GetOwnNotificationsHandler) + + specific := h.Group("/:id") + { + specific.POST("/read", PostReadNotificationHandler) + specific.DELETE("/unread", DeleteUnreadNotificationHandler) + } + } +} + +func GetOwnNotificationsHandler(ctx *gin.Context) { + dict := translations.GetTranslation(ctx) + + page := 1 + perPage := 10 + onlyUnread := false + + if i, e := strconv.Atoi(ctx.Query("page")); e == nil && ctx.Query("page") != "" { + page = i + } + + if i, e := strconv.Atoi(ctx.Query("perpage")); e == nil && ctx.Query("perpage") != "" { + perPage = i + } + + unreadFlag := strings.ToLower(ctx.Query("unread")) + if unreadFlag == "true" || unreadFlag == "t" || unreadFlag == "1" { + onlyUnread = true + } + + issuer := ctx.Keys["auth_user"].(*dto.UserProfile) + + notifications, err := service.Notification.GetForUser(issuer.ID, onlyUnread, page, perPage) + if err != nil { + log.Print(err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": dict.InternalServerError}) + return + } + + ctx.JSON(http.StatusOK, notifications) +} + +func PostReadNotificationHandler(ctx *gin.Context) { + dict := translations.GetTranslation(ctx) + issuer := ctx.Keys["auth_user"].(*dto.UserProfile) + + notificationID, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + log.Print(err) + ctx.JSON(http.StatusBadRequest, gin.H{"error": dict.NotificationInvalid}) + return + } + + if err := service.Notification.MarkAsRead(*issuer, uint(notificationID)); err != nil { + switch { + case errors.Is(err, repository.ErrNotificationNotAssigned): + ctx.JSON(http.StatusForbidden, gin.H{"error": dict.NotificationNotAssigned}) + case errors.Is(err, repository.ErrNotificationAlreadyRead): + ctx.JSON(http.StatusConflict, gin.H{"error": dict.NotificationAlreadyRead}) + default: + log.Print(err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": dict.InternalServerError}) + } + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": dict.NotificationMarkedAsRead}) +} + +func DeleteUnreadNotificationHandler(ctx *gin.Context) { + dict := translations.GetTranslation(ctx) + issuer := ctx.Keys["auth_user"].(*dto.UserProfile) + + notificationID, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + log.Print(err) + ctx.JSON(http.StatusBadRequest, gin.H{"error": dict.NotificationInvalid}) + return + } + + if err := service.Notification.MarkAsUnread(*issuer, uint(notificationID)); err != nil { + switch { + case errors.Is(err, repository.ErrNotificationNotAssigned): + ctx.JSON(http.StatusForbidden, gin.H{"error": dict.NotificationNotAssigned}) + case errors.Is(err, repository.ErrNotificationNotRead): + ctx.JSON(http.StatusConflict, gin.H{"error": dict.NotificationNotRead}) + default: + log.Print(err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": dict.InternalServerError}) + } + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": dict.NotificationMarkedAsUnread}) +} diff --git a/internal/controller/http/v1/project.go b/internal/controller/http/v1/project.go index 9bbc938..5e74c74 100644 --- a/internal/controller/http/v1/project.go +++ b/internal/controller/http/v1/project.go @@ -2,17 +2,20 @@ package v1 import ( "errors" + "fmt" "log" "net/http" "strconv" "strings" "github.com/gin-gonic/gin" + "github.com/swibly/swibly-api/config" "github.com/swibly/swibly-api/internal/model/dto" "github.com/swibly/swibly-api/internal/service" "github.com/swibly/swibly-api/internal/service/repository" "github.com/swibly/swibly-api/pkg/aws" "github.com/swibly/swibly-api/pkg/middleware" + "github.com/swibly/swibly-api/pkg/notification" "github.com/swibly/swibly-api/pkg/utils" "github.com/swibly/swibly-api/translations" ) @@ -52,7 +55,7 @@ func newProjectRoutes(handler *gin.RouterGroup) { specific.DELETE("/unpublish", middleware.ProjectIsAllowed(dto.Allow{Publish: true}), UnpublishProjectHandler) specific.DELETE("/unfavorite", middleware.ProjectIsAllowed(dto.Allow{View: true}), UnfavoriteProjectHandler) specific.DELETE("/fork", middleware.ProjectIsAllowed(dto.Allow{Manage: dto.AllowManage{Metadata: true}}), UnlinkProjectHandler) - specific.DELETE("/leave", middleware.ProjectIsMember, LeaveProjectHandler) + specific.DELETE("/leave", middleware.ProjectIsMember, LeaveProjectHandler) trashActions := specific.Group("/trash") { @@ -177,6 +180,13 @@ func CreateProjectHandler(ctx *gin.Context) { ctx.JSON(http.StatusInternalServerError, gin.H{"error": dict.InternalServerError}) return } else { + service.CreateNotification(dto.CreateNotification{ + Title: dict.CategoryProject, + Message: fmt.Sprintf(dict.NotificationNewProjectCreated, project.Name), + Type: notification.Information, + Redirect: utils.ToPtr(fmt.Sprintf(config.Redirects.Project, id)), + }, issuer.ID) + ctx.JSON(http.StatusOK, gin.H{"message": dict.ProjectCreated, "project": id}) } } @@ -276,6 +286,20 @@ func ForkProjectHandler(ctx *gin.Context) { ctx.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": dict.InternalServerError}) return } else { + service.CreateNotification(dto.CreateNotification{ + Title: dict.CategoryProject, + Message: fmt.Sprintf(dict.NotificationUserClonedYourProject, issuer.FirstName+" "+issuer.LastName, project.Name), + Type: notification.Information, + Redirect: utils.ToPtr(fmt.Sprintf(config.Redirects.Project, id)), + }, project.OwnerID) + + service.CreateNotification(dto.CreateNotification{ + Title: dict.CategoryProject, + Message: fmt.Sprintf(dict.NotificationNewProjectCreated, project.Name), + Type: notification.Information, + Redirect: utils.ToPtr(fmt.Sprintf(config.Redirects.Project, id)), + }, issuer.ID) + ctx.JSON(http.StatusOK, gin.H{"message": dict.ProjectForked, "project": id}) } } @@ -428,6 +452,18 @@ func PublishProjectHandler(ctx *gin.Context) { return } + ids := []uint{project.OwnerID} + + for _, user := range project.AllowedUsers { + ids = append(ids, user.ID) + } + + service.CreateNotification(dto.CreateNotification{ + Title: dict.CategoryProject, + Message: fmt.Sprintf(dict.NotificationYourProjectPublished, project.Name), + Type: notification.Warning, + }, ids...) + ctx.JSON(http.StatusOK, gin.H{"message": dict.ProjectPublished}) } @@ -467,6 +503,12 @@ func FavoriteProjectHandler(ctx *gin.Context) { return } + service.CreateNotification(dto.CreateNotification{ + Title: dict.CategoryProject, + Message: fmt.Sprintf(dict.NotificationYourProjectFavorited, project.Name, issuer.FirstName+" "+issuer.LastName), + Type: notification.Information, + }, project.OwnerID) + ctx.JSON(http.StatusOK, gin.H{"message": dict.ProjectFavorited}) } @@ -525,6 +567,13 @@ func RestoreProjectHandler(ctx *gin.Context) { return } + service.CreateNotification(dto.CreateNotification{ + Title: dict.CategoryProject, + Message: fmt.Sprintf(dict.NotificationRestoredProjectFromTrash, project.Name), + Type: notification.Warning, + Redirect: utils.ToPtr(fmt.Sprintf(config.Redirects.Project, project.ID)), + }, project.OwnerID) + ctx.JSON(http.StatusOK, gin.H{"message": dict.ProjectRestored}) } @@ -544,6 +593,12 @@ func DeleteProjectForceHandler(ctx *gin.Context) { return } + service.CreateNotification(dto.CreateNotification{ + Title: dict.CategoryProject, + Message: fmt.Sprintf(dict.NotificationDeletedProjectFromTrash, project.Name), + Type: notification.Danger, + }, project.OwnerID) + ctx.JSON(http.StatusOK, gin.H{"message": dict.ProjectDeleted}) } @@ -595,6 +650,19 @@ func AssignProjectHandler(ctx *gin.Context) { return } + service.CreateNotification(dto.CreateNotification{ + Title: dict.CategoryProject, + Message: fmt.Sprintf(dict.NotificationAddedUserToProject, user.FirstName+user.LastName, project.Name), + Type: notification.Danger, + }, project.OwnerID) + + service.CreateNotification(dto.CreateNotification{ + Title: dict.CategoryProject, + Message: fmt.Sprintf(dict.NotificationAddedYouToProject, project.Name), + Type: notification.Information, + Redirect: utils.ToPtr(fmt.Sprintf(config.Redirects.Project, project.ID)), + }, user.ID) + ctx.JSON(http.StatusOK, gin.H{"message": dict.ProjectAssignedUser}) } @@ -615,5 +683,18 @@ func UnassignProjectHandler(ctx *gin.Context) { return } + service.CreateNotification(dto.CreateNotification{ + Title: dict.CategoryProject, + Message: fmt.Sprintf(dict.NotificationRemovedUserFromProject, user.FirstName+user.LastName, project.Name), + Type: notification.Danger, + }, project.OwnerID) + + service.CreateNotification(dto.CreateNotification{ + Title: dict.CategoryProject, + Message: fmt.Sprintf(dict.NotificationRemovedYouFromProject, project.Name), + Type: notification.Information, + Redirect: utils.ToPtr(fmt.Sprintf(config.Redirects.Project, project.ID)), + }, user.ID) + ctx.JSON(http.StatusOK, gin.H{"message": dict.ProjectUnassignedUser}) } diff --git a/internal/controller/http/v1/router.go b/internal/controller/http/v1/router.go index dccc4d2..1684abc 100644 --- a/internal/controller/http/v1/router.go +++ b/internal/controller/http/v1/router.go @@ -12,5 +12,6 @@ func NewRouter(handler *gin.Engine) { newSearchRoutes(g) newProjectRoutes(g) newComponentRoutes(g) + newNotificationRoutes(g) } } diff --git a/internal/controller/http/v1/user.go b/internal/controller/http/v1/user.go index dd00aa6..c9e6fa0 100644 --- a/internal/controller/http/v1/user.go +++ b/internal/controller/http/v1/user.go @@ -7,9 +7,12 @@ import ( "strconv" "github.com/gin-gonic/gin" + "github.com/swibly/swibly-api/config" "github.com/swibly/swibly-api/internal/model/dto" "github.com/swibly/swibly-api/internal/service" "github.com/swibly/swibly-api/pkg/middleware" + "github.com/swibly/swibly-api/pkg/notification" + "github.com/swibly/swibly-api/pkg/utils" "github.com/swibly/swibly-api/translations" ) @@ -116,6 +119,13 @@ func FollowUserHandler(ctx *gin.Context) { return } + service.CreateNotification(dto.CreateNotification{ + Title: dict.CategoryFollowers, + Message: fmt.Sprintf(dict.NotificationUserFollowedYou, issuer.FirstName+" "+issuer.LastName), + Type: notification.Information, + Redirect: utils.ToPtr(fmt.Sprintf(config.Redirects.Profile, issuer.Username)), + }, receiver.ID) + ctx.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf(dict.UserFollowingStarted, receiver.Username)}) } diff --git a/internal/model/dto/notification.go b/internal/model/dto/notification.go new file mode 100644 index 0000000..43c2146 --- /dev/null +++ b/internal/model/dto/notification.go @@ -0,0 +1,29 @@ +package dto + +import ( + "time" + + "github.com/swibly/swibly-api/pkg/notification" +) + +type CreateNotification struct { + Title string `json:"title" validate:"required,max=255"` + Message string `json:"message" validate:"required"` + Type notification.NotificationType `json:"type" validate:"required,mustbenotificationtype"` + Redirect *string `json:"redirect" validate:"omitempty,url"` +} + +type NotificationInfo struct { + ID uint `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + Title string `json:"title"` + Message string `json:"message"` + + Type notification.NotificationType `json:"type"` + Redirect *string `json:"redirect"` + + ReadAt *time.Time `json:"read_at"` + IsRead bool `json:"is_read"` +} diff --git a/internal/model/dto/user.go b/internal/model/dto/user.go index fb8212a..4100e33 100644 --- a/internal/model/dto/user.go +++ b/internal/model/dto/user.go @@ -98,7 +98,8 @@ type UserProfile struct { Country string `json:"country"` Language string `json:"language"` - Permissions []string `gorm:"-" json:"permissions"` + Permissions []string `gorm:"-" json:"permissions"` + UnreadNotifications int64 `gorm:"-" json:"unread_notifications"` ProfilePicture string `json:"pfp"` } diff --git a/internal/model/notification.go b/internal/model/notification.go new file mode 100644 index 0000000..5672dda --- /dev/null +++ b/internal/model/notification.go @@ -0,0 +1,40 @@ +package model + +import ( + "time" + + "github.com/swibly/swibly-api/pkg/notification" + "gorm.io/gorm" +) + +type Notification struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + + Title string + Message string + + Type notification.NotificationType `gorm:"type:notification_type;default:'information'"` + + Redirect *string +} + +type NotificationUser struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + + NotificationID uint `gorm:"not null;index"` + UserID uint `gorm:"not null;index"` +} + +type NotificationUserRead struct { + ID uint `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + + NotificationID uint `gorm:"not null;index"` + UserID uint `gorm:"not null;index"` +} diff --git a/internal/service/init.go b/internal/service/init.go index 3cb0637..57a03aa 100644 --- a/internal/service/init.go +++ b/internal/service/init.go @@ -10,6 +10,7 @@ var ( Project usecase.ProjectUseCase Component usecase.ComponentUseCase PasswordReset usecase.PasswordResetUseCase + Notification usecase.NotificationUseCase ) func Init() { @@ -20,4 +21,5 @@ func Init() { Project = usecase.NewProjectUseCase() Component = usecase.NewComponentUseCase() PasswordReset = usecase.NewPasswordResetUseCase() + Notification = usecase.NewNotificationUseCase() } diff --git a/internal/service/repository/notification.go b/internal/service/repository/notification.go new file mode 100644 index 0000000..eeacc61 --- /dev/null +++ b/internal/service/repository/notification.go @@ -0,0 +1,229 @@ +package repository + +import ( + "errors" + + "github.com/swibly/swibly-api/internal/model" + "github.com/swibly/swibly-api/internal/model/dto" + "github.com/swibly/swibly-api/pkg/db" + "github.com/swibly/swibly-api/pkg/pagination" + "gorm.io/gorm" +) + +type notificationRepository struct { + db *gorm.DB +} + +type NotificationRepository interface { + Create(createModel dto.CreateNotification) (uint, error) + + GetForUser(userID uint, onlyUnread bool, page, perPage int) (*dto.Pagination[dto.NotificationInfo], error) + GetUnreadCount(userID uint) (int64, error) + + SendToAll(notificationID uint) error + SendToIDs(notificationID uint, usersID []uint) error + + UnsendToAll(notificationID uint) error + UnsendToIDs(notificationID uint, usersID []uint) error + + MarkAsRead(issuer dto.UserProfile, notificationID uint) error + MarkAsUnread(issuer dto.UserProfile, notificationID uint) error +} + +var ( + ErrNotificationAlreadyRead = errors.New("notification already marked as read") + ErrNotificationNotRead = errors.New("notification is not marked as read") + ErrNotificationNotAssigned = errors.New("notification not assigned to user") +) + +func NewNotificationRepository() NotificationRepository { + return ¬ificationRepository{db: db.Postgres} +} + +func (nr *notificationRepository) baseNotificationQuery(userID uint) *gorm.DB { + return nr.db.Table("notification_users AS nu"). + Select(` + n.id AS id, + n.created_at AS created_at, + n.updated_at AS updated_at, + n.title AS title, + n.message AS message, + n.type AS type, + n.redirect AS redirect, + nur.created_at AS read_at, + CASE WHEN nur.created_at IS NOT NULL THEN true ELSE false END AS is_read + `). + Joins("JOIN notifications AS n ON n.id = nu.notification_id"). + Joins("LEFT JOIN notification_user_reads AS nur ON n.id = nur.notification_id AND nur.user_id = ?", userID). + Where("nu.user_id = ?", userID) +} + +func (nr *notificationRepository) Create(createModel dto.CreateNotification) (uint, error) { + notification := &model.Notification{ + Title: createModel.Title, + Message: createModel.Message, + Type: createModel.Type, + Redirect: createModel.Redirect, + } + if err := nr.db.Create(notification).Error; err != nil { + return 0, err + } + + return notification.ID, nil +} + +func (nr *notificationRepository) GetForUser(userID uint, onlyUnread bool, page, perPage int) (*dto.Pagination[dto.NotificationInfo], error) { + query := nr.baseNotificationQuery(userID).Order("n.created_at DESC") + + if onlyUnread { + query = query.Where("is_read IS false") + } + + paginationResult, err := pagination.Generate[dto.NotificationInfo](query, page, perPage) + if err != nil { + return nil, err + } + + return paginationResult, nil +} + +func (nr *notificationRepository) GetUnreadCount(userID uint) (int64, error) { + count := int64(0) + if err := nr.baseNotificationQuery(userID).Where("is_read IS false").Count(&count).Error; err != nil { + return 0, err + } + + return count, nil +} + +func (nr *notificationRepository) SendToAll(notificationID uint) error { + tx := nr.db.Begin() + + var userIDs []uint + if err := tx.Model(&model.User{}).Select("id").Find(&userIDs).Error; err != nil { + tx.Rollback() + return err + } + + notifications := make([]model.NotificationUser, len(userIDs)) + for i, userID := range userIDs { + notifications[i] = model.NotificationUser{ + NotificationID: notificationID, + UserID: userID, + } + } + + if err := tx.Create(¬ifications).Error; err != nil { + tx.Rollback() + return err + } + + return tx.Commit().Error +} + +func (nr *notificationRepository) SendToIDs(notificationID uint, userIDs []uint) error { + tx := nr.db.Begin() + + notifications := make([]model.NotificationUser, len(userIDs)) + for i, userID := range userIDs { + notifications[i] = model.NotificationUser{ + NotificationID: notificationID, + UserID: userID, + } + } + + if err := tx.Create(¬ifications).Error; err != nil { + tx.Rollback() + return err + } + + return tx.Commit().Error +} + +func (nr *notificationRepository) UnsendToAll(notificationID uint) error { + if err := nr.db.Where("notification_id = ?", notificationID).Delete(&model.NotificationUser{}).Error; err != nil { + return err + } + + if err := nr.db.Where("notification_id = ?", notificationID).Delete(&model.NotificationUserRead{}).Error; err != nil { + return err + } + + return nil +} + +func (nr *notificationRepository) UnsendToIDs(notificationID uint, userIDs []uint) error { + tx := nr.db.Begin() + + if len(userIDs) == 0 { + return nil + } + + if err := tx.Where("notification_id = ? AND user_id IN ?", notificationID, userIDs).Delete(&model.NotificationUser{}).Error; err != nil { + return err + } + + if err := tx.Where("notification_id = ? AND user_id IN ?", notificationID, userIDs).Delete(&model.NotificationUserRead{}).Error; err != nil { + return err + } + + return nil +} + +func (nr *notificationRepository) MarkAsRead(issuer dto.UserProfile, notificationID uint) error { + tx := nr.db.Begin() + + var notificationUser model.NotificationUser + if err := tx.Where("notification_id = ? AND user_id = ?", notificationID, issuer.ID).First(¬ificationUser).Error; err == gorm.ErrRecordNotFound { + tx.Rollback() + return ErrUserNotAssigned + } else if err != nil { + tx.Rollback() + return err + } + + var existingRead model.NotificationUserRead + if err := tx.Where("notification_id = ? AND user_id = ?", notificationID, issuer.ID).First(&existingRead).Error; err == nil { + tx.Rollback() + return ErrNotificationAlreadyRead + } else if err != gorm.ErrRecordNotFound { + tx.Rollback() + return err + } + + if err := tx.Create(&model.NotificationUserRead{NotificationID: notificationID, UserID: issuer.ID}).Error; err != nil { + tx.Rollback() + return err + } + + return tx.Commit().Error +} + +func (nr *notificationRepository) MarkAsUnread(issuer dto.UserProfile, notificationID uint) error { + tx := nr.db.Begin() + + var notificationUser model.NotificationUser + if err := tx.Where("notification_id = ? AND user_id = ?", notificationID, issuer.ID).First(¬ificationUser).Error; err == gorm.ErrRecordNotFound { + tx.Rollback() + return ErrUserNotAssigned + } else if err != nil { + tx.Rollback() + return err + } + + var existingRead model.NotificationUserRead + if err := tx.Where("notification_id = ? AND user_id = ?", notificationID, issuer.ID).First(&existingRead).Error; err == gorm.ErrRecordNotFound { + tx.Rollback() + return ErrNotificationNotRead + } else if err != nil { + tx.Rollback() + return err + } + + if err := tx.Where("notification_id = ? AND user_id = ?", notificationID, issuer.ID).Delete(&model.NotificationUserRead{}).Error; err != nil { + tx.Rollback() + return err + } + + return tx.Commit().Error +} diff --git a/internal/service/repository/user.go b/internal/service/repository/user.go index 8716e6d..9605abe 100644 --- a/internal/service/repository/user.go +++ b/internal/service/repository/user.go @@ -13,8 +13,9 @@ import ( type userRepository struct { db *gorm.DB - followRepo FollowRepository - permissionRepo PermissionRepository + followRepo FollowRepository + permissionRepo PermissionRepository + notificationRepo NotificationRepository } type UserRepository interface { @@ -31,7 +32,7 @@ type UserRepository interface { } func NewUserRepository() UserRepository { - return &userRepository{db: db.Postgres, followRepo: NewFollowRepository(), permissionRepo: NewPermissionRepository()} + return &userRepository{db: db.Postgres, followRepo: NewFollowRepository(), permissionRepo: NewPermissionRepository(), notificationRepo: NewNotificationRepository()} } func (u userRepository) Create(createModel *model.User) error { @@ -81,6 +82,12 @@ func (u userRepository) Get(searchModel *model.User) (*dto.UserProfile, error) { } } + if count, err := u.notificationRepo.GetUnreadCount(user.ID); err != nil { + return nil, err + } else { + user.UnreadNotifications = count + } + return user, nil } diff --git a/internal/service/usecase/notification.go b/internal/service/usecase/notification.go new file mode 100644 index 0000000..16f5c1e --- /dev/null +++ b/internal/service/usecase/notification.go @@ -0,0 +1,46 @@ +package usecase + +import ( + "github.com/swibly/swibly-api/internal/model/dto" + "github.com/swibly/swibly-api/internal/service/repository" +) + +type NotificationUseCase struct { + nr repository.NotificationRepository +} + +func NewNotificationUseCase() NotificationUseCase { + return NotificationUseCase{nr: repository.NewNotificationRepository()} +} + +func (nuc *NotificationUseCase) Create(createModel dto.CreateNotification) (uint, error) { + return nuc.nr.Create(createModel) +} + +func (nuc *NotificationUseCase) GetForUser(userID uint, onlyUnread bool, page, perPage int) (*dto.Pagination[dto.NotificationInfo], error) { + return nuc.nr.GetForUser(userID, onlyUnread, page, perPage) +} + +func (nuc *NotificationUseCase) SendToAll(notificationID uint) error { + return nuc.nr.SendToAll(notificationID) +} + +func (nuc *NotificationUseCase) SendToIDs(notificationID uint, usersID []uint) error { + return nuc.nr.SendToIDs(notificationID, usersID) +} + +func (nuc *NotificationUseCase) UnsendToAll(notificationID uint) error { + return nuc.nr.UnsendToAll(notificationID) +} + +func (nuc *NotificationUseCase) UnsendToIDs(notificationID uint, usersID []uint) error { + return nuc.nr.UnsendToIDs(notificationID, usersID) +} + +func (nuc *NotificationUseCase) MarkAsRead(issuer dto.UserProfile, notificationID uint) error { + return nuc.nr.MarkAsRead(issuer, notificationID) +} + +func (nuc *NotificationUseCase) MarkAsUnread(issuer dto.UserProfile, notificationID uint) error { + return nuc.nr.MarkAsUnread(issuer, notificationID) +} diff --git a/internal/service/utils.go b/internal/service/utils.go new file mode 100644 index 0000000..1c74b79 --- /dev/null +++ b/internal/service/utils.go @@ -0,0 +1,16 @@ +package service + +import "github.com/swibly/swibly-api/internal/model/dto" + +func CreateNotification(createModel dto.CreateNotification, ids ...uint) error { + notification, err := Notification.Create(createModel) + if err != nil { + return err + } + + if err := Notification.SendToIDs(notification, ids); err != nil { + return err + } + + return nil +} diff --git a/pkg/db/postgres.go b/pkg/db/postgres.go index 11dd336..4988ba8 100644 --- a/pkg/db/postgres.go +++ b/pkg/db/postgres.go @@ -11,6 +11,7 @@ import ( "github.com/swibly/swibly-api/config" "github.com/swibly/swibly-api/internal/model" "github.com/swibly/swibly-api/pkg/language" + "github.com/swibly/swibly-api/pkg/notification" "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -83,6 +84,10 @@ func Load() { log.Fatal(err) } + if err := typeCheckAndCreate(db, "notification_type", notification.ArrayString); err != nil { + log.Fatal(err) + } + models := []any{ &model.APIKey{}, &model.User{}, @@ -101,6 +106,10 @@ func Load() { &model.ComponentOwner{}, &model.ComponentHolder{}, &model.ComponentPublication{}, + + &model.Notification{}, + &model.NotificationUser{}, + &model.NotificationUserRead{}, } if err := db.AutoMigrate(models...); err != nil { diff --git a/pkg/notification/general.go b/pkg/notification/general.go new file mode 100644 index 0000000..5a138c5 --- /dev/null +++ b/pkg/notification/general.go @@ -0,0 +1,14 @@ +package notification + +type NotificationType string + +const ( + Information NotificationType = "information" + Warning NotificationType = "warning" + Danger NotificationType = "danger" +) + +var ( + Array = []NotificationType{Information, Warning, Danger} + ArrayString = []string{string(Information), string(Warning), string(Danger)} +) diff --git a/pkg/utils/validator.go b/pkg/utils/validator.go index 22012d9..24e384f 100644 --- a/pkg/utils/validator.go +++ b/pkg/utils/validator.go @@ -5,10 +5,11 @@ import ( "regexp" "strings" - "github.com/swibly/swibly-api/pkg/language" - "github.com/swibly/swibly-api/translations" "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" + "github.com/swibly/swibly-api/pkg/language" + "github.com/swibly/swibly-api/pkg/notification" + "github.com/swibly/swibly-api/translations" ) var Validate *validator.Validate = newValidator() @@ -31,6 +32,16 @@ func newValidator() *validator.Validate { return nn == 1 || nn == 0 || nn == -1 }) + vv.RegisterValidation("mustbenotificationtype", func(fl validator.FieldLevel) bool { + _, valid := fl.Field().Interface().(notification.NotificationType) + + if !valid { + return false + } + + return true + }) + vv.RegisterValidation("mustbesupportedlanguage", func(fl validator.FieldLevel) bool { lang := fl.Field().String() diff --git a/translations/configure.go b/translations/configure.go index 35a3de2..e1c8e23 100644 --- a/translations/configure.go +++ b/translations/configure.go @@ -16,6 +16,11 @@ type Translation struct { MaximumAPIKey string `yaml:"maximum_api_key"` RequirePermissionAPIKey string `yaml:"require_permission_api_key"` + CategoryAuth string `yaml:"category_auth"` + CategoryFollowers string `yaml:"category_followers"` + CategoryProject string `yaml:"category_project"` + CategoryComponent string `yaml:"category_component"` + InternalServerError string `yaml:"internal_server_error"` Unauthorized string `yaml:"unauthorized"` InvalidBody string `yaml:"invalid_body"` @@ -29,6 +34,34 @@ type Translation struct { AuthUserUpdated string `yaml:"auth_user_updated"` AuthWrongCredentials string `yaml:"auth_wrong_credentials"` + NotificationWelcomeUserRegister string `yaml:"notification_welcome_user_register"` + NotificationNewLoginDetected string `yaml:"notification_new_login_detected"` + NotificationUserFollowedYou string `yaml:"notification_user_followed_you"` + NotificationNewProjectCreated string `yaml:"notification_new_project_created"` + NotificationUserClonedYourProject string `yaml:"notification_user_cloned_your_project"` + NotificationYourProjectPublished string `yaml:"notification_your_project_published"` + NotificationYourProjectFavorited string `yaml:"notification_your_project_favorited"` + NotificationDeletedProjectFromTrash string `yaml:"notification_deleted_project_from_trash"` + NotificationRestoredProjectFromTrash string `yaml:"notification_restored_project_from_trash"` + NotificationAddedUserToProject string `yaml:"notification_added_user_to_project"` + NotificationRemovedUserFromProject string `yaml:"notification_removed_user_from_project"` + NotificationAddedYouToProject string `yaml:"notification_added_you_to_project"` + NotificationRemovedYouFromProject string `yaml:"notification_removed_you_from_project"` + NotificationUserLeftProject string `yaml:"notification_user_left_project"` + NotificationNewComponentCreated string `yaml:"notification_new_component_created"` + NotificationYourComponentPublished string `yaml:"notification_your_component_published"` + NotificationDeletedComponentFromTrash string `yaml:"notification_deleted_component_from_trash"` + NotificationRestoredComponentFromTrash string `yaml:"notification_restored_component_from_trash"` + NotificationYourComponentBought string `yaml:"notification_your_component_bought"` + NotificationYouBoughtComponent string `yaml:"notification_you_bought_component"` + + NotificationInvalid string `yaml:"notification_invalid"` + NotificationAlreadyRead string `yaml:"notification_already_read"` + NotificationNotRead string `yaml:"notification_not_read"` + NotificationNotAssigned string `yaml:"notification_not_assigned"` + NotificationMarkedAsRead string `yaml:"notification_marked_as_read"` + NotificationMarkedAsUnread string `yaml:"notification_marked_as_unread"` + SearchIncorrect string `yaml:"search_incorrect"` SearchNoResults string `yaml:"search_no_results"` diff --git a/translations/en.yaml b/translations/en.yaml index 8769b46..58ad9f9 100644 --- a/translations/en.yaml +++ b/translations/en.yaml @@ -5,6 +5,36 @@ auth_user_deleted: User deleted. auth_user_updated: User updated. auth_wrong_credentials: Email, username or password are incorrect or don't exist. hello: Hello! +category_auth: Authentication +category_followers: Followers +category_project: Project +category_component: Component +notification_welcome_user_register: Welcome, %s! Thank you for registering. +notification_new_login_detected: New login detected from a different device. +notification_user_followed_you: "%s has started following you." +notification_new_project_created: The project "%s" has been created. +notification_user_cloned_your_project: '%s has cloned your project "%s."' +notification_your_project_published: Your project "%s" has been published. +notification_your_project_favorited: Your project "%s" has been added to favorites by %s. +notification_deleted_project_from_trash: The project "%s" has been deleted from trash. +notification_restored_project_from_trash: The project "%s" has been restored from trash. +notification_added_user_to_project: '%s has been added to the project "%s."' +notification_removed_user_from_project: '%s has been removed from the project "%s."' +notification_added_you_to_project: You have been added to the project "%s." +notification_removed_you_from_project: You have been removed from the project "%s." +notification_user_left_project: '%s has left the project "%s."' +notification_new_component_created: The component "%s" has been created. +notification_your_component_published: Your component "%s" has been published. +notification_deleted_component_from_trash: The component "%s" has been deleted from trash. +notification_restored_component_from_trash: The component "%s" has been restored from trash. +notification_your_component_bought: Your component "%s" has been purchased by %s. +notification_you_bought_component: You have purchased the component "%s." +notification_invalid: The provided ID is invalid. Please check and try again. +notification_already_read: This notification has already been read. +notification_not_read: This notification has not been read yet. +notification_not_assigned: This notification is not assigned. +notification_marked_as_read: The notification has been marked as read. +notification_marked_as_unread: The notification has been marked as unread. internal_server_error: Internal server error. Please, try again later. invalid_api_key: Invalid API key. invalid_body: diff --git a/translations/pt.yaml b/translations/pt.yaml index 6841a1c..343ad43 100644 --- a/translations/pt.yaml +++ b/translations/pt.yaml @@ -5,6 +5,36 @@ auth_user_deleted: Usuário deletado. auth_user_updated: Usuário atualizado. auth_wrong_credentials: Email, nome de usuário ou senha estão incorretos ou não existem. hello: Olá! +category_auth: Autenticação +category_followers: Seguidores +category_project: Projeto +category_component: Componente +notification_welcome_user_register: Bem-vindo(a), %s! Obrigado por se registrar. +notification_new_login_detected: Novo login detectado a partir de outro dispositivo. +notification_user_followed_you: "%s começou a seguir você." +notification_new_project_created: O projeto "%s" foi criado. +notification_user_cloned_your_project: '%s clonou o seu projeto "%s."' +notification_your_project_published: Seu projeto "%s" foi publicado. +notification_your_project_favorited: Seu projeto "%s" foi adicionado aos favoritos de %s. +notification_deleted_project_from_trash: O projeto "%s" foi excluído da lixeira. +notification_restored_project_from_trash: O projeto "%s" foi restaurado da lixeira. +notification_added_user_to_project: '%s foi adicionado(a) ao projeto "%s."' +notification_removed_user_from_project: '%s foi removido(a) do projeto "%s."' +notification_added_you_to_project: Você foi adicionado(a) ao projeto "%s." +notification_removed_you_from_project: Você foi removido(a) do projeto "%s." +notification_user_left_project: '%s saiu do projeto "%s."' +notification_new_component_created: O componente "%s" foi criado. +notification_your_component_published: Seu componente "%s" foi publicado. +notification_deleted_component_from_trash: O componente "%s" foi excluído da lixeira. +notification_restored_component_from_trash: O componente "%s" foi restaurado da lixeira. +notification_your_component_bought: Seu componente "%s" foi comprado por %s. +notification_you_bought_component: Você comprou o componente "%s." +notification_invalid: O ID fornecido é inválido. Verifique e tente novamente. +notification_already_read: Esta notificação já foi lida. +notification_not_read: Esta notificação ainda não foi lida. +notification_not_assigned: Esta notificação não está atribuída. +notification_marked_as_read: A notificação foi marcada como lida. +notification_marked_as_unread: A notificação foi marcada como não lida. internal_server_error: Erro interno de servidor. Por favor, tente novamente mais tarde. invalid_api_key: Chave de API inválida. invalid_body: diff --git a/translations/ru.yaml b/translations/ru.yaml index 4523dc2..467f1ba 100644 --- a/translations/ru.yaml +++ b/translations/ru.yaml @@ -5,6 +5,36 @@ auth_user_deleted: Пользователь удален. auth_user_updated: Пользователь обновлен. auth_wrong_credentials: Электронная почта, имя пользователя или пароль неверны или не существуют. hello: Привет! +category_auth: Авторизация +category_followers: Подписчики +category_project: Проект +category_component: Компонент +notification_welcome_user_register: Добро пожаловать, %s! Спасибо за регистрацию. +notification_new_login_detected: Обнаружен новый вход с другого устройства. +notification_user_followed_you: "%s начал(а) следовать за вами." +notification_new_project_created: Проект "%s" был создан. +notification_user_cloned_your_project: '%s склонировал ваш проект "%s."' +notification_your_project_published: Ваш проект "%s" был опубликован. +notification_your_project_favorited: Ваш проект "%s" добавлен в избранное пользователем %s. +notification_deleted_project_from_trash: Проект "%s" был удален из корзины. +notification_restored_project_from_trash: Проект "%s" был восстановлен из корзины. +notification_added_user_to_project: '%s был добавлен в проект "%s."' +notification_removed_user_from_project: '%s был удален из проекта "%s."' +notification_added_you_to_project: Вы были добавлены в проект "%s." +notification_removed_you_from_project: Вы были удалены из проекта "%s." +notification_user_left_project: '%s покинул проект "%s."' +notification_new_component_created: Компонент "%s" был создан. +notification_your_component_published: Ваш компонент "%s" был опубликован. +notification_deleted_component_from_trash: Компонент "%s" был удален из корзины. +notification_restored_component_from_trash: Компонент "%s" был восстановлен из корзины. +notification_your_component_bought: Ваш компонент "%s" был приобретен пользователем %s. +notification_you_bought_component: Вы приобрели компонент "%s." +notification_invalid: Указанный идентификатор недействителен. Пожалуйста, проверьте и попробуйте снова. +notification_already_read: Это уведомление уже было прочитано. +notification_not_read: Это уведомление ещё не прочитано. +notification_not_assigned: Это уведомление не назначено. +notification_marked_as_read: Уведомление помечено как прочитанное. +notification_marked_as_unread: Уведомление помечено как непрочитанное. internal_server_error: Внутренняя ошибка сервера. Пожалуйста, попробуйте позже. invalid_api_key: Неверный ключ API. invalid_body: