Changes to pagination and fixing archive messages in progress

This commit is contained in:
2025-10-31 22:55:04 +00:00
parent 8529116ad2
commit 9dc01f925a
9 changed files with 219 additions and 41 deletions

View File

@@ -18,4 +18,9 @@ type MessageService interface {
ListArchived(userID int64) ([]Message, error) ListArchived(userID int64) ([]Message, error)
GetByID(userID, id int64) (*Message, error) GetByID(userID, id int64) (*Message, error)
Create(userID int64, in CreateMessageInput) (int64, 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
} }

View File

@@ -5,10 +5,13 @@
package accountMessageHandler package accountMessageHandler
import ( import (
"bytes"
"net/http" "net/http"
"strconv"
templateHandlers "synlotto-website/internal/handlers/template" templateHandlers "synlotto-website/internal/handlers/template"
templateHelpers "synlotto-website/internal/helpers/template" templateHelpers "synlotto-website/internal/helpers/template"
errors "synlotto-website/internal/http/error"
"synlotto-website/internal/logging" "synlotto-website/internal/logging"
"synlotto-website/internal/platform/bootstrap" "synlotto-website/internal/platform/bootstrap"
@@ -22,30 +25,118 @@ import (
func (h *AccountMessageHandlers) ArchivedList(c *gin.Context) { func (h *AccountMessageHandlers) ArchivedList(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App) app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager sm := app.SessionManager
userID := mustUserID(c) 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 { if err != nil {
logging.Info("❌ list archived error: %v", err) logging.Info("❌ list archived error: %v", err)
c.String(http.StatusInternalServerError, "Failed to load archived messages") c.String(http.StatusInternalServerError, "Failed to load archived messages")
return 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) data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request)
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data) ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data)
if f := sm.PopString(c.Request.Context(), "flash"); f != "" { if f := sm.PopString(c.Request.Context(), "flash"); f != "" {
ctx["Flash"] = f ctx["Flash"] = f
} }
ctx["CSRFToken"] = nosurf.Token(c.Request) ctx["CSRFToken"] = nosurf.Token(c.Request)
ctx["Title"] = "Archived Messages" ctx["Title"] = "Archived Messages"
ctx["Messages"] = msgs 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") tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/messages/archived.html")
c.Status(http.StatusOK) var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil { if err := tmpl.ExecuteTemplate(&buf, "layout", ctx); err != nil {
logging.Info("❌ Template render error: %v", err) logging.Info("❌ Template render error: %v", err)
c.String(http.StatusInternalServerError, "Error rendering archived messages") 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")
} }

View File

@@ -2,7 +2,6 @@
// Path: /internal/handlers/account/messages // Path: /internal/handlers/account/messages
// File: read.go // File: read.go
// ToDo: Remove SQL // ToDo: Remove SQL
// add LIMIT/OFFSET in service
package accountMessageHandler package accountMessageHandler
@@ -13,8 +12,8 @@ import (
templateHandlers "synlotto-website/internal/handlers/template" templateHandlers "synlotto-website/internal/handlers/template"
templateHelpers "synlotto-website/internal/helpers/template" templateHelpers "synlotto-website/internal/helpers/template"
errors "synlotto-website/internal/http/error" errors "synlotto-website/internal/http/error"
"synlotto-website/internal/logging" "synlotto-website/internal/logging"
"synlotto-website/internal/platform/bootstrap" "synlotto-website/internal/platform/bootstrap"
@@ -27,31 +26,36 @@ import (
func (h *AccountMessageHandlers) List(c *gin.Context) { func (h *AccountMessageHandlers) List(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App) app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager sm := app.SessionManager
userID := mustUserID(c) userID := mustUserID(c)
// 1) Parse page param (default 1) // --- Pagination ---
page := 1 page := 1
if ps := c.Query("page"); ps != "" { if ps := c.Query("page"); ps != "" {
if n, err := strconv.Atoi(ps); err == nil && n > 0 { if n, err := strconv.Atoi(ps); err == nil && n > 0 {
page = n page = n
} }
} }
pageSize := 20 pageSize := 20
// 2) Count total for this user (so TotalPages is accurate) totalPages, totalCount, err := templateHelpers.GetTotalPages(
totalPages, totalCount := templateHelpers.GetTotalPages( c.Request.Context(),
app.DB, app.DB,
"user_messages", "user_messages",
"WHERE recipientId = ? AND is_archived = FALSE", "recipientId = ? AND is_archived = FALSE",
[]interface{}{userID}, []any{userID},
pageSize, pageSize,
) )
if err != nil {
logging.Info("❌ count inbox error: %v", err)
c.String(http.StatusInternalServerError, "Failed to load messages")
return
}
if page > totalPages { if page > totalPages {
page = totalPages page = totalPages
} }
// 3) Fetch (existing service returns all inbox items) // --- Data ---
msgsAll, err := h.Svc.ListInbox(userID) msgsAll, err := h.Svc.ListInbox(userID)
if err != nil { if err != nil {
logging.Info("❌ list inbox error: %v", err) logging.Info("❌ list inbox error: %v", err)
@@ -59,7 +63,7 @@ func (h *AccountMessageHandlers) List(c *gin.Context) {
return 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 start := (page - 1) * pageSize
if start > len(msgsAll) { if start > len(msgsAll) {
start = len(msgsAll) start = len(msgsAll)
@@ -70,7 +74,7 @@ func (h *AccountMessageHandlers) List(c *gin.Context) {
} }
msgs := msgsAll[start:end] msgs := msgsAll[start:end]
// 5) Build context with paging + CSRF + session-driven user meta // --- Template context ---
data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request) data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request)
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data) ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data)
@@ -85,7 +89,7 @@ func (h *AccountMessageHandlers) List(c *gin.Context) {
ctx["TotalCount"] = totalCount ctx["TotalCount"] = totalCount
ctx["PageRange"] = templateHelpers.MakePageRange(1, totalPages) 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") tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/messages/index.html")
var buf bytes.Buffer 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()) c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
} }
// GET /account/messages/read?id=123
// Renders: web/templates/account/messages/read.html // Renders: web/templates/account/messages/read.html
func (h *AccountMessageHandlers) ReadGet(c *gin.Context) { func (h *AccountMessageHandlers) ReadGet(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App) app := c.MustGet("app").(*bootstrap.App)

View File

@@ -9,9 +9,8 @@ import (
"sort" "sort"
"strconv" "strconv"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/helpers" "synlotto-website/internal/helpers"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/http/middleware" "synlotto-website/internal/http/middleware"
"synlotto-website/internal/models" "synlotto-website/internal/models"
) )
@@ -20,7 +19,6 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
ip, _, _ := net.SplitHostPort(r.RemoteAddr) ip, _, _ := net.SplitHostPort(r.RemoteAddr)
limiter := middleware.GetVisitorLimiter(ip) limiter := middleware.GetVisitorLimiter(ip)
if !limiter.Allow() { if !limiter.Allow() {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests) http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return return
@@ -46,7 +44,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
doSearch := isValidDate(query) || isValidNumber(query) doSearch := isValidDate(query) || isValidNumber(query)
whereClause := "WHERE 1=1" whereClause := "WHERE 1=1"
args := []interface{}{} args := []any{}
if doSearch { if doSearch {
whereClause += " AND (draw_date = ? OR id = ?)" whereClause += " AND (draw_date = ? OR id = ?)"
@@ -65,7 +63,21 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
args = append(args, ballSetFilter) 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 { if page < 1 || page > totalPages {
http.NotFound(w, r) http.NotFound(w, r)
return return
@@ -79,7 +91,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
LIMIT ? OFFSET ?` LIMIT ? OFFSET ?`
argsWithLimit := append(args, pageSize, offset) argsWithLimit := append(args, pageSize, offset)
rows, err := db.Query(querySQL, argsWithLimit...) rows, err := db.QueryContext(r.Context(), querySQL, argsWithLimit...)
if err != nil { if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError) http.Error(w, "Database error", http.StatusInternalServerError)
log.Println("❌ DB error:", err) log.Println("❌ DB error:", err)
@@ -113,7 +125,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
noResultsMsg = "No results found for \"" + query + "\"" 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{}{ err = tmpl.ExecuteTemplate(w, "layout", map[string]interface{}{
"Results": results, "Results": results,

View File

@@ -1,27 +1,72 @@
// internal/helpers/pagination/pagination.go (move out of template/*)
package templateHelper package templateHelper
import ( import (
"context"
"database/sql" "database/sql"
"fmt"
"time"
) )
// ToDo: Sql shouldnt be here. // Whitelist
func GetTotalPages(db *sql.DB, tableName, whereClause string, args []interface{}, pageSize int) (totalPages, totalCount int) { var allowedTables = map[string]struct{}{
query := "SELECT COUNT(*) FROM " + tableName + " " + whereClause "user_messages": {},
row := db.QueryRow(query, args...) "user_notifications": {},
if err := row.Scan(&totalCount); err != nil { "results_thunderball": {},
return 1, 0 }
// 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 { if totalPages < 1 {
totalPages = 1 totalPages = 1
} }
return totalPages, totalCount return totalPages, totalCount, nil
} }
func MakePageRange(current, total int) []int { 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++ { for i := 1; i <= total; i++ {
pages = append(pages, i) pages = append(pages, i)
} }
return pages 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
}

View File

@@ -66,11 +66,12 @@ func RegisterAccountRoutes(app *bootstrap.App) {
messages.Use(middleware.AuthMiddleware(), middleware.RequireAuth()) messages.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
{ {
messages.GET("/", msgH.List) messages.GET("/", msgH.List)
messages.GET("/read", msgH.ReadGet)
messages.GET("/send", msgH.SendGet) messages.GET("/send", msgH.SendGet)
messages.POST("/send", msgH.SendPost) messages.POST("/send", msgH.SendPost)
messages.GET("/archived", msgH.ArchivedList) // renders archived.html messages.GET("/archive", msgH.ArchivedList) // view archived messages
messages.GET("/read", msgH.ReadGet) messages.POST("/archive", msgH.ArchivePost) // archive a message
messages.POST("/restore", msgH.RestorePost)
} }
// Notifications (auth-required) // Notifications (auth-required)

View File

@@ -42,7 +42,6 @@ func New(db *sql.DB, opts ...func(*Service)) *Service {
// Ensure *Service satisfies the domain interface. // Ensure *Service satisfies the domain interface.
var _ domain.MessageService = (*Service)(nil) 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) { func (s *Service) ListInbox(userID int64) ([]domain.Message, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout) ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel() defer cancel()
@@ -164,7 +163,6 @@ func (s *Service) Create(senderID int64, in domain.CreateMessageInput) (int64, e
return id, nil return id, nil
} }
// compactSQL removes newlines/extra spaces for cleaner logs
func compactSQL(s string) string { func compactSQL(s string) string {
out := make([]rune, 0, len(s)) out := make([]rune, 0, len(s))
space := false space := false
@@ -200,7 +198,28 @@ func (s *Service) bind(q string) string {
return string(out) 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 { func intToStr(n int) string {
if n == 0 { if n == 0 {
return "0" return "0"

View File

@@ -12,7 +12,7 @@
<small class="text-muted"> <small class="text-muted">
Archived: Archived:
{{ if .ArchivedAt.Valid }} {{ if .ArchivedAt.Valid }}
{{ .ArchivedAt.Time.Format "02 Jan 2006 15:04" }} {{ .Format "02 Jan 2006 15:04" }}
{{ else }} {{ else }}
{{ end }} {{ end }}

View File

@@ -46,7 +46,7 @@
<div class="mt-3"> <div class="mt-3">
<a href="/account/messages/send" class="btn btn-primary">Compose Message</a> <a href="/account/messages/send" class="btn btn-primary">Compose Message</a>
<a href="/account/messages/archived" class="btn btn-outline-secondary ms-2">View Archived</a> <a href="/account/messages/archive" class="btn btn-outline-secondary ms-2">View Archived</a>
</div> </div>
</div> </div>
{{ end }} {{ end }}