diff --git a/pkg/internal/database/migrator.go b/pkg/internal/database/migrator.go index 2ba081e..0b45570 100644 --- a/pkg/internal/database/migrator.go +++ b/pkg/internal/database/migrator.go @@ -11,7 +11,6 @@ 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 deleted file mode 100644 index 779b4ab..0000000 --- a/pkg/internal/models/articles.go +++ /dev/null @@ -1,31 +0,0 @@ -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"` - Language string `json:"language"` - 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"` - - Metric PostMetric `json:"metric" gorm:"-"` -} diff --git a/pkg/internal/models/categories.go b/pkg/internal/models/categories.go index 30605f7..4cc0d66 100644 --- a/pkg/internal/models/categories.go +++ b/pkg/internal/models/categories.go @@ -3,19 +3,17 @@ package models type Tag struct { BaseModel - Alias string `json:"alias" gorm:"uniqueIndex" validate:"lowercase"` - Name string `json:"name"` - Description string `json:"description"` - Posts []Post `json:"posts" gorm:"many2many:post_tags"` - Articles []Article `json:"articles" gorm:"many2many:article_tags"` + Alias string `json:"alias" gorm:"uniqueIndex" validate:"lowercase"` + Name string `json:"name"` + Description string `json:"description"` + Posts []Post `json:"posts" gorm:"many2many:post_tags"` } type Category struct { BaseModel - Alias string `json:"alias" gorm:"uniqueIndex" validate:"lowercase,alphanum"` - Name string `json:"name"` - Description string `json:"description"` - Posts []Post `json:"posts" gorm:"many2many:post_categories"` - Articles []Article `json:"articles" gorm:"many2many:article_categories"` + Alias string `json:"alias" gorm:"uniqueIndex" validate:"lowercase,alphanum"` + Name string `json:"name"` + Description string `json:"description"` + Posts []Post `json:"posts" gorm:"many2many:post_categories"` } diff --git a/pkg/internal/models/posts.go b/pkg/internal/models/posts.go index 191f5a5..e506429 100644 --- a/pkg/internal/models/posts.go +++ b/pkg/internal/models/posts.go @@ -6,29 +6,48 @@ import ( "gorm.io/datatypes" ) +const ( + PostTypeStory = "story" + PostTypeArticle = "article" +) + type Post struct { BaseModel - Alias string `json:"alias" gorm:"uniqueIndex"` - Content *string `json:"content"` - Language string `json:"language"` - Tags []Tag `json:"tags" gorm:"many2many:post_tags"` - Categories []Category `json:"categories" gorm:"many2many:post_categories"` - Reactions []Reaction `json:"reactions"` - Replies []Post `json:"replies" gorm:"foreignKey:ReplyID"` - Attachments datatypes.JSONSlice[uint] `json:"attachments"` - ReplyID *uint `json:"reply_id"` - RepostID *uint `json:"repost_id"` - RealmID *uint `json:"realm_id"` - ReplyTo *Post `json:"reply_to" gorm:"foreignKey:ReplyID"` - RepostTo *Post `json:"repost_to" gorm:"foreignKey:RepostID"` - Realm *Realm `json:"realm"` + Type string `json:"type"` + Body datatypes.JSONMap `json:"body"` + Language string `json:"language"` + Tags []Tag `json:"tags" gorm:"many2many:post_tags"` + Categories []Category `json:"categories" gorm:"many2many:post_categories"` + Reactions []Reaction `json:"reactions"` + Replies []Post `json:"replies" gorm:"foreignKey:ReplyID"` + ReplyID *uint `json:"reply_id"` + RepostID *uint `json:"repost_id"` + RealmID *uint `json:"realm_id"` + ReplyTo *Post `json:"reply_to" gorm:"foreignKey:ReplyID"` + RepostTo *Post `json:"repost_to" gorm:"foreignKey:RepostID"` + Realm *Realm `json:"realm"` - IsDraft bool `json:"is_draft"` - PublishedAt *time.Time `json:"published_at"` + IsDraft bool `json:"is_draft"` + PublishedAt *time.Time `json:"published_at"` + PublishedUntil *time.Time `json:"published_until"` AuthorID uint `json:"author_id"` Author Account `json:"author"` Metric PostMetric `json:"metric" gorm:"-"` } + +type PostStoryBody struct { + Title *string `json:"title"` + Content string `json:"content"` + Location *string `json:"location"` + Attachments []uint `json:"attachments"` +} + +type PostArticleBody struct { + Title string `json:"title"` + Description *string `json:"description"` + Content string `json:"content"` + Attachments []uint `json:"attachments"` +} diff --git a/pkg/internal/models/reactions.go b/pkg/internal/models/reactions.go index 98231c5..da1762a 100644 --- a/pkg/internal/models/reactions.go +++ b/pkg/internal/models/reactions.go @@ -21,6 +21,5 @@ 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 index 8b5ef1f..6b9982d 100644 --- a/pkg/internal/server/api/articles_api.go +++ b/pkg/internal/server/api/articles_api.go @@ -2,164 +2,73 @@ package api import ( "fmt" - "strings" - "time" - "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" + jsoniter "github.com/json-iterator/go" + "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.Metric.ReactionCount = services.CountArticleReactions(item.ID) - item.Metric.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")) - } - - counTx := tx - count, err := services.CountArticle(counTx) - 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, "CreateArticles", true); err != nil { + if err := gap.H.EnsureGrantedPerm(c, "CreatePosts", 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"` + Title string `json:"title" validate:"required,max=1024"` + Description *string `json:"description" validate:"max=2048"` + Content string `json:"content" validate:"required"` + Attachments []uint `json:"attachments"` + Tags []models.Tag `json:"tags"` + Categories []models.Category `json:"categories"` + PublishedAt *time.Time `json:"published_at"` + PublishedUntil *time.Time `json:"published_until"` + IsDraft bool `json:"is_draft"` + 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, + body := models.PostArticleBody{ 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 { + var bodyMapping map[string]any + rawBody, _ := jsoniter.Marshal(body) + _ = jsoniter.Unmarshal(rawBody, &bodyMapping) + + item := models.Post{ + Type: models.PostTypeArticle, + Body: bodyMapping, + Language: services.DetectLanguage(data.Content), + Tags: data.Tags, + Categories: data.Categories, + IsDraft: data.IsDraft, + PublishedAt: data.PublishedAt, + PublishedUntil: data.PublishedUntil, + AuthorID: user.ID, + } + + if data.RealmAlias != nil { + 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 if _, err = services.GetRealmMember(realm.ExternalID, user.ExternalID); err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("unable to post in the realm, access denied: %v", err)) } else { item.RealmID = &realm.ID } } - item, err := services.NewArticle(user, item) + item, err := services.NewPost(user, item) if err != nil { return fiber.NewError(fiber.StatusBadRequest, err.Error()) } @@ -168,114 +77,57 @@ func createArticle(c *fiber.Ctx) error { } func editArticle(c *fiber.Ctx) error { - id, _ := c.ParamsInt("articleId", 0) + id, _ := c.ParamsInt("postId", 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"` + Title string `json:"title" validate:"required,max=1024"` + Description *string `json:"description" validate:"max=2048"` + Content string `json:"content" validate:"required"` + Attachments []uint `json:"attachments"` + Tags []models.Tag `json:"tags"` + Categories []models.Category `json:"categories"` + IsDraft bool `json:"is_draft"` + PublishedAt *time.Time `json:"published_at"` + PublishedUntil *time.Time `json:"published_until"` } if err := exts.BindAndValidate(c, &data); err != nil { return err } - var item models.Article - if err := database.C.Where(models.Article{ + var item models.Post + if err := database.C.Where(models.Post{ 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)) - } + body := models.PostArticleBody{ + Title: data.Title, + Content: data.Content, + Attachments: data.Attachments, } - item.Alias = data.Alias - item.Title = data.Title - item.Description = data.Description - item.Content = data.Content - item.IsDraft = data.IsDraft - item.PublishedAt = data.PublishedAt + var bodyMapping map[string]any + rawBody, _ := jsoniter.Marshal(body) + _ = jsoniter.Unmarshal(rawBody, &bodyMapping) + + item.Body = bodyMapping + item.Language = services.DetectLanguage(data.Content) item.Tags = data.Tags item.Categories = data.Categories - item.Attachments = data.Attachments + item.IsDraft = data.IsDraft + item.PublishedAt = data.PublishedAt + item.PublishedUntil = data.PublishedUntil - if item, err := services.EditArticle(item); err != nil { + if item, err := services.EditPost(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 deleted file mode 100644 index b6ab05e..0000000 --- a/pkg/internal/server/api/feed_api.go +++ /dev/null @@ -1,173 +0,0 @@ -package api - -import ( - "fmt" - "sort" - "time" - - "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/services" - "github.com/gofiber/fiber/v2" - jsoniter "github.com/json-iterator/go" -) - -type FeedRecord struct { - Type string `json:"type"` - Data map[string]any `json:"data"` - CreatedAt time.Time `json:"created_at"` -} - -func listFeed(c *fiber.Ctx) error { - take := c.QueryInt("take", 0) - offset := c.QueryInt("offset", 0) - realmId := c.QueryInt("realmId", 0) - - postTx := services.FilterPostDraft(database.C) - articleTx := services.FilterArticleDraft(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 { - postTx = services.FilterPostWithRealm(postTx, realm.ID) - articleTx = services.FilterArticleWithRealm(articleTx, 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()) - } - postTx = postTx.Where("author_id = ?", author.ID) - articleTx = articleTx.Where("author_id = ?", author.ID) - } - - if len(c.Query("category")) > 0 { - postTx = services.FilterPostWithCategory(postTx, c.Query("category")) - articleTx = services.FilterArticleWithCategory(articleTx, c.Query("category")) - } - if len(c.Query("tag")) > 0 { - postTx = services.FilterPostWithTag(postTx, c.Query("tag")) - articleTx = services.FilterArticleWithTag(articleTx, c.Query("tag")) - } - - postCountTx := postTx - articleCountTx := articleTx - - postCount, err := services.CountPost(postCountTx) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, err.Error()) - } - articleCount, err := services.CountArticle(articleCountTx) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, err.Error()) - } - - postItems, err := services.ListPost(postTx, take, offset) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - articleItems, err := services.ListArticle(articleTx, take, offset) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - var feed []FeedRecord - - encodeToFeed := func(t string, in any, createdAt time.Time) FeedRecord { - var result map[string]any - raw, _ := jsoniter.Marshal(in) - jsoniter.Unmarshal(raw, &result) - - return FeedRecord{ - Type: t, - Data: result, - CreatedAt: createdAt, - } - } - - for _, post := range postItems { - feed = append(feed, encodeToFeed("post", post, post.CreatedAt)) - } - - for _, article := range articleItems { - feed = append(feed, encodeToFeed("article", article, article.CreatedAt)) - } - - sort.Slice(feed, func(i, j int) bool { - return feed[i].CreatedAt.After(feed[j].CreatedAt) - }) - - return c.JSON(fiber.Map{ - "count": postCount + articleCount, - "data": feed, - }) -} - -func listDraftMixed(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) - - postTx := services.FilterPostWithAuthorDraft(database.C, user.ID) - articleTx := services.FilterArticleWithAuthorDraft(database.C, user.ID) - - postCountTx := postTx - articleCountTx := articleTx - - postCount, err := services.CountPost(postCountTx) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, err.Error()) - } - articleCount, err := services.CountArticle(articleCountTx) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, err.Error()) - } - - postItems, err := services.ListPost(postTx, take, offset) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - articleItems, err := services.ListArticle(articleTx, take, offset) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - var feed []FeedRecord - - encodeToFeed := func(t string, in any, createdAt time.Time) FeedRecord { - var result map[string]any - raw, _ := jsoniter.Marshal(in) - jsoniter.Unmarshal(raw, &result) - - return FeedRecord{ - Type: t, - Data: result, - CreatedAt: createdAt, - } - } - - for _, post := range postItems { - feed = append(feed, encodeToFeed("post", post, post.CreatedAt)) - } - - for _, article := range articleItems { - feed = append(feed, encodeToFeed("article", article, article.CreatedAt)) - } - - sort.Slice(feed, func(i, j int) bool { - return feed[i].CreatedAt.After(feed[j].CreatedAt) - }) - - return c.JSON(fiber.Map{ - "count": postCount + articleCount, - "data": feed, - }) -} diff --git a/pkg/internal/server/api/index.go b/pkg/internal/server/api/index.go index 6f483de..5ff97c7 100644 --- a/pkg/internal/server/api/index.go +++ b/pkg/internal/server/api/index.go @@ -10,37 +10,28 @@ func MapAPIs(app *fiber.App, baseURL string) { api.Get("/users/me", getUserinfo) api.Get("/users/:accountId", getOthersInfo) - api.Get("/feed", listFeed) - - drafts := api.Group("/drafts").Name("Draft box API") + stories := api.Group("/stories").Name("Story API") { - drafts.Get("/", listDraftMixed) - drafts.Get("/posts", listDraftPost) - drafts.Get("/articles", listDraftArticle) + stories.Post("/", createStory) + stories.Put("/:postId", editStory) + } + articles := api.Group("/articles").Name("Article API") + { + articles.Post("/", createArticle) + articles.Put("/:articleId", editArticle) } posts := api.Group("/posts").Name("Posts API") { posts.Get("/", listPost) - posts.Get("/:post", getPost) - posts.Post("/", createPost) - posts.Post("/:post/react", reactPost) - posts.Put("/:postId", editPost) + posts.Get("/drafts", listDraftPost) + posts.Get("/:postId", getPost) + posts.Post("/:postId/react", reactPost) posts.Delete("/:postId", deletePost) 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) api.Post("/categories", newCategory) api.Put("/categories/:categoryId", editCategory) diff --git a/pkg/internal/server/api/posts_api.go b/pkg/internal/server/api/posts_api.go index 8662535..6ceaac6 100644 --- a/pkg/internal/server/api/posts_api.go +++ b/pkg/internal/server/api/posts_api.go @@ -2,23 +2,19 @@ package api import ( "fmt" - "strings" - "time" - "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" ) func getPost(c *fiber.Ctx) error { - alias := c.Params("post") + id, _ := c.ParamsInt("postId") - item, err := services.GetPostWithAlias(services.FilterPostDraft(database.C), alias) + item, err := services.GetPost(services.FilterPostDraft(database.C), uint(id)) if err != nil { return fiber.NewError(fiber.StatusNotFound, err.Error()) } @@ -108,133 +104,6 @@ func listDraftPost(c *fiber.Ctx) error { }) } -func createPost(c *fiber.Ctx) error { - if err := gap.H.EnsureGrantedPerm(c, "CreatePosts", true); err != nil { - return err - } - user := c.Locals("user").(models.Account) - - var data struct { - 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 { - 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.Post{ - Alias: data.Alias, - Content: &data.Content, - Tags: data.Tags, - Categories: data.Categories, - Attachments: data.Attachments, - IsDraft: data.IsDraft, - PublishedAt: data.PublishedAt, - AuthorID: user.ID, - } - - if data.ReplyTo != nil { - var replyTo models.Post - if err := database.C.Where("id = ?", data.ReplyTo).First(&replyTo).Error; err != nil { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("related post was not found: %v", err)) - } else { - item.ReplyID = &replyTo.ID - } - } - if data.RepostTo != nil { - var repostTo models.Post - if err := database.C.Where("id = ?", data.RepostTo).First(&repostTo).Error; err != nil { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("related post was not found: %v", err)) - } else { - item.RepostID = &repostTo.ID - } - } - - 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.NewPost(user, item) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - - return c.JSON(item) -} - -func editPost(c *fiber.Ctx) error { - id, _ := c.ParamsInt("postId", 0) - if err := gap.H.EnsureAuthenticated(c); err != nil { - return err - } - user := c.Locals("user").(models.Account) - - var data struct { - 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 { - return err - } - - var item models.Post - if err := database.C.Where(models.Post{ - 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.Content = &data.Content - item.Alias = data.Alias - item.IsDraft = data.IsDraft - item.PublishedAt = data.PublishedAt - item.Tags = data.Tags - item.Categories = data.Categories - item.Attachments = data.Attachments - - if item, err := services.EditPost(item); err != nil { - return fiber.NewError(fiber.StatusBadRequest, err.Error()) - } else { - return c.JSON(item) - } -} - func deletePost(c *fiber.Ctx) error { if err := gap.H.EnsureAuthenticated(c); err != nil { return err diff --git a/pkg/internal/server/api/stories_api.go b/pkg/internal/server/api/stories_api.go new file mode 100644 index 0000000..b38f2e8 --- /dev/null +++ b/pkg/internal/server/api/stories_api.go @@ -0,0 +1,153 @@ +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" + jsoniter "github.com/json-iterator/go" + "time" +) + +func createStory(c *fiber.Ctx) error { + if err := gap.H.EnsureGrantedPerm(c, "CreatePosts", true); err != nil { + return err + } + user := c.Locals("user").(models.Account) + + var data struct { + Title *string `json:"title" validate:"max=1024"` + Content string `json:"content" validate:"required,max=4096"` + Location *string `json:"location" validate:"max=2048"` + Attachments []uint `json:"attachments"` + Tags []models.Tag `json:"tags"` + Categories []models.Category `json:"categories"` + PublishedAt *time.Time `json:"published_at"` + PublishedUntil *time.Time `json:"published_until"` + IsDraft bool `json:"is_draft"` + RealmAlias *string `json:"realm"` + ReplyTo *uint `json:"reply_to"` + RepostTo *uint `json:"repost_to"` + } + + if err := exts.BindAndValidate(c, &data); err != nil { + return err + } + + body := models.PostStoryBody{ + Title: data.Title, + Content: data.Content, + Location: data.Location, + Attachments: data.Attachments, + } + + var bodyMapping map[string]any + rawBody, _ := jsoniter.Marshal(body) + _ = jsoniter.Unmarshal(rawBody, &bodyMapping) + + item := models.Post{ + Type: models.PostTypeStory, + Body: bodyMapping, + Language: services.DetectLanguage(data.Content), + Tags: data.Tags, + Categories: data.Categories, + PublishedAt: data.PublishedAt, + PublishedUntil: data.PublishedUntil, + IsDraft: data.IsDraft, + AuthorID: user.ID, + } + + if data.ReplyTo != nil { + var replyTo models.Post + if err := database.C.Where("id = ?", data.ReplyTo).First(&replyTo).Error; err != nil { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("related post was not found: %v", err)) + } else { + item.ReplyID = &replyTo.ID + } + } + if data.RepostTo != nil { + var repostTo models.Post + if err := database.C.Where("id = ?", data.RepostTo).First(&repostTo).Error; err != nil { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("related post was not found: %v", err)) + } else { + item.RepostID = &repostTo.ID + } + } + + if data.RealmAlias != nil { + 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("unable to post in the realm, access denied: %v", err)) + } else { + item.RealmID = &realm.ID + } + } + + item, err := services.NewPost(user, item) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + return c.JSON(item) +} + +func editStory(c *fiber.Ctx) error { + id, _ := c.ParamsInt("postId", 0) + if err := gap.H.EnsureAuthenticated(c); err != nil { + return err + } + user := c.Locals("user").(models.Account) + + var data struct { + Title *string `json:"title" validate:"max=1024"` + Content string `json:"content" validate:"required,max=4096"` + Location *string `json:"location" validate:"max=2048"` + Attachments []uint `json:"attachments"` + Tags []models.Tag `json:"tags"` + Categories []models.Category `json:"categories"` + PublishedAt *time.Time `json:"published_at"` + PublishedUntil *time.Time `json:"published_until"` + IsDraft bool `json:"is_draft"` + } + + if err := exts.BindAndValidate(c, &data); err != nil { + return err + } + + var item models.Post + if err := database.C.Where(models.Post{ + BaseModel: models.BaseModel{ID: uint(id)}, + AuthorID: user.ID, + }).First(&item).Error; err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } + + body := models.PostStoryBody{ + Title: data.Title, + Content: data.Content, + Location: data.Location, + Attachments: data.Attachments, + } + + var bodyMapping map[string]any + rawBody, _ := jsoniter.Marshal(body) + _ = jsoniter.Unmarshal(rawBody, &bodyMapping) + + item.Body = bodyMapping + item.Language = services.DetectLanguage(data.Content) + item.Tags = data.Tags + item.Categories = data.Categories + item.PublishedAt = data.PublishedAt + item.PublishedUntil = data.PublishedUntil + item.IsDraft = data.IsDraft + + if item, err := services.EditPost(item); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } else { + return c.JSON(item) + } +} diff --git a/pkg/internal/services/articles.go b/pkg/internal/services/articles.go deleted file mode 100644 index 84835d5..0000000 --- a/pkg/internal/services/articles.go +++ /dev/null @@ -1,234 +0,0 @@ -package services - -import ( - "errors" - "fmt" - "time" - - "git.solsynth.dev/hydrogen/interactive/pkg/internal/database" - "git.solsynth.dev/hydrogen/interactive/pkg/internal/models" - "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 { - prefix := viper.GetString("database.prefix") - return tx.Joins(fmt.Sprintf("JOIN %sarticle_categories ON %sarticles.id = %sarticle_categories.article_id", prefix, prefix, prefix)). - Joins(fmt.Sprintf("JOIN %scategories ON %scategories.id = %sarticle_categories.category_id", prefix, prefix, prefix)). - Where(fmt.Sprintf("%scategories.alias = ?", prefix), alias) -} - -func FilterArticleWithTag(tx *gorm.DB, alias string) *gorm.DB { - prefix := viper.GetString("database.prefix") - return tx.Joins(fmt.Sprintf("JOIN %sarticle_tags ON %sarticles.id = %sarticle_tags.article_id", prefix, prefix, prefix)). - Joins(fmt.Sprintf("JOIN %stags ON %stags.id = %sarticle_tags.tag_id", prefix, prefix, prefix)). - Where(fmt.Sprintf("%stags.alias = ?", prefix), 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 = ? OR is_draft IS NULL", 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("Tags"). - Preload("Categories"). - 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("Tags"). - Preload("Categories"). - 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 > 100 { - take = 100 - } - - var items []*models.Article - if err := tx. - Limit(take).Offset(offset). - Order("created_at DESC"). - Preload("Tags"). - Preload("Categories"). - 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), "article_id"); 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.Metric = models.PostMetric{ - 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.Language = DetectLanguage(&item.Content) - - 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.Language = DetectLanguage(&item.Content) - 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 { - err = NotifyPosterAccount( - op.Author, - "Article got reacted", - fmt.Sprintf("%s (%s) reacted your article a %s", user.Nick, user.Name, reaction.Symbol), - lo.ToPtr(fmt.Sprintf("%s reacted your article", user.Nick)), - ) - 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/attachments.go b/pkg/internal/services/attachments.go deleted file mode 100644 index 6ab544a..0000000 --- a/pkg/internal/services/attachments.go +++ /dev/null @@ -1,21 +0,0 @@ -package services - -import ( - "context" - "git.solsynth.dev/hydrogen/dealer/pkg/hyper" - "git.solsynth.dev/hydrogen/interactive/pkg/internal/gap" - "git.solsynth.dev/hydrogen/paperclip/pkg/proto" - "github.com/samber/lo" -) - -func CheckAttachmentByIDExists(id uint, usage string) bool { - pc, err := gap.H.GetServiceGrpcConn(hyper.ServiceTypeFileProvider) - if err != nil { - return false - } - _, err = proto.NewAttachmentsClient(pc).CheckAttachmentExists(context.Background(), &proto.AttachmentLookupRequest{ - Id: lo.ToPtr(uint64(id)), - Usage: &usage, - }) - return err == nil -} diff --git a/pkg/internal/services/languages.go b/pkg/internal/services/languages.go index f244290..36e9503 100644 --- a/pkg/internal/services/languages.go +++ b/pkg/internal/services/languages.go @@ -5,14 +5,12 @@ import ( "strings" ) -func DetectLanguage(content *string) string { - if content != nil { - detector := lingua.NewLanguageDetectorBuilder(). - FromLanguages(lingua.AllLanguages()...). - Build() - if lang, ok := detector.DetectLanguageOf(*content); ok { - return strings.ToLower(lang.String()) - } +func DetectLanguage(content string) string { + detector := lingua.NewLanguageDetectorBuilder(). + FromLanguages(lingua.AllLanguages()...). + Build() + if lang, ok := detector.DetectLanguageOf(content); ok { + return strings.ToLower(lang.String()) } return "unknown" } diff --git a/pkg/internal/services/posts.go b/pkg/internal/services/posts.go index f56d0c8..ca9a52c 100644 --- a/pkg/internal/services/posts.go +++ b/pkg/internal/services/posts.go @@ -44,7 +44,9 @@ func FilterPostReply(tx *gorm.DB, replyTo ...uint) *gorm.DB { } func FilterPostWithPublishedAt(tx *gorm.DB, date time.Time) *gorm.DB { - return tx.Where("published_at <= ? OR published_at IS NULL", date) + return tx. + Where("published_at <= ? OR published_at IS NULL", date). + Where("published_until > ? OR published_until IS NULL", date) } func FilterPostWithAuthorDraft(tx *gorm.DB, uid uint) *gorm.DB { @@ -55,33 +57,6 @@ func FilterPostDraft(tx *gorm.DB) *gorm.DB { return tx.Where("is_draft = ? OR is_draft IS NULL", false) } -func GetPostWithAlias(tx *gorm.DB, alias string, ignoreLimitation ...bool) (models.Post, error) { - if len(ignoreLimitation) == 0 || !ignoreLimitation[0] { - tx = FilterPostWithPublishedAt(tx, time.Now()) - } - - var item models.Post - if err := tx. - Where("alias = ?", alias). - Preload("Tags"). - Preload("Categories"). - Preload("Realm"). - Preload("Author"). - Preload("ReplyTo"). - Preload("ReplyTo.Author"). - Preload("ReplyTo.Tags"). - Preload("ReplyTo.Categories"). - Preload("RepostTo"). - Preload("RepostTo.Author"). - Preload("RepostTo.Tags"). - Preload("RepostTo.Categories"). - First(&item).Error; err != nil { - return item, err - } - - return item, nil -} - func GetPost(tx *gorm.DB, id uint, ignoreLimitation ...bool) (models.Post, error) { if len(ignoreLimitation) == 0 || !ignoreLimitation[0] { tx = FilterPostWithPublishedAt(tx, time.Now()) @@ -148,7 +123,7 @@ func ListPost(tx *gorm.DB, take int, offset int, noReact ...bool) ([]*models.Pos var items []*models.Post if err := tx. Limit(take).Offset(offset). - Order("created_at DESC"). + Order("published_at DESC"). Preload("Tags"). Preload("Categories"). Preload("Realm"). @@ -243,7 +218,9 @@ func EnsurePostCategoriesAndTags(item models.Post) (models.Post, error) { } func NewPost(user models.Account, item models.Post) (models.Post, error) { - item.Language = DetectLanguage(item.Content) + if !item.IsDraft && item.PublishedAt == nil { + item.PublishedAt = lo.ToPtr(time.Now()) + } item, err := EnsurePostCategoriesAndTags(item) if err != nil { @@ -272,7 +249,7 @@ func NewPost(user models.Account, item models.Post) (models.Post, error) { err = NotifyPosterAccount( op.Author, "Post got replied", - fmt.Sprintf("%s (%s) replied your post #%s.", user.Nick, user.Name, op.Alias), + fmt.Sprintf("%s (%s) replied your post.", user.Nick, user.Name), lo.ToPtr(fmt.Sprintf("%s replied you", user.Nick)), ) if err != nil { @@ -286,7 +263,6 @@ func NewPost(user models.Account, item models.Post) (models.Post, error) { } func EditPost(item models.Post) (models.Post, error) { - item.Language = DetectLanguage(item.Content) item, err := EnsurePostCategoriesAndTags(item) if err != nil { return item, err @@ -312,9 +288,9 @@ func ReactPost(user models.Account, reaction models.Reaction) (bool, models.Reac if op.Author.ID != user.ID { err = NotifyPosterAccount( op.Author, - "Post got replied", - fmt.Sprintf("%s (%s) replied your post #%s.", user.Nick, user.Name, op.Alias), - lo.ToPtr(fmt.Sprintf("%s replied you", user.Nick)), + "Post got reacted", + fmt.Sprintf("%s (%s) reacted your post a %s.", user.Nick, user.Name, reaction.Symbol), + lo.ToPtr(fmt.Sprintf("%s reacted you", user.Nick)), ) if err != nil { log.Error().Err(err).Msg("An error occurred when notifying user...")