diff --git a/go.mod b/go.mod index bf825c5..9fd92b1 100644 --- a/go.mod +++ b/go.mod @@ -29,12 +29,16 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect + git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-ap/activitypub v0.0.0-20250124194921-d52b4c694e14 // indirect + github.com/go-ap/errors v0.0.0-20250124135319-3da8adefd4a9 // indirect + github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect @@ -82,6 +86,7 @@ require ( github.com/tinylib/msgp v1.2.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.57.0 // indirect + github.com/valyala/fastjson v1.6.4 // indirect github.com/valyala/tcplisten v1.0.0 // indirect github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect diff --git a/go.sum b/go.sum index c380023..b694f92 100644 --- a/go.sum +++ b/go.sum @@ -45,6 +45,8 @@ git.solsynth.dev/hypernet/pusher v0.0.0-20241228030233-50ff8304e465 h1:KFtv9lF0J git.solsynth.dev/hypernet/pusher v0.0.0-20241228030233-50ff8304e465/go.mod h1:XHTqFU/vBe4JiuAjl87GUcL8+w/IizSNoqH6n3WkQFc= git.solsynth.dev/hypernet/wallet v0.0.0-20250129150034-87b94cdb5488 h1:/9Ol+PfDQFAYtHo0kk6sxqiEsZ6epb6yUEsZJxy14Mk= git.solsynth.dev/hypernet/wallet v0.0.0-20250129150034-87b94cdb5488/go.mod h1:jd1MTBI5NPHne22nq7nR7kyl4iYb9kV2A+tpXi7HOYY= +git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 h1:cliQ4HHsCo6xi2oWZYKWW4bly/Ory9FuTpFPRxj/mAg= +git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078/go.mod h1:g/V2Hjas6Z1UHUp4yIx6bATpNzJ7DYtD0FG3+xARWxs= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -95,6 +97,12 @@ github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/ github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/go-ap/activitypub v0.0.0-20250124194921-d52b4c694e14 h1:4VkepceDBxPt9BwsHncwtwIZCCgCuxctFHfosz8aWQA= +github.com/go-ap/activitypub v0.0.0-20250124194921-d52b4c694e14/go.mod h1:IO2PtAsxfGXN5IHrPuOslENFbq7MprYLNOyiiOELoRQ= +github.com/go-ap/errors v0.0.0-20250124135319-3da8adefd4a9 h1:AJBGzuJVgfkKF3LoXCNQfH9yWmsVDV/oPDJE/zeXOjE= +github.com/go-ap/errors v0.0.0-20250124135319-3da8adefd4a9/go.mod h1:Vkh+Z3f24K8nMsJKXo1FHn5ebPsXvB/WDH5JRtYqdNo= +github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73 h1:GMKIYXyXPGIp+hYiWOhfqK4A023HdgisDT4YGgf99mw= +github.com/go-ap/jsonld v0.0.0-20221030091449-f2a191312c73/go.mod h1:jyveZeGw5LaADntW+UEsMjl3IlIwk+DxlYNsbofQkGA= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -356,6 +364,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.57.0 h1:Xw8SjWGEP/+wAAgyy5XTvgrWlOD1+TxbbvNADYCm1Tg= github.com/valyala/fasthttp v1.57.0/go.mod h1:h6ZBaPRlzpZ6O3H5t2gEk1Qi33+TmLvfwgLLp0t9CpE= +github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= +github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= diff --git a/pkg/internal/database/migrator.go b/pkg/internal/database/migrator.go index 39429bb..1ccceb2 100644 --- a/pkg/internal/database/migrator.go +++ b/pkg/internal/database/migrator.go @@ -12,6 +12,8 @@ var AutoMaintainRange = []any{ &models.Post{}, &models.PostInsight{}, &models.Subscription{}, + &models.Poll{}, + &models.PollOption{}, } func RunMigration(source *gorm.DB) error { diff --git a/pkg/internal/http/api/index.go b/pkg/internal/http/api/index.go index 9fe940a..ca74957 100644 --- a/pkg/internal/http/api/index.go +++ b/pkg/internal/http/api/index.go @@ -64,6 +64,15 @@ func MapAPIs(app *fiber.App, baseURL string) { posts.Get("/:postId/replies/featured", listPostFeaturedReply) } + polls := api.Group("/polls").Name("Polls API") + { + polls.Get("/:pollId", getPoll) + polls.Post("/", createPoll) + polls.Put("/:pollId", updatePoll) + polls.Delete("/:pollId", deletePoll) + polls.Post("/:pollId/answer", answerPoll) + } + subscriptions := api.Group("/subscriptions").Name("Subscriptions API") { subscriptions.Get("/users/:userId", getSubscriptionOnUser) diff --git a/pkg/internal/http/api/poll_answers_api.go b/pkg/internal/http/api/poll_answers_api.go new file mode 100644 index 0000000..748fd54 --- /dev/null +++ b/pkg/internal/http/api/poll_answers_api.go @@ -0,0 +1,62 @@ +package api + +import ( + "time" + + "git.solsynth.dev/hypernet/interactive/pkg/internal/database" + "git.solsynth.dev/hypernet/interactive/pkg/internal/http/exts" + "git.solsynth.dev/hypernet/interactive/pkg/internal/models" + "git.solsynth.dev/hypernet/interactive/pkg/internal/services" + "git.solsynth.dev/hypernet/nexus/pkg/nex/sec" + authm "git.solsynth.dev/hypernet/passport/pkg/authkit/models" + "github.com/gofiber/fiber/v2" +) + +func answerPoll(c *fiber.Ctx) error { + pollId, _ := c.ParamsInt("pollId") + + if err := sec.EnsureAuthenticated(c); err != nil { + return err + } + user := c.Locals("user").(authm.Account) + + var data struct { + Answer string `json:"answer" validate:"required"` + } + + if err := exts.BindAndValidate(c, &data); err != nil { + return err + } + + var poll models.Poll + if err := database.C.Where("id = ?", pollId).First(&poll).Error; err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if poll.ExpiredAt != nil && time.Now().Unix() >= poll.ExpiredAt.Unix() { + return fiber.NewError(fiber.StatusBadRequest, "poll has been ended") + } + + doesContains := false + for _, option := range poll.Options { + if option.ID == data.Answer { + doesContains = true + break + } + } + if !doesContains { + return fiber.NewError(fiber.StatusBadRequest, "poll does not have a option like that") + } + + answer := models.PollAnswer{ + Answer: data.Answer, + PollID: poll.ID, + AccountID: user.ID, + } + + if answer, err := services.AddPollAnswer(poll, answer); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } else { + return c.JSON(answer) + } +} diff --git a/pkg/internal/http/api/polls_api.go b/pkg/internal/http/api/polls_api.go new file mode 100644 index 0000000..667e63b --- /dev/null +++ b/pkg/internal/http/api/polls_api.go @@ -0,0 +1,108 @@ +package api + +import ( + "time" + + "git.solsynth.dev/hypernet/interactive/pkg/internal/database" + "git.solsynth.dev/hypernet/interactive/pkg/internal/http/exts" + "git.solsynth.dev/hypernet/interactive/pkg/internal/models" + "git.solsynth.dev/hypernet/interactive/pkg/internal/services" + "git.solsynth.dev/hypernet/nexus/pkg/nex/sec" + authm "git.solsynth.dev/hypernet/passport/pkg/authkit/models" + "github.com/gofiber/fiber/v2" +) + +func getPoll(c *fiber.Ctx) error { + pollId, _ := c.ParamsInt("pollId") + + var poll models.Poll + if err := database.C.Where("id = ?", pollId).First(&poll).Error; err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } + + poll.Metric = services.GetPollMetric(poll) + + return c.JSON(poll) +} + +func createPoll(c *fiber.Ctx) error { + if err := sec.EnsureAuthenticated(c); err != nil { + return err + } + user := c.Locals("user").(authm.Account) + + var data struct { + Options []models.PollOption `json:"options" validate:"required"` + ExpiredAt *time.Time `json:"expired_at"` + } + + if err := exts.BindAndValidate(c, &data); err != nil { + return err + } + + poll := models.Poll{ + ExpiredAt: data.ExpiredAt, + Options: data.Options, + AccountID: user.ID, + } + + var err error + if poll, err = services.NewPoll(poll); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + return c.JSON(poll) +} + +func updatePoll(c *fiber.Ctx) error { + pollId, _ := c.ParamsInt("pollId") + + if err := sec.EnsureAuthenticated(c); err != nil { + return err + } + user := c.Locals("user").(authm.Account) + + var data struct { + Options []models.PollOption `json:"options" validate:"required"` + ExpiredAt *time.Time `json:"expired_at"` + } + + if err := exts.BindAndValidate(c, &data); err != nil { + return err + } + + var poll models.Poll + if err := database.C.Where("id = ? AND account_id = ?", pollId, user.ID).First(&poll).Error; err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } + + poll.Options = data.Options + poll.ExpiredAt = data.ExpiredAt + + var err error + if poll, err = services.UpdatePoll(poll); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + return c.JSON(poll) +} + +func deletePoll(c *fiber.Ctx) error { + pollId, _ := c.ParamsInt("pollId") + + if err := sec.EnsureAuthenticated(c); err != nil { + return err + } + user := c.Locals("user").(authm.Account) + + var poll models.Poll + if err := database.C.Where("id = ? AND account_id = ?", pollId, user.ID).First(&poll).Error; err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } + + if err := database.C.Delete(&poll).Error; err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + return c.JSON(poll) +} diff --git a/pkg/internal/models/polls.go b/pkg/internal/models/polls.go new file mode 100644 index 0000000..02426ef --- /dev/null +++ b/pkg/internal/models/polls.go @@ -0,0 +1,38 @@ +package models + +import ( + "time" + + "git.solsynth.dev/hypernet/nexus/pkg/nex/cruda" + "gorm.io/datatypes" +) + +type Poll struct { + cruda.BaseModel + + ExpiredAt *time.Time `json:"expired_at"` + Options datatypes.JSONSlice[PollOption] `json:"options"` + AccountID uint `json:"account_id"` + + Metric PollMetric `json:"metric" gorm:"-"` +} + +type PollMetric struct { + TotalAnswer int64 `json:"total_answer"` + ByOptions map[string]int64 `json:"by_options"` +} + +type PollOption struct { + ID string `json:"id"` + Icon string `json:"icon"` + Name string `json:"name"` + Description string `json:"description"` +} + +type PollAnswer struct { + cruda.BaseModel + + Answer string `json:"answer"` + PollID uint `json:"poll_id"` + AccountID uint `json:"account_id"` +} diff --git a/pkg/internal/services/polls.go b/pkg/internal/services/polls.go new file mode 100644 index 0000000..c67f144 --- /dev/null +++ b/pkg/internal/services/polls.go @@ -0,0 +1,56 @@ +package services + +import ( + "fmt" + + "git.solsynth.dev/hypernet/interactive/pkg/internal/database" + "git.solsynth.dev/hypernet/interactive/pkg/internal/models" +) + +func NewPoll(poll models.Poll) (models.Poll, error) { + if err := database.C.Create(&poll).Error; err != nil { + return poll, err + } + return poll, nil +} + +func UpdatePoll(poll models.Poll) (models.Poll, error) { + if err := database.C.Save(&poll).Error; err != nil { + return poll, err + } + return poll, nil +} + +func AddPollAnswer(poll models.Poll, answer models.PollAnswer) (models.PollAnswer, error) { + answer.PollID = poll.ID + + var count int64 + if err := database.C.Model(&models.PollAnswer{}).Where("poll_id = ? AND account_id = ?", poll.ID, answer.AccountID).Count(&count).Error; err != nil { + return answer, fmt.Errorf("you already answered the poll") + } + if err := database.C.Create(&answer).Error; err != nil { + return answer, err + } + + return answer, nil +} + +func GetPollMetric(poll models.Poll) models.PollMetric { + var answers []models.PollAnswer + if err := database.C.Where("poll_id = ?", poll.ID).Find(&answers); err != nil { + return models.PollMetric{} + } + + byOptions := make(map[string]int64) + for _, answer := range answers { + if _, ok := byOptions[answer.Answer]; !ok { + byOptions[answer.Answer] = 0 + } + byOptions[answer.Answer]++ + } + + return models.PollMetric{ + TotalAnswer: int64(len(answers)), + ByOptions: byOptions, + } +}