From 9dc01f925a0be6893bebca2fc7d50eb1b8ac3a2f Mon Sep 17 00:00:00 2001 From: H3ALY Date: Fri, 31 Oct 2025 22:55:04 +0000 Subject: [PATCH] Changes to pagination and fixing archive messages in progress --- internal/domain/messages/domain.go | 5 + internal/handlers/account/messages/archive.go | 101 +++++++++++++++++- internal/handlers/account/messages/read.go | 29 ++--- internal/handlers/results.go | 26 +++-- internal/helpers/template/pagination.go | 63 +++++++++-- internal/http/routes/accountroutes.go | 7 +- .../platform/services/messages/service.go | 25 ++++- web/templates/account/messages/archived.html | 2 +- web/templates/account/messages/index.html | 2 +- 9 files changed, 219 insertions(+), 41 deletions(-) diff --git a/internal/domain/messages/domain.go b/internal/domain/messages/domain.go index 582fabd..446bb0e 100644 --- a/internal/domain/messages/domain.go +++ b/internal/domain/messages/domain.go @@ -18,4 +18,9 @@ type MessageService interface { ListArchived(userID int64) ([]Message, error) GetByID(userID, id int64) (*Message, error) Create(userID int64, in CreateMessageInput) (int64, error) + Archive(userID, id int64) error + //Restore() + //ToDo: implement + //Unarchive(userID, id int64) error + //MarkRead(userID, id int64) error } diff --git a/internal/handlers/account/messages/archive.go b/internal/handlers/account/messages/archive.go index b1d507e..6f58800 100644 --- a/internal/handlers/account/messages/archive.go +++ b/internal/handlers/account/messages/archive.go @@ -5,10 +5,13 @@ package accountMessageHandler import ( + "bytes" "net/http" + "strconv" templateHandlers "synlotto-website/internal/handlers/template" templateHelpers "synlotto-website/internal/helpers/template" + errors "synlotto-website/internal/http/error" "synlotto-website/internal/logging" "synlotto-website/internal/platform/bootstrap" @@ -22,30 +25,118 @@ import ( func (h *AccountMessageHandlers) ArchivedList(c *gin.Context) { app := c.MustGet("app").(*bootstrap.App) sm := app.SessionManager - userID := mustUserID(c) - msgs, err := h.Svc.ListArchived(userID) + + // pagination + page := 1 + if ps := c.Query("page"); ps != "" { + if n, err := strconv.Atoi(ps); err == nil && n > 0 { + page = n + } + } + pageSize := 20 + + totalPages, totalCount, err := templateHelpers.GetTotalPages( + c.Request.Context(), + app.DB, + "user_messages", + "recipientId = ? AND is_archived = TRUE", + []any{userID}, + pageSize, + ) + if err != nil { + logging.Info("❌ count archived error: %v", err) + c.String(http.StatusInternalServerError, "Failed to load archived messages") + return + } + if page > totalPages { + page = totalPages + } + + msgsAll, err := h.Svc.ListArchived(userID) if err != nil { logging.Info("❌ list archived error: %v", err) c.String(http.StatusInternalServerError, "Failed to load archived messages") return } + // slice in-memory for now + start := (page - 1) * pageSize + if start > len(msgsAll) { + start = len(msgsAll) + } + end := start + pageSize + if end > len(msgsAll) { + end = len(msgsAll) + } + msgs := msgsAll[start:end] + data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request) ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data) - if f := sm.PopString(c.Request.Context(), "flash"); f != "" { ctx["Flash"] = f } ctx["CSRFToken"] = nosurf.Token(c.Request) ctx["Title"] = "Archived Messages" ctx["Messages"] = msgs + ctx["CurrentPage"] = page + ctx["TotalPages"] = totalPages + ctx["TotalCount"] = totalCount + ctx["PageRange"] = templateHelpers.MakePageRange(1, totalPages) tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/messages/archived.html") - c.Status(http.StatusOK) - if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil { + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, "layout", ctx); err != nil { logging.Info("❌ Template render error: %v", err) c.String(http.StatusInternalServerError, "Error rendering archived messages") + return } + c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes()) +} + +// POST /account/messages/archive +func (h *AccountMessageHandlers) ArchivePost(c *gin.Context) { + app := c.MustGet("app").(*bootstrap.App) + sm := app.SessionManager + userID := mustUserID(c) + + idStr := c.PostForm("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil || id <= 0 { + errors.RenderStatus(c, sm, http.StatusBadRequest) + return + } + + if err := h.Svc.Archive(userID, id); err != nil { + logging.Info("❌ Archive error: %v", err) + sm.Put(c.Request.Context(), "flash", "Could not archive message.") + c.Redirect(http.StatusSeeOther, "/account/messages") + return + } + + sm.Put(c.Request.Context(), "flash", "Message archived.") + c.Redirect(http.StatusSeeOther, "/account/messages") +} + +// POST /account/messages/restore +func (h *AccountMessageHandlers) RestorePost(c *gin.Context) { + app := c.MustGet("app").(*bootstrap.App) + sm := app.SessionManager + //userID := mustUserID(c) + + idStr := c.PostForm("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil || id <= 0 { + errors.RenderStatus(c, sm, http.StatusBadRequest) + return + } + // + // if err := h.Svc.Unarchive(userID, id); err != nil { + // logging.Info("❌ Restore error: %v", err) + // sm.Put(c.Request.Context(), "flash", "Could not restore message.") + // } else { + // sm.Put(c.Request.Context(), "flash", "Message restored.") + // } + c.Redirect(http.StatusSeeOther, "/account/messages/archived") } diff --git a/internal/handlers/account/messages/read.go b/internal/handlers/account/messages/read.go index 537812f..6c9ef0c 100644 --- a/internal/handlers/account/messages/read.go +++ b/internal/handlers/account/messages/read.go @@ -2,7 +2,6 @@ // Path: /internal/handlers/account/messages // File: read.go // ToDo: Remove SQL -// add LIMIT/OFFSET in service package accountMessageHandler @@ -13,8 +12,8 @@ import ( templateHandlers "synlotto-website/internal/handlers/template" templateHelpers "synlotto-website/internal/helpers/template" - errors "synlotto-website/internal/http/error" + "synlotto-website/internal/logging" "synlotto-website/internal/platform/bootstrap" @@ -27,31 +26,36 @@ import ( func (h *AccountMessageHandlers) List(c *gin.Context) { app := c.MustGet("app").(*bootstrap.App) sm := app.SessionManager - userID := mustUserID(c) - // 1) Parse page param (default 1) + // --- Pagination --- page := 1 if ps := c.Query("page"); ps != "" { if n, err := strconv.Atoi(ps); err == nil && n > 0 { page = n } } + pageSize := 20 - // 2) Count total for this user (so TotalPages is accurate) - totalPages, totalCount := templateHelpers.GetTotalPages( + totalPages, totalCount, err := templateHelpers.GetTotalPages( + c.Request.Context(), app.DB, "user_messages", - "WHERE recipientId = ? AND is_archived = FALSE", - []interface{}{userID}, + "recipientId = ? AND is_archived = FALSE", + []any{userID}, pageSize, ) + if err != nil { + logging.Info("❌ count inbox error: %v", err) + c.String(http.StatusInternalServerError, "Failed to load messages") + return + } if page > totalPages { page = totalPages } - // 3) Fetch (existing service returns all inbox items) + // --- Data --- msgsAll, err := h.Svc.ListInbox(userID) if err != nil { logging.Info("❌ list inbox error: %v", err) @@ -59,7 +63,7 @@ func (h *AccountMessageHandlers) List(c *gin.Context) { return } - // 4) Slice in-memory for now (until you add LIMIT/OFFSET in service) + // Temporary in-memory slice (until LIMIT/OFFSET is added) start := (page - 1) * pageSize if start > len(msgsAll) { start = len(msgsAll) @@ -70,7 +74,7 @@ func (h *AccountMessageHandlers) List(c *gin.Context) { } msgs := msgsAll[start:end] - // 5) Build context with paging + CSRF + session-driven user meta + // --- Template context --- data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request) ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data) @@ -85,7 +89,7 @@ func (h *AccountMessageHandlers) List(c *gin.Context) { ctx["TotalCount"] = totalCount ctx["PageRange"] = templateHelpers.MakePageRange(1, totalPages) - // 6) Render (Buffer to avoid “headers already written” on error + // --- Render --- tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/messages/index.html") var buf bytes.Buffer @@ -97,6 +101,7 @@ func (h *AccountMessageHandlers) List(c *gin.Context) { c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes()) } +// GET /account/messages/read?id=123 // Renders: web/templates/account/messages/read.html func (h *AccountMessageHandlers) ReadGet(c *gin.Context) { app := c.MustGet("app").(*bootstrap.App) diff --git a/internal/handlers/results.go b/internal/handlers/results.go index 0cc6991..8661bb3 100644 --- a/internal/handlers/results.go +++ b/internal/handlers/results.go @@ -9,9 +9,8 @@ import ( "sort" "strconv" - templateHelpers "synlotto-website/internal/helpers/template" - "synlotto-website/internal/helpers" + templateHelpers "synlotto-website/internal/helpers/template" "synlotto-website/internal/http/middleware" "synlotto-website/internal/models" ) @@ -20,7 +19,6 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ip, _, _ := net.SplitHostPort(r.RemoteAddr) limiter := middleware.GetVisitorLimiter(ip) - if !limiter.Allow() { http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests) return @@ -46,7 +44,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc { doSearch := isValidDate(query) || isValidNumber(query) whereClause := "WHERE 1=1" - args := []interface{}{} + args := []any{} if doSearch { whereClause += " AND (draw_date = ? OR id = ?)" @@ -65,7 +63,21 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc { args = append(args, ballSetFilter) } - totalPages, totalResults := templateHelpers.GetTotalPages(db, "results_thunderball", whereClause, args, pageSize) + // ✅ FIX: Proper GetTotalPages call with context + correct table name + totalPages, totalResults, err := templateHelpers.GetTotalPages( + r.Context(), + db, + "results_thunderball", + whereClause, + args, + pageSize, + ) + if err != nil { + log.Println("❌ Pagination count error:", err) + http.Error(w, "Database error", http.StatusInternalServerError) + return + } + if page < 1 || page > totalPages { http.NotFound(w, r) return @@ -79,7 +91,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc { LIMIT ? OFFSET ?` argsWithLimit := append(args, pageSize, offset) - rows, err := db.Query(querySQL, argsWithLimit...) + rows, err := db.QueryContext(r.Context(), querySQL, argsWithLimit...) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) log.Println("❌ DB error:", err) @@ -113,7 +125,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc { noResultsMsg = "No results found for \"" + query + "\"" } - tmpl := templateHelpers.LoadTemplateFiles("thunderball.html", "web/templates/results/thunderball.html") + tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/results/thunderball.html") err = tmpl.ExecuteTemplate(w, "layout", map[string]interface{}{ "Results": results, diff --git a/internal/helpers/template/pagination.go b/internal/helpers/template/pagination.go index fffd15a..5184537 100644 --- a/internal/helpers/template/pagination.go +++ b/internal/helpers/template/pagination.go @@ -1,27 +1,72 @@ +// internal/helpers/pagination/pagination.go (move out of template/*) package templateHelper import ( + "context" "database/sql" + "fmt" + "time" ) -// ToDo: Sql shouldnt be here. -func GetTotalPages(db *sql.DB, tableName, whereClause string, args []interface{}, pageSize int) (totalPages, totalCount int) { - query := "SELECT COUNT(*) FROM " + tableName + " " + whereClause - row := db.QueryRow(query, args...) - if err := row.Scan(&totalCount); err != nil { - return 1, 0 +// Whitelist +var allowedTables = map[string]struct{}{ + "user_messages": {}, + "user_notifications": {}, + "results_thunderball": {}, +} + +// GetTotalPages counts rows and returns (totalPages, totalCount). +func GetTotalPages(ctx context.Context, db *sql.DB, table, whereClause string, args []any, pageSize int) (int, int64, error) { + if pageSize <= 0 { + pageSize = 20 } - totalPages = (totalCount + pageSize - 1) / pageSize + if _, ok := allowedTables[table]; !ok { + return 1, 0, fmt.Errorf("table not allowed: %s", table) + } + + q := fmt.Sprintf("SELECT COUNT(*) FROM %s", table) + if whereClause != "" { + q += " WHERE " + whereClause + } + + var totalCount int64 + cctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + + if err := db.QueryRowContext(cctx, q, args...).Scan(&totalCount); err != nil { + return 1, 0, fmt.Errorf("count %s: %w", table, err) + } + totalPages := int((totalCount + int64(pageSize) - 1) / int64(pageSize)) if totalPages < 1 { totalPages = 1 } - return totalPages, totalCount + return totalPages, totalCount, nil } func MakePageRange(current, total int) []int { - var pages []int + if total < 1 { + return []int{1} + } + + pages := make([]int, 0, total) for i := 1; i <= total; i++ { pages = append(pages, i) } return pages } + +func ClampPage(p, total int) int { + if p < 1 { + return 1 + } + if p > total { + return total + } + return p +} +func OffsetLimit(page, pageSize int) (int, int) { + if page < 1 { + page = 1 + } + return (page - 1) * pageSize, pageSize +} diff --git a/internal/http/routes/accountroutes.go b/internal/http/routes/accountroutes.go index cde0892..feb457d 100644 --- a/internal/http/routes/accountroutes.go +++ b/internal/http/routes/accountroutes.go @@ -66,11 +66,12 @@ func RegisterAccountRoutes(app *bootstrap.App) { messages.Use(middleware.AuthMiddleware(), middleware.RequireAuth()) { messages.GET("/", msgH.List) + messages.GET("/read", msgH.ReadGet) messages.GET("/send", msgH.SendGet) messages.POST("/send", msgH.SendPost) - messages.GET("/archived", msgH.ArchivedList) // renders archived.html - messages.GET("/read", msgH.ReadGet) - + messages.GET("/archive", msgH.ArchivedList) // view archived messages + messages.POST("/archive", msgH.ArchivePost) // archive a message + messages.POST("/restore", msgH.RestorePost) } // Notifications (auth-required) diff --git a/internal/platform/services/messages/service.go b/internal/platform/services/messages/service.go index 2a6fee9..ab470ac 100644 --- a/internal/platform/services/messages/service.go +++ b/internal/platform/services/messages/service.go @@ -42,7 +42,6 @@ func New(db *sql.DB, opts ...func(*Service)) *Service { // Ensure *Service satisfies the domain interface. var _ domain.MessageService = (*Service)(nil) -// ToDo: Needs a userId on table or rename the recipiant id.. but then again dont want to expose userids to users for sending. func (s *Service) ListInbox(userID int64) ([]domain.Message, error) { ctx, cancel := context.WithTimeout(context.Background(), s.Timeout) defer cancel() @@ -164,7 +163,6 @@ func (s *Service) Create(senderID int64, in domain.CreateMessageInput) (int64, e return id, nil } -// compactSQL removes newlines/extra spaces for cleaner logs func compactSQL(s string) string { out := make([]rune, 0, len(s)) space := false @@ -200,7 +198,28 @@ func (s *Service) bind(q string) string { return string(out) } -// ToDo: helper dont think it should be here. +func (s *Service) Archive(userID, id int64) error { + ctx, cancel := context.WithTimeout(context.Background(), s.Timeout) + defer cancel() + + q := ` + UPDATE user_messages + SET is_archived = 1, archived_at = CURRENT_TIMESTAMP + WHERE id = ? AND recipientId = ? + ` + q = s.bind(q) + + res, err := s.DB.ExecContext(ctx, q, id, userID) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return sql.ErrNoRows + } + return nil +} + func intToStr(n int) string { if n == 0 { return "0" diff --git a/web/templates/account/messages/archived.html b/web/templates/account/messages/archived.html index 2daa685..5160a7e 100644 --- a/web/templates/account/messages/archived.html +++ b/web/templates/account/messages/archived.html @@ -12,7 +12,7 @@ Archived: {{ if .ArchivedAt.Valid }} - {{ .ArchivedAt.Time.Format "02 Jan 2006 15:04" }} + {{ .Format "02 Jan 2006 15:04" }} {{ else }} — {{ end }} diff --git a/web/templates/account/messages/index.html b/web/templates/account/messages/index.html index 6b47c18..5217166 100644 --- a/web/templates/account/messages/index.html +++ b/web/templates/account/messages/index.html @@ -46,7 +46,7 @@ {{ end }}