diff --git a/pkg/internal/database/migrator.go b/pkg/internal/database/migrator.go index 0b45570..2ba081e 100644 --- a/pkg/internal/database/migrator.go +++ b/pkg/internal/database/migrator.go @@ -11,6 +11,7 @@ var AutoMaintainRange = []any{ &models.Category{}, &models.Tag{}, &models.Post{}, + &models.Article{}, &models.Reaction{}, } diff --git a/pkg/internal/models/articles.go b/pkg/internal/models/articles.go new file mode 100644 index 0000000..67496ee --- /dev/null +++ b/pkg/internal/models/articles.go @@ -0,0 +1,32 @@ +package models + +import ( + "time" + + "gorm.io/datatypes" +) + +type Article struct { + BaseModel + + Alias string `json:"alias" gorm:"uniqueIndex"` + Title string `json:"title"` + Description string `json:"description"` + Content string `json:"content"` + Tags []Tag `json:"tags" gorm:"many2many:article_tags"` + Categories []Category `json:"categories" gorm:"many2many:article_categories"` + Reactions []Reaction `json:"reactions"` + Attachments datatypes.JSONSlice[uint] `json:"attachments"` + RealmID *uint `json:"realm_id"` + Realm *Realm `json:"realm"` + + IsDraft bool `json:"is_draft"` + PublishedAt *time.Time `json:"published_at"` + + AuthorID uint `json:"author_id"` + Author Account `json:"author"` + + // Dynamic Calculated Values + ReactionCount int64 `json:"reaction_count"` + ReactionList map[string]int64 `json:"reaction_list" gorm:"-"` +} diff --git a/pkg/internal/models/categories.go b/pkg/internal/models/categories.go index 6a52e67..1eb7b37 100644 --- a/pkg/internal/models/categories.go +++ b/pkg/internal/models/categories.go @@ -3,17 +3,19 @@ package models type Tag struct { BaseModel - Alias string `json:"alias" gorm:"uniqueIndex" validate:"lowercase,alphanum,min=4,max=24"` - Name string `json:"name"` - Description string `json:"description"` - Posts []Post `json:"posts" gorm:"many2many:post_tags"` + Alias string `json:"alias" gorm:"uniqueIndex" validate:"lowercase,alphanum,min=4,max=24"` + Name string `json:"name"` + Description string `json:"description"` + Posts []Post `json:"posts" gorm:"many2many:post_tags"` + Articles []Article `json:"articles" gorm:"many2many:article_tags"` } type Category struct { BaseModel - Alias string `json:"alias" gorm:"uniqueIndex" validate:"lowercase,alphanum,min=4,max=24"` - Name string `json:"name"` - Description string `json:"description"` - Posts []Post `json:"posts" gorm:"many2many:post_categories"` + Alias string `json:"alias" gorm:"uniqueIndex" validate:"lowercase,alphanum,min=4,max=24"` + Name string `json:"name"` + Description string `json:"description"` + Posts []Post `json:"posts" gorm:"many2many:post_categories"` + Articles []Article `json:"articles" gorm:"many2many:article_categories"` } diff --git a/pkg/internal/models/posts.go b/pkg/internal/models/posts.go index 7848ef7..358fbf4 100644 --- a/pkg/internal/models/posts.go +++ b/pkg/internal/models/posts.go @@ -1,17 +1,10 @@ package models import ( - "gorm.io/datatypes" "time" -) -type PostReactInfo struct { - PostID uint `json:"post_id"` - LikeCount int64 `json:"like_count"` - DislikeCount int64 `json:"dislike_count"` - ReplyCount int64 `json:"reply_count"` - RepostCount int64 `json:"repost_count"` -} + "gorm.io/datatypes" +) type Post struct { BaseModel @@ -30,6 +23,7 @@ type Post struct { RepostTo *Post `json:"repost_to" gorm:"foreignKey:RepostID"` Realm *Realm `json:"realm"` + IsDraft bool `json:"is_draft"` PublishedAt *time.Time `json:"published_at"` AuthorID uint `json:"author_id"` diff --git a/pkg/internal/models/reactions.go b/pkg/internal/models/reactions.go index da1762a..98231c5 100644 --- a/pkg/internal/models/reactions.go +++ b/pkg/internal/models/reactions.go @@ -21,5 +21,6 @@ type Reaction struct { Attitude ReactionAttitude `json:"attitude"` PostID *uint `json:"post_id"` + ArticleID *uint `json:"article_id"` AccountID uint `json:"account_id"` } diff --git a/pkg/internal/server/api/articles_api.go b/pkg/internal/server/api/articles_api.go new file mode 100644 index 0000000..6e1c341 --- /dev/null +++ b/pkg/internal/server/api/articles_api.go @@ -0,0 +1,279 @@ +package api + +import ( + "fmt" + "git.solsynth.dev/hydrogen/interactive/pkg/internal/database" + "git.solsynth.dev/hydrogen/interactive/pkg/internal/gap" + "git.solsynth.dev/hydrogen/interactive/pkg/internal/models" + "git.solsynth.dev/hydrogen/interactive/pkg/internal/server/exts" + "git.solsynth.dev/hydrogen/interactive/pkg/internal/services" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/samber/lo" + "strings" + "time" +) + +func getArticle(c *fiber.Ctx) error { + alias := c.Params("article") + + item, err := services.GetArticleWithAlias(services.FilterPostDraft(database.C), alias) + if err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } + + item.ReactionCount = services.CountArticleReactions(item.ID) + item.ReactionList, err = services.ListResourceReactions(database.C.Where("article_id = ?", item.ID)) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + return c.JSON(item) +} + +func listArticle(c *fiber.Ctx) error { + take := c.QueryInt("take", 0) + offset := c.QueryInt("offset", 0) + realmId := c.QueryInt("realmId", 0) + + tx := services.FilterPostDraft(database.C) + if realmId > 0 { + if realm, err := services.GetRealmWithExtID(uint(realmId)); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("realm was not found: %v", err)) + } else { + tx = services.FilterArticleWithRealm(tx, realm.ID) + } + } + + if len(c.Query("authorId")) > 0 { + var author models.Account + if err := database.C.Where(&models.Account{Name: c.Query("authorId")}).First(&author).Error; err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } + tx = tx.Where("author_id = ?", author.ID) + } + + if len(c.Query("category")) > 0 { + tx = services.FilterArticleWithCategory(tx, c.Query("category")) + } + if len(c.Query("tag")) > 0 { + tx = services.FilterArticleWithTag(tx, c.Query("tag")) + } + + count, err := services.CountArticle(tx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + items, err := services.ListArticle(tx, take, offset) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + return c.JSON(fiber.Map{ + "count": count, + "data": items, + }) +} + +func listDraftArticle(c *fiber.Ctx) error { + take := c.QueryInt("take", 0) + offset := c.QueryInt("offset", 0) + + if err := gap.H.EnsureAuthenticated(c); err != nil { + return err + } + user := c.Locals("user").(models.Account) + + tx := services.FilterArticleWithAuthorDraft(database.C, user.ID) + + count, err := services.CountArticle(tx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + items, err := services.ListArticle(tx, take, offset, true) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + return c.JSON(fiber.Map{ + "count": count, + "data": items, + }) +} + +func createArticle(c *fiber.Ctx) error { + if err := gap.H.EnsureGrantedPerm(c, "CreateArticle", true); err != nil { + return err + } + user := c.Locals("user").(models.Account) + + var data struct { + Alias string `json:"alias"` + Title string `json:"title" validate:"required"` + Description string `json:"description"` + Content string `json:"content"` + Tags []models.Tag `json:"tags"` + Categories []models.Category `json:"categories"` + Attachments []uint `json:"attachments"` + IsDraft bool `json:"is_draft"` + PublishedAt *time.Time `json:"published_at"` + RealmAlias string `json:"realm"` + } + + if err := exts.BindAndValidate(c, &data); err != nil { + return err + } else if len(data.Alias) == 0 { + data.Alias = strings.ReplaceAll(uuid.NewString(), "-", "") + } + + for _, attachment := range data.Attachments { + if !services.CheckAttachmentByIDExists(attachment, "i.attachment") { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("attachment %d not found", attachment)) + } + } + + item := models.Article{ + Alias: data.Alias, + Title: data.Title, + Description: data.Description, + Content: data.Content, + IsDraft: data.IsDraft, + PublishedAt: data.PublishedAt, + AuthorID: user.ID, + Tags: data.Tags, + Categories: data.Categories, + Attachments: data.Attachments, + } + + if len(data.RealmAlias) > 0 { + if realm, err := services.GetRealmWithAlias(data.RealmAlias); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } else if _, err := services.GetRealmMember(realm.ExternalID, user.ExternalID); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("you aren't a part of related realm: %v", err)) + } else { + item.RealmID = &realm.ID + } + } + + item, err := services.NewArticle(user, item) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + return c.JSON(item) +} + +func editArticle(c *fiber.Ctx) error { + id, _ := c.ParamsInt("articleId", 0) + if err := gap.H.EnsureAuthenticated(c); err != nil { + return err + } + user := c.Locals("user").(models.Account) + + var data struct { + Alias string `json:"alias"` + Title string `json:"title"` + Description string `json:"description"` + Content string `json:"content"` + IsDraft bool `json:"is_draft"` + PublishedAt *time.Time `json:"published_at"` + Tags []models.Tag `json:"tags"` + Categories []models.Category `json:"categories"` + Attachments []uint `json:"attachments"` + } + + if err := exts.BindAndValidate(c, &data); err != nil { + return err + } + + var item models.Article + if err := database.C.Where(models.Article{ + BaseModel: models.BaseModel{ID: uint(id)}, + AuthorID: user.ID, + }).First(&item).Error; err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } + + for _, attachment := range data.Attachments { + if !services.CheckAttachmentByIDExists(attachment, "i.attachment") { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("attachment %d not found", attachment)) + } + } + + item.Alias = data.Alias + item.Title = data.Title + item.Description = data.Description + item.Content = data.Content + item.IsDraft = data.IsDraft + item.PublishedAt = data.PublishedAt + item.Tags = data.Tags + item.Categories = data.Categories + item.Attachments = data.Attachments + + if item, err := services.EditArticle(item); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } else { + return c.JSON(item) + } +} + +func deleteArticle(c *fiber.Ctx) error { + if err := gap.H.EnsureAuthenticated(c); err != nil { + return err + } + user := c.Locals("user").(models.Account) + id, _ := c.ParamsInt("articleId", 0) + + var item models.Article + if err := database.C.Where(models.Article{ + BaseModel: models.BaseModel{ID: uint(id)}, + AuthorID: user.ID, + }).First(&item).Error; err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } + + if err := services.DeleteArticle(item); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + return c.SendStatus(fiber.StatusOK) +} + +func reactArticle(c *fiber.Ctx) error { + if err := gap.H.EnsureAuthenticated(c); err != nil { + return err + } + user := c.Locals("user").(models.Account) + + var data struct { + Symbol string `json:"symbol"` + Attitude models.ReactionAttitude `json:"attitude"` + } + + if err := exts.BindAndValidate(c, &data); err != nil { + return err + } + + reaction := models.Reaction{ + Symbol: data.Symbol, + Attitude: data.Attitude, + AccountID: user.ID, + } + + alias := c.Params("article") + + var res models.Article + if err := database.C.Where("alias = ?", alias).Select("id").First(&res).Error; err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("unable to find article to react: %v", err)) + } else { + reaction.ArticleID = &res.ID + } + + if positive, reaction, err := services.ReactArticle(user, reaction); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } else { + return c.Status(lo.Ternary(positive, fiber.StatusCreated, fiber.StatusNoContent)).JSON(reaction) + } +} diff --git a/pkg/internal/server/api/feed_api.go b/pkg/internal/server/api/feed_api.go index a112a34..1090a85 100644 --- a/pkg/internal/server/api/feed_api.go +++ b/pkg/internal/server/api/feed_api.go @@ -18,7 +18,7 @@ func listFeed(c *fiber.Ctx) error { if realm, err := services.GetRealmWithExtID(uint(realmId)); err != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("realm was not found: %v", err)) } else { - tx = services.FilterWithRealm(tx, realm.ID) + tx = services.FilterPostWithRealm(tx, realm.ID) } } diff --git a/pkg/internal/server/api/index.go b/pkg/internal/server/api/index.go index d39aef2..27214be 100644 --- a/pkg/internal/server/api/index.go +++ b/pkg/internal/server/api/index.go @@ -12,6 +12,12 @@ func MapAPIs(app *fiber.App) { api.Get("/feed", listFeed) + drafts := api.Group("/drafts").Name("Draft box API") + { + drafts.Get("/posts", listDraftPost) + drafts.Get("/articles", listDraftArticle) + } + posts := api.Group("/posts").Name("Posts API") { posts.Get("/", listPost) @@ -21,7 +27,17 @@ func MapAPIs(app *fiber.App) { posts.Put("/:postId", editPost) posts.Delete("/:postId", deletePost) - posts.Get("/:post/replies", listReplies) + posts.Get("/:post/replies", listPostReplies) + } + + articles := api.Group("/articles").Name("Articles API") + { + articles.Get("/", listArticle) + articles.Get("/:article", getArticle) + articles.Post("/", createArticle) + articles.Post("/:article/react", reactArticle) + articles.Put("/:articleId", editArticle) + articles.Delete("/:articleId", deleteArticle) } api.Get("/categories", listCategories) diff --git a/pkg/internal/server/api/posts_api.go b/pkg/internal/server/api/posts_api.go index b4221f6..9bb02ab 100644 --- a/pkg/internal/server/api/posts_api.go +++ b/pkg/internal/server/api/posts_api.go @@ -17,14 +17,14 @@ import ( func getPost(c *fiber.Ctx) error { alias := c.Params("post") - item, err := services.GetPostWithAlias(alias) + item, err := services.GetPostWithAlias(services.FilterPostDraft(database.C), alias) if err != nil { return fiber.NewError(fiber.StatusNotFound, err.Error()) } item.ReplyCount = services.CountPostReply(item.ID) item.ReactionCount = services.CountPostReactions(item.ID) - item.ReactionList, err = services.ListPostReactions(item.ID) + item.ReactionList, err = services.ListResourceReactions(database.C.Where("post_id = ?", item.ID)) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, err.Error()) } @@ -37,12 +37,12 @@ func listPost(c *fiber.Ctx) error { offset := c.QueryInt("offset", 0) realmId := c.QueryInt("realmId", 0) - tx := database.C + tx := services.FilterPostDraft(database.C) if realmId > 0 { if realm, err := services.GetRealmWithExtID(uint(realmId)); err != nil { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("realm was not found: %v", err)) } else { - tx = services.FilterWithRealm(tx, realm.ID) + tx = services.FilterPostWithRealm(tx, realm.ID) } } @@ -77,22 +77,50 @@ func listPost(c *fiber.Ctx) error { }) } +func listDraftPost(c *fiber.Ctx) error { + take := c.QueryInt("take", 0) + offset := c.QueryInt("offset", 0) + + if err := gap.H.EnsureAuthenticated(c); err != nil { + return err + } + user := c.Locals("user").(models.Account) + + tx := services.FilterPostWithAuthorDraft(database.C, user.ID) + + count, err := services.CountPost(tx) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + + items, err := services.ListPost(tx, take, offset, true) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + return c.JSON(fiber.Map{ + "count": count, + "data": items, + }) +} + func createPost(c *fiber.Ctx) error { - if err := gap.H.EnsureGrantedPerm(c, "CreateInteractivePost", true); err != nil { + if err := gap.H.EnsureGrantedPerm(c, "CreatePost", true); err != nil { return err } user := c.Locals("user").(models.Account) var data struct { - Alias string `json:"alias" form:"alias"` - Content string `json:"content" form:"content" validate:"required,max=4096"` - Tags []models.Tag `json:"tags" form:"tags"` - Categories []models.Category `json:"categories" form:"categories"` - Attachments []uint `json:"attachments" form:"attachments"` - PublishedAt *time.Time `json:"published_at" form:"published_at"` - RealmAlias string `json:"realm" form:"realm"` - ReplyTo *uint `json:"reply_to" form:"reply_to"` - RepostTo *uint `json:"repost_to" form:"repost_to"` + Alias string `json:"alias"` + Content string `json:"content" validate:"required,max=4096"` + Tags []models.Tag `json:"tags"` + Categories []models.Category `json:"categories"` + Attachments []uint `json:"attachments"` + IsDraft bool `json:"is_draft"` + PublishedAt *time.Time `json:"published_at"` + RealmAlias string `json:"realm"` + ReplyTo *uint `json:"reply_to"` + RepostTo *uint `json:"repost_to"` } if err := exts.BindAndValidate(c, &data); err != nil { @@ -109,12 +137,13 @@ func createPost(c *fiber.Ctx) error { item := models.Post{ Alias: data.Alias, - PublishedAt: data.PublishedAt, - AuthorID: user.ID, + Content: data.Content, Tags: data.Tags, Categories: data.Categories, Attachments: data.Attachments, - Content: data.Content, + IsDraft: data.IsDraft, + PublishedAt: data.PublishedAt, + AuthorID: user.ID, } if data.ReplyTo != nil { @@ -160,12 +189,13 @@ func editPost(c *fiber.Ctx) error { user := c.Locals("user").(models.Account) var data struct { - Alias string `json:"alias" form:"alias" validate:"required"` - Content string `json:"content" form:"content" validate:"required,max=1024"` - PublishedAt *time.Time `json:"published_at" form:"published_at"` - Tags []models.Tag `json:"tags" form:"tags"` - Categories []models.Category `json:"categories" form:"categories"` - Attachments []uint `json:"attachments" form:"attachments"` + Alias string `json:"alias"` + Content string `json:"content" validate:"required,max=4096"` + IsDraft bool `json:"is_draft"` + PublishedAt *time.Time `json:"published_at"` + Tags []models.Tag `json:"tags"` + Categories []models.Category `json:"categories"` + Attachments []uint `json:"attachments"` } if err := exts.BindAndValidate(c, &data); err != nil { @@ -188,6 +218,7 @@ func editPost(c *fiber.Ctx) error { item.Alias = data.Alias item.Content = data.Content + item.IsDraft = data.IsDraft item.PublishedAt = data.PublishedAt item.Tags = data.Tags item.Categories = data.Categories @@ -229,8 +260,8 @@ func reactPost(c *fiber.Ctx) error { user := c.Locals("user").(models.Account) var data struct { - Symbol string `json:"symbol" form:"symbol" validate:"required"` - Attitude models.ReactionAttitude `json:"attitude" form:"attitude"` + Symbol string `json:"symbol"` + Attitude models.ReactionAttitude `json:"attitude"` } if err := exts.BindAndValidate(c, &data); err != nil { diff --git a/pkg/internal/server/api/replies_api.go b/pkg/internal/server/api/replies_api.go index b8caa14..6a6437a 100644 --- a/pkg/internal/server/api/replies_api.go +++ b/pkg/internal/server/api/replies_api.go @@ -8,7 +8,7 @@ import ( "github.com/gofiber/fiber/v2" ) -func listReplies(c *fiber.Ctx) error { +func listPostReplies(c *fiber.Ctx) error { take := c.QueryInt("take", 0) offset := c.QueryInt("offset", 0) diff --git a/pkg/internal/services/articles.go b/pkg/internal/services/articles.go new file mode 100644 index 0000000..a29daf5 --- /dev/null +++ b/pkg/internal/services/articles.go @@ -0,0 +1,223 @@ +package services + +import ( + "errors" + "fmt" + "time" + + "git.solsynth.dev/hydrogen/interactive/pkg/internal/database" + "git.solsynth.dev/hydrogen/interactive/pkg/internal/models" + "git.solsynth.dev/hydrogen/passport/pkg/proto" + "github.com/rs/zerolog/log" + "github.com/samber/lo" + "github.com/spf13/viper" + "gorm.io/gorm" +) + +func FilterArticleWithCategory(tx *gorm.DB, alias string) *gorm.DB { + return tx.Joins("JOIN article_categories ON articles.id = article_categories.article_id"). + Joins("JOIN article_categories ON article_categories.id = article_categories.category_id"). + Where("article_categories.alias = ?", alias) +} + +func FilterArticleWithTag(tx *gorm.DB, alias string) *gorm.DB { + return tx.Joins("JOIN article_tags ON articles.id = article_tags.article_id"). + Joins("JOIN article_tags ON article_tags.id = article_tags.category_id"). + Where("article_tags.alias = ?", alias) +} + +func FilterArticleWithRealm(tx *gorm.DB, id uint) *gorm.DB { + if id > 0 { + return tx.Where("realm_id = ?", id) + } else { + return tx.Where("realm_id IS NULL") + } +} + +func FilterArticleWithPublishedAt(tx *gorm.DB, date time.Time) *gorm.DB { + return tx.Where("published_at <= ? OR published_at IS NULL", date) +} + +func FilterArticleWithAuthorDraft(tx *gorm.DB, uid uint) *gorm.DB { + return tx.Where("author_id = ? AND is_draft = ?", uid, true) +} + +func FilterArticleDraft(tx *gorm.DB) *gorm.DB { + return tx.Where("is_draft = ?", false) +} + +func GetArticleWithAlias(tx *gorm.DB, alias string, ignoreLimitation ...bool) (models.Article, error) { + if len(ignoreLimitation) == 0 || !ignoreLimitation[0] { + tx = FilterArticleWithPublishedAt(tx, time.Now()) + } + + var item models.Article + if err := tx. + Where("alias = ?", alias). + Preload("Realm"). + Preload("Author"). + First(&item).Error; err != nil { + return item, err + } + + return item, nil +} + +func GetArticle(tx *gorm.DB, id uint, ignoreLimitation ...bool) (models.Article, error) { + if len(ignoreLimitation) == 0 || !ignoreLimitation[0] { + tx = FilterArticleWithPublishedAt(tx, time.Now()) + } + + var item models.Article + if err := tx. + Where("id = ?", id). + Preload("Realm"). + Preload("Author"). + First(&item).Error; err != nil { + return item, err + } + + return item, nil +} + +func CountArticle(tx *gorm.DB) (int64, error) { + var count int64 + if err := tx.Model(&models.Article{}).Count(&count).Error; err != nil { + return count, err + } + + return count, nil +} + +func CountArticleReactions(id uint) int64 { + var count int64 + if err := database.C.Model(&models.Reaction{}). + Where("article_id = ?", id). + Count(&count).Error; err != nil { + return 0 + } + + return count +} + +func ListArticle(tx *gorm.DB, take int, offset int, noReact ...bool) ([]*models.Article, error) { + if take > 20 { + take = 20 + } + + var items []*models.Article + if err := tx. + Limit(take).Offset(offset). + Order("created_at DESC"). + Preload("Realm"). + Preload("Author"). + Find(&items).Error; err != nil { + return items, err + } + + idx := lo.Map(items, func(item *models.Article, index int) uint { + return item.ID + }) + + // Load reactions + if len(noReact) <= 0 || !noReact[0] { + if mapping, err := BatchListResourceReactions(database.C.Where("article_id IN ?", idx)); err != nil { + return items, err + } else { + itemMap := lo.SliceToMap(items, func(item *models.Article) (uint, *models.Article) { + return item.ID, item + }) + + for k, v := range mapping { + if post, ok := itemMap[k]; ok { + post.ReactionList = v + } + } + } + } + + return items, nil +} + +func EnsureArticleCategoriesAndTags(item models.Article) (models.Article, error) { + var err error + for idx, category := range item.Categories { + item.Categories[idx], err = GetCategory(category.Alias) + if err != nil { + return item, err + } + } + for idx, tag := range item.Tags { + item.Tags[idx], err = GetTagOrCreate(tag.Alias, tag.Name) + if err != nil { + return item, err + } + } + return item, nil +} + +func NewArticle(user models.Account, item models.Article) (models.Article, error) { + item, err := EnsureArticleCategoriesAndTags(item) + if err != nil { + return item, err + } + + if item.RealmID != nil { + _, err := GetRealmMember(*item.RealmID, user.ExternalID) + if err != nil { + return item, fmt.Errorf("you aren't a part of that realm: %v", err) + } + } + + if err := database.C.Save(&item).Error; err != nil { + return item, err + } + + return item, nil +} + +func EditArticle(item models.Article) (models.Article, error) { + item, err := EnsureArticleCategoriesAndTags(item) + if err != nil { + return item, err + } + + err = database.C.Save(&item).Error + + return item, err +} + +func DeleteArticle(item models.Article) error { + return database.C.Delete(&item).Error +} + +func ReactArticle(user models.Account, reaction models.Reaction) (bool, models.Reaction, error) { + if err := database.C.Where(reaction).First(&reaction).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + var op models.Article + if err := database.C. + Where("id = ?", reaction.ArticleID). + Preload("Author"). + First(&op).Error; err == nil { + if op.Author.ID != user.ID { + articleUrl := fmt.Sprintf("https://%s/articles/%s", viper.GetString("domain"), op.Alias) + err := NotifyPosterAccount( + op.Author, + fmt.Sprintf("%s reacted your article", user.Nick), + fmt.Sprintf("%s (%s) reacted your article a %s", user.Nick, user.Name, reaction.Symbol), + &proto.NotifyLink{Label: "Related article", Url: articleUrl}, + ) + if err != nil { + log.Error().Err(err).Msg("An error occurred when notifying user...") + } + } + } + + return true, reaction, database.C.Save(&reaction).Error + } else { + return true, reaction, err + } + } else { + return false, reaction, database.C.Delete(&reaction).Error + } +} diff --git a/pkg/internal/services/jwt.go b/pkg/internal/services/jwt.go deleted file mode 100644 index 40cfef0..0000000 --- a/pkg/internal/services/jwt.go +++ /dev/null @@ -1,81 +0,0 @@ -package services - -import ( - "fmt" - "github.com/gofiber/fiber/v2" - "time" - - "github.com/golang-jwt/jwt/v5" - "github.com/spf13/viper" -) - -type PayloadClaims struct { - jwt.RegisteredClaims - - Type string `json:"typ"` -} - -const ( - JwtAccessType = "access" - JwtRefreshType = "refresh" -) - -const ( - CookieAccessKey = "passport_auth_key" - CookieRefreshKey = "passport_refresh_key" -) - -func EncodeJwt(id string, typ, sub string, aud []string, exp time.Time) (string, error) { - tk := jwt.NewWithClaims(jwt.SigningMethodHS512, PayloadClaims{ - jwt.RegisteredClaims{ - Subject: sub, - Audience: aud, - Issuer: fmt.Sprintf("https://%s", viper.GetString("domain")), - ExpiresAt: jwt.NewNumericDate(exp), - NotBefore: jwt.NewNumericDate(time.Now()), - IssuedAt: jwt.NewNumericDate(time.Now()), - ID: id, - }, - typ, - }) - - return tk.SignedString([]byte(viper.GetString("secret"))) -} - -func DecodeJwt(str string) (PayloadClaims, error) { - var claims PayloadClaims - tk, err := jwt.ParseWithClaims(str, &claims, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) - } - return []byte(viper.GetString("secret")), nil - }) - if err != nil { - return claims, err - } - - if data, ok := tk.Claims.(*PayloadClaims); ok { - return *data, nil - } else { - return claims, fmt.Errorf("unexpected token payload: not payload claims type") - } -} - -func SetJwtCookieSet(c *fiber.Ctx, access, refresh string) { - c.Cookie(&fiber.Cookie{ - Name: CookieAccessKey, - Value: access, - Domain: viper.GetString("security.cookie_domain"), - SameSite: viper.GetString("security.cookie_samesite"), - Expires: time.Now().Add(60 * time.Minute), - Path: "/", - }) - c.Cookie(&fiber.Cookie{ - Name: CookieRefreshKey, - Value: refresh, - Domain: viper.GetString("security.cookie_domain"), - SameSite: viper.GetString("security.cookie_samesite"), - Expires: time.Now().Add(24 * 30 * time.Hour), - Path: "/", - }) -} diff --git a/pkg/internal/services/mailer.go b/pkg/internal/services/mailer.go deleted file mode 100644 index 74301fe..0000000 --- a/pkg/internal/services/mailer.go +++ /dev/null @@ -1,51 +0,0 @@ -package services - -import ( - "crypto/tls" - "fmt" - "net/smtp" - "net/textproto" - - "github.com/jordan-wright/email" - "github.com/spf13/viper" -) - -func SendMail(target string, subject string, content string) error { - mail := &email.Email{ - To: []string{target}, - From: viper.GetString("mailer.name"), - Subject: subject, - Text: []byte(content), - Headers: textproto.MIMEHeader{}, - } - return mail.SendWithTLS( - fmt.Sprintf("%s:%d", viper.GetString("mailer.smtp_host"), viper.GetInt("mailer.smtp_port")), - smtp.PlainAuth( - "", - viper.GetString("mailer.username"), - viper.GetString("mailer.password"), - viper.GetString("mailer.smtp_host"), - ), - &tls.Config{ServerName: viper.GetString("mailer.smtp_host")}, - ) -} - -func SendMailHTML(target string, subject string, content string) error { - mail := &email.Email{ - To: []string{target}, - From: viper.GetString("mailer.name"), - Subject: subject, - HTML: []byte(content), - Headers: textproto.MIMEHeader{}, - } - return mail.SendWithTLS( - fmt.Sprintf("%s:%d", viper.GetString("mailer.smtp_host"), viper.GetInt("mailer.smtp_port")), - smtp.PlainAuth( - "", - viper.GetString("mailer.username"), - viper.GetString("mailer.password"), - viper.GetString("mailer.smtp_host"), - ), - &tls.Config{ServerName: viper.GetString("mailer.smtp_host")}, - ) -} diff --git a/pkg/internal/services/posts.go b/pkg/internal/services/posts.go index 4bac3c5..a776e2e 100644 --- a/pkg/internal/services/posts.go +++ b/pkg/internal/services/posts.go @@ -26,7 +26,7 @@ func FilterPostWithTag(tx *gorm.DB, alias string) *gorm.DB { Where("post_tags.alias = ?", alias) } -func FilterWithRealm(tx *gorm.DB, id uint) *gorm.DB { +func FilterPostWithRealm(tx *gorm.DB, id uint) *gorm.DB { if id > 0 { return tx.Where("realm_id = ?", id) } else { @@ -46,8 +46,15 @@ func FilterPostWithPublishedAt(tx *gorm.DB, date time.Time) *gorm.DB { return tx.Where("published_at <= ? OR published_at IS NULL", date) } -func GetPostWithAlias(alias string, ignoreLimitation ...bool) (models.Post, error) { - tx := database.C +func FilterPostWithAuthorDraft(tx *gorm.DB, uid uint) *gorm.DB { + return tx.Where("author_id = ? AND is_draft = ?", uid, true) +} + +func FilterPostDraft(tx *gorm.DB) *gorm.DB { + return tx.Where("is_draft = ?", false) +} + +func GetPostWithAlias(tx *gorm.DB, alias string, ignoreLimitation ...bool) (models.Post, error) { if len(ignoreLimitation) == 0 || !ignoreLimitation[0] { tx = FilterPostWithPublishedAt(tx, time.Now()) } @@ -68,8 +75,7 @@ func GetPostWithAlias(alias string, ignoreLimitation ...bool) (models.Post, erro return item, nil } -func GetPost(id uint, ignoreLimitation ...bool) (models.Post, error) { - tx := database.C +func GetPost(tx *gorm.DB, id uint, ignoreLimitation ...bool) (models.Post, error) { if len(ignoreLimitation) == 0 || !ignoreLimitation[0] { tx = FilterPostWithPublishedAt(tx, time.Now()) } @@ -121,29 +127,6 @@ func CountPostReactions(id uint) int64 { return count } -func ListPostReactions(id uint) (map[string]int64, error) { - var reactions []struct { - Symbol string - Count int64 - } - - if err := database.C.Model(&models.Reaction{}). - Select("symbol, COUNT(id) as count"). - Where("post_id = ?", id). - Group("symbol"). - Scan(&reactions).Error; err != nil { - return map[string]int64{}, err - } - - return lo.SliceToMap(reactions, func(item struct { - Symbol string - Count int64 - }, - ) (string, int64) { - return item.Symbol, item.Count - }), nil -} - func ListPost(tx *gorm.DB, take int, offset int, noReact ...bool) ([]*models.Post, error) { if take > 20 { take = 20 @@ -167,40 +150,24 @@ func ListPost(tx *gorm.DB, take int, offset int, noReact ...bool) ([]*models.Pos return item.ID }) + // Load reactions if len(noReact) <= 0 || !noReact[0] { - var reactions []struct { - PostID uint - Symbol string - Count int64 - } - - if err := database.C.Model(&models.Reaction{}). - Select("post_id, symbol, COUNT(id) as count"). - Where("post_id IN (?)", idx). - Group("post_id, symbol"). - Scan(&reactions).Error; err != nil { + if mapping, err := BatchListResourceReactions(database.C.Where("post_id IN ?", idx)); err != nil { return items, err - } + } else { + itemMap := lo.SliceToMap(items, func(item *models.Post) (uint, *models.Post) { + return item.ID, item + }) - itemMap := lo.SliceToMap(items, func(item *models.Post) (uint, *models.Post) { - return item.ID, item - }) - - list := map[uint]map[string]int64{} - for _, info := range reactions { - if _, ok := list[info.PostID]; !ok { - list[info.PostID] = make(map[string]int64) - } - list[info.PostID][info.Symbol] = info.Count - } - - for k, v := range list { - if post, ok := itemMap[k]; ok { - post.ReactionList = v + for k, v := range mapping { + if post, ok := itemMap[k]; ok { + post.ReactionList = v + } } } } + // Load replies if len(noReact) <= 0 || !noReact[0] { var replies []struct { PostID uint @@ -234,7 +201,7 @@ func ListPost(tx *gorm.DB, take int, offset int, noReact ...bool) ([]*models.Pos return items, nil } -func InitPostCategoriesAndTags(item models.Post) (models.Post, error) { +func EnsurePostCategoriesAndTags(item models.Post) (models.Post, error) { var err error for idx, category := range item.Categories { item.Categories[idx], err = GetCategory(category.Alias) @@ -252,7 +219,7 @@ func InitPostCategoriesAndTags(item models.Post) (models.Post, error) { } func NewPost(user models.Account, item models.Post) (models.Post, error) { - item, err := InitPostCategoriesAndTags(item) + item, err := EnsurePostCategoriesAndTags(item) if err != nil { return item, err } @@ -294,7 +261,7 @@ func NewPost(user models.Account, item models.Post) (models.Post, error) { } func EditPost(item models.Post) (models.Post, error) { - item, err := InitPostCategoriesAndTags(item) + item, err := EnsurePostCategoriesAndTags(item) if err != nil { return item, err } diff --git a/pkg/internal/services/reactions.go b/pkg/internal/services/reactions.go new file mode 100644 index 0000000..56568ef --- /dev/null +++ b/pkg/internal/services/reactions.go @@ -0,0 +1,54 @@ +package services + +import ( + "git.solsynth.dev/hydrogen/interactive/pkg/internal/models" + "github.com/samber/lo" + "gorm.io/gorm" +) + +func ListResourceReactions(tx *gorm.DB) (map[string]int64, error) { + var reactions []struct { + Symbol string + Count int64 + } + + if err := tx.Model(&models.Reaction{}). + Select("symbol, COUNT(id) as count"). + Group("symbol"). + Scan(&reactions).Error; err != nil { + return map[string]int64{}, err + } + + return lo.SliceToMap(reactions, func(item struct { + Symbol string + Count int64 + }, + ) (string, int64) { + return item.Symbol, item.Count + }), nil +} + +func BatchListResourceReactions(tx *gorm.DB) (map[uint]map[string]int64, error) { + var reactions []struct { + ArticleID uint + Symbol string + Count int64 + } + + reactInfo := map[uint]map[string]int64{} + if err := tx.Model(&models.Reaction{}). + Select("article_id, symbol, COUNT(id) as count"). + Group("article_id, symbol"). + Scan(&reactions).Error; err != nil { + return reactInfo, err + } + + for _, info := range reactions { + if _, ok := reactInfo[info.ArticleID]; !ok { + reactInfo[info.ArticleID] = make(map[string]int64) + } + reactInfo[info.ArticleID][info.Symbol] = info.Count + } + + return reactInfo, nil +}