diff --git a/CODEOWNERS b/CODEOWNERS index 8f4f8a588..9882e7b12 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,7 +1,7 @@ # Default reviewers for every PR -* @huynguyenh @namnhce @lmquang -.github/ @huynguyenh @namnhce @lmquang -cmd/ @huynguyenh @namnhce @lmquang -docs/ @huynguyenh @namnhce @lmquang -migrations/ @huynguyenh @namnhce @lmquang -pkg/ @huynguyenh @namnhce @lmquang +* @huynguyenh @lmquang +.github/ @huynguyenh @lmquang +cmd/ @huynguyenh @lmquang +docs/ @huynguyenh @lmquang +migrations/ @huynguyenh @lmquang +pkg/ @huynguyenh @lmquang diff --git a/migrations/schemas/20241220064435-merge_memo_authors_to_memo_logs.sql b/migrations/schemas/20241220064435-merge_memo_authors_to_memo_logs.sql new file mode 100644 index 000000000..5c7824442 --- /dev/null +++ b/migrations/schemas/20241220064435-merge_memo_authors_to_memo_logs.sql @@ -0,0 +1,56 @@ +-- +migrate Up +-- Add JSONB column to store discord account IDs +ALTER TABLE memo_logs +ADD COLUMN discord_account_ids JSONB DEFAULT '[]'::JSONB; + +-- Migrate data from memo_authors to memo_logs +WITH author_data AS ( + SELECT + memo_log_id, + json_agg(discord_account_id) AS discord_account_ids + FROM memo_authors + GROUP BY memo_log_id +) +UPDATE memo_logs ml +SET discord_account_ids = ad.discord_account_ids +FROM author_data ad +WHERE ml.id = ad.memo_log_id; + +-- Drop the memo_authors table +DROP TABLE memo_authors; +DROP TABLE brainery_logs; + +-- +migrate Down +CREATE TABLE "brainery_logs" ( + "id" uuid NOT NULL DEFAULT uuid(), + "deleted_at" timestamp, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now(), + "title" text NOT NULL, + "url" text NOT NULL, + "github_id" text, + "discord_id" text NOT NULL, + "employee_id" uuid, + "tags" jsonb, + "published_at" timestamp NOT NULL, + "reward" numeric, + CONSTRAINT "brainery_logs_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id"), + PRIMARY KEY ("id") +); +CREATE TABLE IF NOT EXISTS memo_authors ( + memo_log_id UUID NOT NULL REFERENCES memo_logs(id), + discord_account_id UUID NOT NULL REFERENCES discord_accounts(id), + created_at TIMESTAMP(6) DEFAULT (now()), + PRIMARY KEY (memo_log_id, discord_account_id) +); + +INSERT INTO memo_authors (memo_log_id, discord_account_id) +SELECT + id AS memo_log_id, + jsonb_array_elements(discord_account_ids)::UUID AS discord_account_id +FROM memo_logs +WHERE jsonb_array_length(discord_account_ids) > 0; + +-- Remove the JSONB column +ALTER TABLE memo_logs +DROP COLUMN discord_account_ids; diff --git a/pkg/controller/memologs/sync.go b/pkg/controller/memologs/sync.go index 80bf3c98b..8d65f9682 100644 --- a/pkg/controller/memologs/sync.go +++ b/pkg/controller/memologs/sync.go @@ -97,12 +97,17 @@ func (c *controller) Sync() ([]model.MemoLog, error) { continue } + discordAccountIDs := make([]string, 0, len(authors)) + for _, author := range authors { + discordAccountIDs = append(discordAccountIDs, author.ID.String()) + } + newMemos = append(newMemos, model.MemoLog{ Title: item.Title, URL: item.Link, Description: item.Description, PublishedAt: &pubDate, - Authors: authors, + DiscordAccountIDs: discordAccountIDs, AuthorMemoUsernames: authorUsernames, Category: extractMemoCategory(item.Link), }) diff --git a/pkg/handler/discord/discord.go b/pkg/handler/discord/discord.go index 9a7cc9581..41c4f8d5f 100644 --- a/pkg/handler/discord/discord.go +++ b/pkg/handler/discord/discord.go @@ -54,7 +54,7 @@ func New(controller *controller.Controller, store *store.Store, repo store.DBRep const ( discordReadingChannel = "1225085624260759622" discordRandomChannel = "788084358991970337" - discordPlayGroundReadingChannel = "1119171172198797393" + discordPlayGroundReadingChannel = "1064460652720160808" // quang's channel ) func (h *handler) SyncDiscordInfo(c *gin.Context) { @@ -375,7 +375,14 @@ func (h *handler) SyncMemo(c *gin.Context) { return } - _, err = h.service.Discord.SendNewMemoMessage(h.config.Discord.IDs.DwarvesGuild, memos, targetChannelID) + _, err = h.service.Discord.SendNewMemoMessage( + h.config.Discord.IDs.DwarvesGuild, + memos, + targetChannelID, + func(discordAccountID string) (*model.DiscordAccount, error) { + return h.store.DiscordAccount.One(h.repo.DB(), discordAccountID) + }, + ) if err != nil { h.logger.Error(err, "failed to send new memo message") c.JSON(http.StatusInternalServerError, view.CreateResponse[any](nil, nil, err, nil, "")) @@ -396,6 +403,61 @@ func (h *handler) SweepMemo(c *gin.Context) { c.JSON(http.StatusOK, view.CreateResponse[any](nil, nil, nil, nil, "ok")) } +func (h *handler) NotifyTopMemoAuthors(c *gin.Context) { + in := request.TopMemoAuthorsInput{} + if err := c.ShouldBindJSON(&in); err != nil { + h.logger.Error(err, "failed to decode body") + c.JSON(http.StatusBadRequest, view.CreateResponse[any](nil, nil, err, in, "")) + return + } + + if err := in.Validate(); err != nil { + h.logger.Error(err, "failed to validate input") + c.JSON(http.StatusBadRequest, view.CreateResponse[any](nil, nil, err, in, "")) + return + } + + now := time.Now() + end := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 999999999, now.Location()) + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -in.Days+1) + + topAuthors, err := h.store.MemoLog.GetTopAuthors(h.repo.DB(), in.Limit, &start, &end) + if err != nil { + h.logger.Error(err, "failed to retrieve top memo authors") + c.JSON(http.StatusInternalServerError, view.CreateResponse[any](nil, nil, err, nil, "")) + return + } + + if len(topAuthors) == 0 { + c.JSON(http.StatusOK, view.CreateResponse[any](nil, nil, nil, nil, "no memo authors found")) + return + } + + var topAuthorsStr string + for i, author := range topAuthors { + topAuthorsStr += fmt.Sprintf("%d. <@%s> (%d memos)\n", i+1, author.DiscordID, author.TotalMemos) + } + + targetChannelID := discordPlayGroundReadingChannel + if h.config.Env == "prod" { + targetChannelID = discordRandomChannel + } + + title := fmt.Sprintf("Top %d Memo Authors (Last %d Days)", in.Limit, in.Days) + msg := &discordgo.MessageEmbed{ + Title: title, + Description: topAuthorsStr, + } + + _, err = h.service.Discord.SendEmbeddedMessageWithChannel(nil, msg, targetChannelID) + if err != nil { + h.logger.Error(err, "failed to send top memo authors message") + c.JSON(http.StatusInternalServerError, view.CreateResponse[any](nil, nil, err, nil, "")) + return + } + + c.JSON(http.StatusOK, view.CreateResponse[any](nil, nil, nil, nil, "ok")) +} func (h *handler) NotifyWeeklyMemos(c *gin.Context) { // get last 7 days @@ -430,7 +492,15 @@ func (h *handler) NotifyWeeklyMemos(c *gin.Context) { targetChannelID = discordRandomChannel } - _, err = h.service.Discord.SendWeeklyMemosMessage(h.config.Discord.IDs.DwarvesGuild, memos, weekRangeStr, targetChannelID) + _, err = h.service.Discord.SendWeeklyMemosMessage( + h.config.Discord.IDs.DwarvesGuild, + memos, + weekRangeStr, + targetChannelID, + func(discordAccountID string) (*model.DiscordAccount, error) { + return h.store.DiscordAccount.One(h.repo.DB(), discordAccountID) + }, + ) if err != nil { h.logger.Error(err, "failed to send weekly memos report") c.JSON(http.StatusInternalServerError, view.CreateResponse[any](nil, nil, err, nil, "")) diff --git a/pkg/handler/discord/interface.go b/pkg/handler/discord/interface.go index edd7200b6..44425c958 100644 --- a/pkg/handler/discord/interface.go +++ b/pkg/handler/discord/interface.go @@ -18,4 +18,5 @@ type IHandler interface { UserOgifStats(c *gin.Context) OgifLeaderboard(c *gin.Context) SweepOgifEvent(c *gin.Context) + NotifyTopMemoAuthors(c *gin.Context) } diff --git a/pkg/handler/discord/request/top_memo_authors.go b/pkg/handler/discord/request/top_memo_authors.go new file mode 100644 index 000000000..dd8408151 --- /dev/null +++ b/pkg/handler/discord/request/top_memo_authors.go @@ -0,0 +1,18 @@ +package request + +import "errors" + +type TopMemoAuthorsInput struct { + Limit int `json:"limit"` + Days int `json:"days"` +} + +func (i *TopMemoAuthorsInput) Validate() error { + if i.Limit <= 0 { + return errors.New("limit must be greater than 0") + } + if i.Days <= 0 { + return errors.New("days must be greater than 0") + } + return nil +} diff --git a/pkg/handler/memologs/memo_log.go b/pkg/handler/memologs/memo_log.go index 3274e474d..b298dd754 100644 --- a/pkg/handler/memologs/memo_log.go +++ b/pkg/handler/memologs/memo_log.go @@ -8,9 +8,11 @@ import ( "time" "github.com/bwmarrin/discordgo" + "github.com/gin-gonic/gin" + "github.com/dwarvesf/fortress-api/pkg/config" "github.com/dwarvesf/fortress-api/pkg/controller" - "github.com/dwarvesf/fortress-api/pkg/handler/memologs/request" + memologRequest "github.com/dwarvesf/fortress-api/pkg/handler/memologs/request" "github.com/dwarvesf/fortress-api/pkg/logger" "github.com/dwarvesf/fortress-api/pkg/model" "github.com/dwarvesf/fortress-api/pkg/service" @@ -18,7 +20,6 @@ import ( "github.com/dwarvesf/fortress-api/pkg/store" "github.com/dwarvesf/fortress-api/pkg/store/memolog" "github.com/dwarvesf/fortress-api/pkg/view" - "github.com/gin-gonic/gin" ) type handler struct { @@ -51,7 +52,7 @@ func (h *handler) Create(c *gin.Context) { }, ) - body := request.CreateMemoLogsRequest{} + body := memologRequest.CreateMemoLogsRequest{} if err := c.ShouldBindJSON(&body); err != nil { l.Error(err, "[memologs.Create] failed to decode body") c.JSON(http.StatusBadRequest, view.CreateResponse[any](nil, nil, err, body, "")) @@ -132,14 +133,19 @@ func (h *handler) Create(c *gin.Context) { authors = append(authors, newAuthor) } + discordAccountIDs := make([]string, 0, len(authors)) + for _, author := range authors { + discordAccountIDs = append(discordAccountIDs, author.ID.String()) + } + b := model.MemoLog{ - Title: b.Title, - URL: b.URL, - Authors: authors, - Tags: b.Tags, - PublishedAt: &publishedAt, - Description: b.Description, - Reward: b.Reward, + Title: b.Title, + URL: b.URL, + DiscordAccountIDs: discordAccountIDs, + Tags: b.Tags, + PublishedAt: &publishedAt, + Description: b.Description, + Reward: b.Reward, } memologs = append(memologs, b) } @@ -283,7 +289,26 @@ func (h *handler) GetTopAuthors(c *gin.Context) { }, ) - topAuthors, err := h.store.MemoLog.GetTopAuthors(h.repo.DB(), 10) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) + days, _ := strconv.Atoi(c.DefaultQuery("days", "30")) + + if limit <= 0 { + l.Error(errors.New("limit must be greater than 0"), "invalid limit") + c.JSON(http.StatusBadRequest, view.CreateResponse[any](nil, nil, errors.New("limit must be greater than 0"), nil, "")) + return + } + + if days <= 0 { + l.Error(errors.New("days must be greater than 0"), "invalid days") + c.JSON(http.StatusBadRequest, view.CreateResponse[any](nil, nil, errors.New("days must be greater than 0"), nil, "")) + return + } + + now := time.Now() + end := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 999999999, now.Location()) + start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -days+1) + + topAuthors, err := h.store.MemoLog.GetTopAuthors(h.repo.DB(), limit, &start, &end) if err != nil { l.Error(err, "failed to get top authors") c.JSON(http.StatusInternalServerError, view.CreateResponse[any](nil, nil, err, nil, "")) diff --git a/pkg/handler/survey/survey_test.go b/pkg/handler/survey/survey_test.go index 0fdbc9275..f7ccf669f 100644 --- a/pkg/handler/survey/survey_test.go +++ b/pkg/handler/survey/survey_test.go @@ -825,6 +825,7 @@ func TestHandler_GetSurveyReviewDetail(t *testing.T) { } } +// year in test case should be changed to the current year func TestHandler_CreateSurvey(t *testing.T) { cfg := config.LoadTestConfig() loggerMock := logger.NewLogrusLogger() @@ -844,10 +845,10 @@ func TestHandler_CreateSurvey(t *testing.T) { wantResponsePath: "testdata/create_survey/200_work.json", body: request.CreateSurveyFeedbackInput{ Quarter: "q3,q4", - Year: 2023, + Year: 2025, Type: "peer-review", - FromDate: "2023-11-28", - ToDate: "2023-11-29", + FromDate: "2025-11-28", + ToDate: "2025-11-29", }, }, { @@ -856,10 +857,10 @@ func TestHandler_CreateSurvey(t *testing.T) { wantResponsePath: "testdata/create_survey/400.json", body: request.CreateSurveyFeedbackInput{ Quarter: "q3,q4", - Year: 2023, + Year: 2025, Type: "work", - FromDate: "2023-11-30", - ToDate: "2023-11-29", + FromDate: "2025-11-30", + ToDate: "2025-11-29", }, }, { @@ -868,10 +869,10 @@ func TestHandler_CreateSurvey(t *testing.T) { wantResponsePath: "testdata/create_survey/invalid_subtype.json", body: request.CreateSurveyFeedbackInput{ Quarter: "q3,q4", - Year: 2023, + Year: 2025, Type: "peer-revieww", - FromDate: "2023-11-28", - ToDate: "2023-11-29", + FromDate: "2025-11-28", + ToDate: "2025-11-29", }, }, { diff --git a/pkg/model/memo_log.go b/pkg/model/memo_log.go index ecc5df705..3351d9ab7 100644 --- a/pkg/model/memo_log.go +++ b/pkg/model/memo_log.go @@ -5,7 +5,6 @@ import ( "github.com/lib/pq" "github.com/shopspring/decimal" - "gorm.io/gorm" ) type MemoLog struct { @@ -19,7 +18,7 @@ type MemoLog struct { Reward decimal.Decimal Category pq.StringArray `json:"value" gorm:"type:text[]"` - Authors []DiscordAccount `json:"authors" gorm:"many2many:memo_authors;"` + DiscordAccountIDs JSONArrayString `json:"discord_account_ids" gorm:"type:jsonb;column:discord_account_ids"` // This field is used to make sure response always contains authors AuthorMemoUsernames []string `json:"-" gorm:"-"` @@ -33,6 +32,4 @@ type DiscordAccountMemoRank struct { Rank int } -func (MemoLog) BeforeCreate(db *gorm.DB) error { - return db.SetupJoinTable(&MemoLog{}, "Authors", &MemoAuthor{}) -} +// Remove BeforeCreate method as we no longer use many-to-many join table diff --git a/pkg/routes/v1.go b/pkg/routes/v1.go index 5fc8cef4b..441a75f0f 100644 --- a/pkg/routes/v1.go +++ b/pkg/routes/v1.go @@ -34,6 +34,7 @@ func loadV1Routes(r *gin.Engine, h *handler.Handler, repo store.DBRepo, s *store cronjob.POST("/sync-memo", amw.WithAuth, pmw.WithPerm(model.PermissionCronjobExecute), h.Discord.SyncMemo) cronjob.POST("/sweep-memo", amw.WithAuth, pmw.WithPerm(model.PermissionCronjobExecute), h.Discord.SweepMemo) cronjob.POST("/notify-weekly-memos", amw.WithAuth, pmw.WithPerm(model.PermissionCronjobExecute), h.Discord.NotifyWeeklyMemos) + cronjob.POST("/notify-top-memo-authors", amw.WithAuth, pmw.WithPerm(model.PermissionCronjobExecute), h.Discord.NotifyTopMemoAuthors) cronjob.POST("/transcribe-youtube-broadcast", amw.WithAuth, pmw.WithPerm(model.PermissionCronjobExecute), h.Youtube.TranscribeBroadcast) cronjob.POST("/sweep-ogif-event", amw.WithAuth, pmw.WithPerm(model.PermissionCronjobExecute), h.Discord.SweepOgifEvent) } diff --git a/pkg/routes/v1_test.go b/pkg/routes/v1_test.go index 5834588d0..9aade63da 100644 --- a/pkg/routes/v1_test.go +++ b/pkg/routes/v1_test.go @@ -693,6 +693,12 @@ func Test_loadV1Routes(t *testing.T) { Handler: "github.com/dwarvesf/fortress-api/pkg/handler/discord.IHandler.NotifyWeeklyMemos-fm", }, }, + "/cronjobs/notify-top-memo-authors": { + "POST": { + Method: "POST", + Handler: "github.com/dwarvesf/fortress-api/pkg/handler/discord.IHandler.NotifyTopMemoAuthors-fm", + }, + }, "/cronjobs/transcribe-youtube-broadcast": { "POST": { Method: "POST", diff --git a/pkg/service/discord/discord.go b/pkg/service/discord/discord.go index ddb425ea0..025e31cd2 100644 --- a/pkg/service/discord/discord.go +++ b/pkg/service/discord/discord.go @@ -678,17 +678,30 @@ func (d *discordClient) SendEmbeddedMessageWithChannel(original *model.OriginalD return msg, err } -func (d *discordClient) SendNewMemoMessage(guildID string, memos []model.MemoLog, channelID string) (*discordgo.Message, error) { +func (d *discordClient) SendNewMemoMessage( + guildID string, + memos []model.MemoLog, + channelID string, + getDiscordAccountByID func(discordAccountID string) (*model.DiscordAccount, error), +) (*discordgo.Message, error) { for i, content := range memos { if i <= 10 { var textMessage string authorField := "" - for _, author := range content.Authors { - if author.DiscordID != "" { - authorField += fmt.Sprintf(" <@%s> ", author.DiscordID) - } else if author.DiscordUsername != "" { - authorField += fmt.Sprintf(" @%s ", author.DiscordUsername) + for _, discordAccountID := range content.DiscordAccountIDs { + // Fetch discord account details for the ID + discordAccount, err := getDiscordAccountByID(discordAccountID) + if err != nil { + // If fetching fails, use the ID as a fallback + authorField += fmt.Sprintf(" <@%s> ", discordAccountID) + continue + } + + if discordAccount.DiscordID != "" { + authorField += fmt.Sprintf(" <@%s> ", discordAccount.DiscordID) + } else if discordAccount.DiscordUsername != "" { + authorField += fmt.Sprintf(" @%s ", discordAccount.DiscordUsername) } else { authorField += " **@unknown-user**" } @@ -722,7 +735,13 @@ func (d *discordClient) SendNewMemoMessage(guildID string, memos []model.MemoLog return nil, nil } -func (d *discordClient) SendWeeklyMemosMessage(guildID string, memos []model.MemoLog, weekRangeStr, channelID string) (*discordgo.Message, error) { +func (d *discordClient) SendWeeklyMemosMessage( + guildID string, + memos []model.MemoLog, + weekRangeStr, + channelID string, + getDiscordAccountByID func(discordAccountID string) (*model.DiscordAccount, error), +) (*discordgo.Message, error) { bagde1Emoji := getEmoji("BADGE1") bagde5Emoji := getEmoji("BADGE5") pepeNoteEmoji := getEmoji("PEPE_NOTE") @@ -769,13 +788,21 @@ func (d *discordClient) SendWeeklyMemosMessage(guildID string, memos []model.Mem for idx, mem := range memos { authorField := "" - for _, author := range mem.Authors { - authorMap[author.DiscordID] += 1 + for _, discordAccountID := range mem.DiscordAccountIDs { + // Fetch discord account details for the ID + discordAccount, err := getDiscordAccountByID(discordAccountID) + if err != nil { + // If fetching fails, use the ID as a fallback + authorField += fmt.Sprintf(" <@%s> ", discordAccountID) + continue + } + + authorMap[discordAccount.DiscordID] += 1 - if author.DiscordID != "" { - authorField += fmt.Sprintf(" <@%s> ", author.DiscordID) - } else if author.DiscordUsername != "" { - authorField += fmt.Sprintf(" @%s ", author.DiscordUsername) + if discordAccount.DiscordID != "" { + authorField += fmt.Sprintf(" <@%s> ", discordAccount.DiscordID) + } else if discordAccount.DiscordUsername != "" { + authorField += fmt.Sprintf(" @%s ", discordAccount.DiscordUsername) } else { authorField += " **@unknown-user**" } diff --git a/pkg/service/discord/service.go b/pkg/service/discord/service.go index 1769c213c..6bc810943 100644 --- a/pkg/service/discord/service.go +++ b/pkg/service/discord/service.go @@ -30,8 +30,19 @@ type IService interface { ReportBraineryMetrics(queryView string, braineryMetric *view.BraineryMetric, channelID string) (*discordgo.Message, error) DeliveryMetricWeeklyReport(deliveryMetrics *view.DeliveryMetricWeeklyReport, leaderBoard *view.WeeklyLeaderBoard, channelID string) (*discordgo.Message, error) DeliveryMetricMonthlyReport(deliveryMetrics *view.DeliveryMetricMonthlyReport, leaderBoard *view.WeeklyLeaderBoard, channelID string) (*discordgo.Message, error) - SendNewMemoMessage(guildID string, memos []model.MemoLog, channelID string) (*discordgo.Message, error) - SendWeeklyMemosMessage(guildID string, memos []model.MemoLog, weekRangeStr, channelID string) (*discordgo.Message, error) + SendNewMemoMessage( + guildID string, + memos []model.MemoLog, + channelID string, + getDiscordAccountByID func(discordAccountID string) (*model.DiscordAccount, error), + ) (*discordgo.Message, error) + SendWeeklyMemosMessage( + guildID string, + memos []model.MemoLog, + weekRangeStr, + channelID string, + getDiscordAccountByID func(discordAccountID string) (*model.DiscordAccount, error), + ) (*discordgo.Message, error) /* WEBHOOK */ diff --git a/pkg/store/memolog/interface.go b/pkg/store/memolog/interface.go index 3a4480a99..bf2b3da9a 100644 --- a/pkg/store/memolog/interface.go +++ b/pkg/store/memolog/interface.go @@ -15,5 +15,5 @@ type IStore interface { GetRankByDiscordID(db *gorm.DB, discordID string) (*model.DiscordAccountMemoRank, error) ListNonAuthor(db *gorm.DB) ([]model.MemoLog, error) CreateMemoAuthor(db *gorm.DB, memoAuthor *model.MemoAuthor) error - GetTopAuthors(db *gorm.DB, limit int) ([]model.DiscordAccountMemoRank, error) + GetTopAuthors(db *gorm.DB, limit int, from, to *time.Time) ([]model.DiscordAccountMemoRank, error) } diff --git a/pkg/store/memolog/memo_log.go b/pkg/store/memolog/memo_log.go index 92f37923c..12d0a484f 100644 --- a/pkg/store/memolog/memo_log.go +++ b/pkg/store/memolog/memo_log.go @@ -23,7 +23,7 @@ func (s *store) Create(db *gorm.DB, b []model.MemoLog) ([]model.MemoLog, error) // GetLimitByTimeRange gets memo logs in a specific time range, with limit func (s *store) GetLimitByTimeRange(db *gorm.DB, start, end *time.Time, limit int) ([]model.MemoLog, error) { var logs []model.MemoLog - return logs, db.Preload("Authors").Preload("Authors.Employee").Where("published_at BETWEEN ? AND ?", start, end).Limit(limit).Order("published_at DESC").Find(&logs).Error + return logs, db.Where("published_at BETWEEN ? AND ?", start, end).Limit(limit).Order("published_at DESC").Find(&logs).Error } // ListFilter is a filter for List function @@ -36,7 +36,7 @@ type ListFilter struct { // List gets all memo logs func (s *store) List(db *gorm.DB, filter ListFilter) ([]model.MemoLog, error) { var logs []model.MemoLog - query := db.Preload("Authors").Preload("Authors.Employee").Order("published_at DESC") + query := db.Order("published_at DESC") if filter.From != nil { query = query.Where("published_at >= ?", *filter.From) } @@ -45,8 +45,7 @@ func (s *store) List(db *gorm.DB, filter ListFilter) ([]model.MemoLog, error) { } if filter.DiscordID != "" { - query = query.Joins("JOIN memo_authors ma ON ma.memo_log_id = memo_logs.id"). - Joins("JOIN discord_accounts da ON da.id = ma.discord_account_id AND da.discord_id = ?", filter.DiscordID) + query = query.Where("? = ANY(discord_account_ids)", filter.DiscordID) } return logs, query.Find(&logs).Error @@ -60,13 +59,9 @@ func (s *store) ListNonAuthor(db *gorm.DB) ([]model.MemoLog, error) { memo_logs.* FROM memo_logs - LEFT JOIN - memo_authors ON memo_authors.memo_log_id = memo_logs.id - GROUP BY - memo_logs.id - HAVING - STRING_AGG(memo_authors.discord_account_id::text, ', ') IS NULL OR - STRING_AGG(memo_authors.discord_account_id::text, ', ') = '' + WHERE + discord_account_ids IS NULL OR + jsonb_array_length(discord_account_ids) = 0 ` return logs, db.Raw(query).Scan(&logs).Error @@ -74,18 +69,24 @@ func (s *store) ListNonAuthor(db *gorm.DB) ([]model.MemoLog, error) { func (s *store) GetRankByDiscordID(db *gorm.DB, discordID string) (*model.DiscordAccountMemoRank, error) { query := ` - WITH memo_count AS ( + WITH discord_account AS ( + SELECT id + FROM public.discord_accounts + WHERE discord_id = ? + ), + memo_count AS ( SELECT da.discord_id, - COUNT(ml.id) AS total_memos + COUNT(DISTINCT ml.id) AS total_memos FROM - public.memo_authors ma - JOIN - public.memo_logs ml ON ma.memo_log_id = ml.id - JOIN - public.discord_accounts da ON ma.discord_account_id = da.id + public.memo_logs ml, + discord_account da_id, + public.discord_accounts da, + jsonb_array_elements_text(ml.discord_account_ids) AS account_id WHERE - ml.deleted_at IS NULL + ml.deleted_at IS NULL AND + da.id = da_id.id AND + da.id::text = account_id GROUP BY da.discord_id ), @@ -93,7 +94,7 @@ func (s *store) GetRankByDiscordID(db *gorm.DB, discordID string) (*model.Discor SELECT discord_id, total_memos, - RANK() OVER (ORDER BY total_memos DESC) AS rank + DENSE_RANK() OVER (ORDER BY total_memos DESC) AS rank FROM memo_count ) @@ -103,8 +104,6 @@ func (s *store) GetRankByDiscordID(db *gorm.DB, discordID string) (*model.Discor rm.rank FROM ranked_memos rm - WHERE - rm.discord_id = ? ` var memoRank model.DiscordAccountMemoRank result := db.Raw(query, discordID).Scan(&memoRank) @@ -122,26 +121,26 @@ func (s *store) GetRankByDiscordID(db *gorm.DB, discordID string) (*model.Discor // CreateMemoAuthor creates a memo author record in the database func (s *store) CreateMemoAuthor(db *gorm.DB, memoAuthor *model.MemoAuthor) error { - return db.Create(memoAuthor).Error + return fmt.Errorf("memo_authors table no longer exists") } -// GetTopAuthors gets the top authors by memo count -func (s *store) GetTopAuthors(db *gorm.DB, limit int) ([]model.DiscordAccountMemoRank, error) { +// GetTopAuthors gets the top authors by memo count within a time range +func (s *store) GetTopAuthors(db *gorm.DB, limit int, from, to *time.Time) ([]model.DiscordAccountMemoRank, error) { query := ` WITH memo_count AS ( SELECT da.discord_id, da.discord_username, da.memo_username, - COUNT(ml.id) AS total_memos + COUNT(DISTINCT ml.id) AS total_memos FROM - public.memo_authors ma - JOIN - public.memo_logs ml ON ma.memo_log_id = ml.id - JOIN - public.discord_accounts da ON ma.discord_account_id = da.id + public.memo_logs ml, + public.discord_accounts da, + jsonb_array_elements_text(ml.discord_account_ids) AS account_id WHERE - ml.deleted_at IS NULL -- Exclude deleted memos if necessary + ml.deleted_at IS NULL AND + da.id::text = account_id AND + ml.published_at BETWEEN ? AND ? GROUP BY da.discord_id, da.discord_username, @@ -152,7 +151,7 @@ func (s *store) GetTopAuthors(db *gorm.DB, limit int) ([]model.DiscordAccountMem discord_username, memo_username, total_memos, - RANK() OVER (ORDER BY total_memos DESC) AS rank + DENSE_RANK() OVER (ORDER BY total_memos DESC) AS rank FROM memo_count ORDER BY @@ -160,5 +159,5 @@ func (s *store) GetTopAuthors(db *gorm.DB, limit int) ([]model.DiscordAccountMem LIMIT ?; ` var topAuthors []model.DiscordAccountMemoRank - return topAuthors, db.Raw(query, limit).Scan(&topAuthors).Error + return topAuthors, db.Raw(query, from, to, limit).Scan(&topAuthors).Error } diff --git a/pkg/view/memo.go b/pkg/view/memo.go index 305f02859..e5f932e1b 100644 --- a/pkg/view/memo.go +++ b/pkg/view/memo.go @@ -3,8 +3,9 @@ package view import ( "time" - "github.com/dwarvesf/fortress-api/pkg/model" "github.com/shopspring/decimal" + + "github.com/dwarvesf/fortress-api/pkg/model" ) type MemoLog struct { @@ -35,9 +36,30 @@ type MemoLogsResponse struct { func ToMemoLog(memoLogs []model.MemoLog) []MemoLog { rs := make([]MemoLog, 0) + + // Fetch all unique discord account IDs + discordAccountIDs := make(map[string]bool) + for _, memoLog := range memoLogs { + for _, discordAccountID := range memoLog.DiscordAccountIDs { + discordAccountIDs[discordAccountID] = true + } + } + + // Fetch discord accounts for these IDs (this would typically be done via a database query) + // For now, we'll leave this as a placeholder + discordAccounts := make(map[string]model.DiscordAccount) + for _, memoLog := range memoLogs { authors := make([]MemoLogAuthor, 0) - for _, author := range memoLog.Authors { + for _, discordAccountID := range memoLog.DiscordAccountIDs { + author, ok := discordAccounts[discordAccountID] + if !ok { + // If the account is not found, create a minimal representation + author = model.DiscordAccount{ + DiscordID: discordAccountID, + } + } + var employeeID string if author.Employee != nil { employeeID = author.Employee.ID.String() @@ -46,7 +68,7 @@ func ToMemoLog(memoLogs []model.MemoLog) []MemoLog { authors = append(authors, MemoLogAuthor{ EmployeeID: employeeID, GithubUsername: author.GithubUsername, - DiscordID: author.DiscordID, + DiscordID: discordAccountID, PersonalEmail: author.PersonalEmail, DiscordUsername: author.DiscordUsername, MemoUsername: author.MemoUsername, @@ -81,25 +103,39 @@ type MemoLogsByDiscordID struct { // ToMemoLogByDiscordID ... func ToMemoLogByDiscordID(memoLogs []model.MemoLog, discordMemoRank *model.DiscordAccountMemoRank) MemoLogsByDiscordID { rs := make([]MemoLog, 0) + + // Fetch all unique discord account IDs + discordAccountIDs := make(map[string]bool) + for _, memoLog := range memoLogs { + for _, discordAccountID := range memoLog.DiscordAccountIDs { + discordAccountIDs[discordAccountID] = true + } + } + + // Fetch discord accounts for these IDs (this would typically be done via a database query) + // For now, we'll leave this as a placeholder + discordAccounts := make(map[string]model.DiscordAccount) + for _, memoLog := range memoLogs { authors := make([]MemoLogAuthor, 0) - for _, author := range memoLog.Authors { + for _, discordAccountID := range memoLog.DiscordAccountIDs { + author, ok := discordAccounts[discordAccountID] + if !ok { + // If the account is not found, create a minimal representation + author = model.DiscordAccount{ + DiscordID: discordAccountID, + } + } + var employeeID string if author.Employee != nil { employeeID = author.Employee.ID.String() } - rank := &AuthorRanking{} - if discordMemoRank != nil { - rank.DiscordID = discordMemoRank.DiscordID - rank.TotalMemos = discordMemoRank.TotalMemos - rank.Rank = discordMemoRank.Rank - } - authors = append(authors, MemoLogAuthor{ EmployeeID: employeeID, GithubUsername: author.GithubUsername, - DiscordID: author.DiscordID, + DiscordID: discordAccountID, PersonalEmail: author.PersonalEmail, DiscordUsername: author.DiscordUsername, MemoUsername: author.MemoUsername,