✨ Articles & Article CRUD APIs
This commit is contained in:
		| @@ -11,6 +11,7 @@ var AutoMaintainRange = []any{ | ||||
| 	&models.Category{}, | ||||
| 	&models.Tag{}, | ||||
| 	&models.Post{}, | ||||
| 	&models.Article{}, | ||||
| 	&models.Reaction{}, | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										32
									
								
								pkg/internal/models/articles.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								pkg/internal/models/articles.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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:"-"` | ||||
| } | ||||
| @@ -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"` | ||||
| } | ||||
|   | ||||
| @@ -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"` | ||||
|   | ||||
| @@ -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"` | ||||
| } | ||||
|   | ||||
							
								
								
									
										279
									
								
								pkg/internal/server/api/articles_api.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										279
									
								
								pkg/internal/server/api/articles_api.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| 	} | ||||
| } | ||||
| @@ -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) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|   | ||||
							
								
								
									
										223
									
								
								pkg/internal/services/articles.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										223
									
								
								pkg/internal/services/articles.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| 	} | ||||
| } | ||||
| @@ -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:     "/", | ||||
| 	}) | ||||
| } | ||||
| @@ -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")}, | ||||
| 	) | ||||
| } | ||||
| @@ -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 | ||||
| 	} | ||||
|   | ||||
							
								
								
									
										54
									
								
								pkg/internal/services/reactions.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								pkg/internal/services/reactions.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| } | ||||
		Reference in New Issue
	
	Block a user