diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css index 8386ee3..0c802d7 100644 --- a/backend/internal/adminconsole/assets/console.css +++ b/backend/internal/adminconsole/assets/console.css @@ -74,6 +74,7 @@ h1 { font-size: 1.4rem; margin: 0 0 0.4rem; } .subnav a.active { color: var(--ink); } .form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.4rem; } +.form .export { margin-left: auto; align-self: center; color: var(--accent); white-space: nowrap; } .form.col { flex-direction: column; align-items: stretch; max-width: 540px; } .form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); } .form input, .form select, .form textarea { diff --git a/backend/internal/adminconsole/templates/pages/messages.gohtml b/backend/internal/adminconsole/templates/pages/messages.gohtml index 7c985f5..917079b 100644 --- a/backend/internal/adminconsole/templates/pages/messages.gohtml +++ b/backend/internal/adminconsole/templates/pages/messages.gohtml @@ -7,6 +7,7 @@ +Export CSV ↓ {{if or .GameID .UserID}}
Filtered{{if .GameID}} to game {{.GameID}}{{end}}{{if .UserID}} from sender{{end}} · clear
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index c1a0b3f..b84decd 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -2,6 +2,7 @@ package server import ( "bytes" + "encoding/csv" "fmt" "net/http" "net/url" @@ -53,6 +54,7 @@ func (s *Server) registerConsole(router *gin.Engine) { gm.GET("/complaints/:id", s.consoleComplaintDetail) gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint) gm.GET("/messages", s.consoleMessages) + gm.GET("/messages.csv", s.consoleMessagesCSV) gm.GET("/dictionary", s.consoleDictionary) gm.POST("/dictionary/reload", s.consoleReloadDictionary) gm.POST("/dictionary/changes/apply", s.consoleApplyChanges) @@ -186,6 +188,38 @@ func (s *Server) consoleMessages(c *gin.Context) { s.renderConsole(c, "messages", "messages", "Messages", view) } +// adminMessagesExportCap bounds the CSV export row count (the moderated chat volume is small). +const adminMessagesExportCap = 100000 + +// consoleMessagesCSV exports the whole filtered chat-message list (ignoring pagination) as a +// CSV download, for offline moderation review. +func (s *Server) consoleMessagesCSV(c *gin.Context) { + ctx := c.Request.Context() + gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game"))) + userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user"))) + filter := social.AdminMessageFilter{ + GameID: gameID, + SenderID: userID, + NameMask: c.Query("name"), + ExtMask: c.Query("ext"), + } + items, err := s.social.AdminListMessages(ctx, filter, adminMessagesExportCap, 0) + if err != nil { + s.consoleError(c, err) + return + } + c.Header("Content-Type", "text/csv; charset=utf-8") + c.Header("Content-Disposition", `attachment; filename="messages.csv"`) + w := csv.NewWriter(c.Writer) + _ = w.Write([]string{"time", "source", "sender_id", "sender", "ip", "message", "game_id"}) + for _, m := range items { + _ = w.Write([]string{ + fmtTime(m.CreatedAt), m.Source, m.SenderID.String(), m.SenderName, m.SenderIP, m.Body, m.GameID.String(), + }) + } + w.Flush() +} + // consoleUserDetail renders one account with its stats, identities and games. func (s *Server) consoleUserDetail(c *gin.Context) { ctx := c.Request.Context()