Continued work around getting messages and notifications cleaned up since moving to MySQL and changing to Gin, SCS, NoSurf.

This commit is contained in:
2025-10-29 15:22:05 +00:00
parent 0b2883a494
commit b41e92629b
8 changed files with 402 additions and 109 deletions

View File

@@ -0,0 +1,26 @@
// Package accountMessageHandler
// Path: /internal/handlers/account/messages
// File: archive.go
package accountMessageHandler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// GET /account/messages/archived
// Renders: web/templates/account/messages/archived.html
func (h *AccountMessageHandlers) ArchivedList(c *gin.Context) {
userID := mustUserID(c)
msgs, err := h.Svc.ListArchived(userID)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to load archived messages"})
return
}
c.HTML(http.StatusOK, "account/messages/archived.html", gin.H{
"title": "Archived Messages",
"messages": msgs,
})
}

View File

@@ -1,98 +1,14 @@
// Package accountMessageHandler
// Path: /internal/handlers/account/messages
// File: list.go
// ToDo: helpers for reading getting messages shouldn't really be here. ---
package accountMessageHandler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// GET /account/messages
// Renders: web/templates/account/messages/index.html
func (h *AccountMessageHandlers) List(c *gin.Context) {
userID := mustUserID(c) // replace with your auth/user extraction
msgs, err := h.Svc.ListInbox(userID)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to load messages"})
return
}
c.HTML(http.StatusOK, "account/messages/index.html", gin.H{
"title": "Messages",
"messages": msgs,
})
}
// GET /account/messages/add
// Renders: web/templates/account/messages/send.html
func (h *AccountMessageHandlers) AddGet(c *gin.Context) {
c.HTML(http.StatusOK, "account/messages/send.html", gin.H{
"title": "Send Message",
})
}
// POST /account/messages/add
func (h *AccountMessageHandlers) AddPost(c *gin.Context) {
userID := mustUserID(c)
var in CreateMessageInput
if err := c.ShouldBind(&in); err != nil {
// Re-render form with validation errors
c.HTML(http.StatusBadRequest, "account/messages/send.html", gin.H{
"title": "Send Message",
"error": "Please correct the errors below.",
"form": in,
})
return
}
if _, err := h.Svc.Create(userID, in); err != nil {
c.HTML(http.StatusInternalServerError, "account/messages/send.html", gin.H{
"title": "Send Message",
"error": "Could not send message.",
"form": in,
})
return
}
// Redirect back to inbox
c.Redirect(http.StatusSeeOther, "/account/messages")
}
// --- Optional extras since you have read.html and archived.html ---
// GET /account/messages/:id
// Renders: web/templates/account/messages/read.html
func (h *AccountMessageHandlers) ReadGet(c *gin.Context) {
userID := mustUserID(c)
id, err := parseIDParam(c, "id")
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
msg, err := h.Svc.GetByID(userID, id)
if err != nil || msg == nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
c.HTML(http.StatusOK, "account/messages/read.html", gin.H{
"title": msg.Subject,
"message": msg,
})
}
// GET /account/messages/archived
// Renders: web/templates/account/messages/archived.html
func (h *AccountMessageHandlers) ArchivedList(c *gin.Context) {
userID := mustUserID(c)
msgs, err := h.Svc.ListArchived(userID)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to load archived messages"})
return
}
c.HTML(http.StatusOK, "account/messages/archived.html", gin.H{
"title": "Archived Messages",
"messages": msgs,
})
}
// --- helpers ---
func mustUserID(c *gin.Context) int64 {
// Pull from your auth middleware/session. Panic-unsafe alternative:
if v, ok := c.Get("userID"); ok {

View File

@@ -0,0 +1,46 @@
// Package accountMessageHandler
// Path: /internal/handlers/account/messages
// File: read.go
package accountMessageHandler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// GET /account/messages
// Renders: web/templates/account/messages/index.html
func (h *AccountMessageHandlers) List(c *gin.Context) {
userID := mustUserID(c)
msgs, err := h.Svc.ListInbox(userID)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to load messages"})
return
}
c.HTML(http.StatusOK, "account/messages/index.html", gin.H{
"title": "Messages",
"messages": msgs,
})
}
// GET /account/messages/:id
// Renders: web/templates/account/messages/read.html
func (h *AccountMessageHandlers) ReadGet(c *gin.Context) {
userID := mustUserID(c)
id, err := parseIDParam(c, "id")
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
msg, err := h.Svc.GetByID(userID, id)
if err != nil || msg == nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
c.HTML(http.StatusOK, "account/messages/read.html", gin.H{
"title": msg.Subject,
"message": msg,
})
}

View File

@@ -0,0 +1,44 @@
// Package accountMessageHandler
// Path: /internal/handlers/account/messages
// File: send.go
package accountMessageHandler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// GET /account/messages/add
// Renders: web/templates/account/messages/send.html
func (h *AccountMessageHandlers) AddGet(c *gin.Context) {
c.HTML(http.StatusOK, "account/messages/send.html", gin.H{
"title": "Send Message",
})
}
// POST /account/messages/add
func (h *AccountMessageHandlers) AddPost(c *gin.Context) {
userID := mustUserID(c)
var in CreateMessageInput
if err := c.ShouldBind(&in); err != nil {
// Re-render form with validation errors
c.HTML(http.StatusBadRequest, "account/messages/send.html", gin.H{
"title": "Send Message",
"error": "Please correct the errors below.",
"form": in,
})
return
}
if _, err := h.Svc.Create(userID, in); err != nil {
c.HTML(http.StatusInternalServerError, "account/messages/send.html", gin.H{
"title": "Send Message",
"error": "Could not send message.",
"form": in,
})
return
}
// Redirect back to inbox
c.Redirect(http.StatusSeeOther, "/account/messages")
}

View File

@@ -1,3 +1,7 @@
// Package accountMessageHandler
// Path: /internal/handlers/account/messages
// File: types.go
package accountMessageHandler
import "time"

View File

@@ -21,10 +21,10 @@
package routes
import (
accountHandlers "synlotto-website/internal/handlers/account"
accountMessageHandlers "synlotto-website/internal/handlers/account/messages"
accountNotificationHandlers "synlotto-website/internal/handlers/account/notifications"
accountTicketHandlers "synlotto-website/internal/handlers/account/tickets"
accountHandler "synlotto-website/internal/handlers/account"
accoutMessageHandler "synlotto-website/internal/handlers/account/messages"
accountNotificationHandler "synlotto-website/internal/handlers/account/notifications"
accountTicketHandler "synlotto-website/internal/handlers/account/tickets"
"synlotto-website/internal/http/middleware"
"synlotto-website/internal/platform/bootstrap"
@@ -37,45 +37,45 @@ func RegisterAccountRoutes(app *bootstrap.App) {
acc := r.Group("/account")
acc.Use(middleware.PublicOnly())
{
acc.GET("/login", accountHandlers.LoginGet)
acc.POST("/login", accountHandlers.LoginPost)
acc.GET("/signup", accountHandlers.SignupGet)
acc.POST("/signup", accountHandlers.SignupPost)
acc.GET("/login", accountHandler.LoginGet)
acc.POST("/login", accountHandler.LoginPost)
acc.GET("/signup", accountHandler.SignupGet)
acc.POST("/signup", accountHandler.SignupPost)
}
// Auth-required account actions
accAuth := r.Group("/account")
accAuth.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
{
accAuth.POST("/logout", accountHandlers.Logout)
accAuth.GET("/logout", accountHandlers.Logout) // optional
accAuth.POST("/logout", accountHandler.Logout)
accAuth.GET("/logout", accountHandler.Logout) // optional
}
// Messages (auth-required)
messages := r.Group("/account/messages")
messages.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
{
messages.GET("/", accountMessageHandlers.List)
messages.GET("/add", accountMessageHandlers.AddGet)
messages.POST("/add", accountMessageHandlers.AddPost)
messages.GET("/archived", accountMessageHandlers.ArchivedList) // renders archived.html
messages.GET("/:id", accountMessageHandlers.ReadGet) // renders read.html
messages.GET("/", accoutMessageHandler.List)
messages.GET("/add", accoutMessageHandler.AddGet)
messages.POST("/add", accoutMessageHandler.AddPost)
messages.GET("/archived", accoutMessageHandler.ArchivedList) // renders archived.html
messages.GET("/:id", accoutMessageHandler.ReadGet) // renders read.html
}
// Notifications (auth-required)
notifications := r.Group("/account/notifications")
notifications.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
{
notifications.GET("/", accountNotificationHandlers.List)
notifications.GET("/:id", accountNotificationHandlers.ReadGet) // renders read.html
notifications.GET("/", accountNotificationHandler.List)
notifications.GET("/:id", accountNotificationHandler.ReadGet) // renders read.html
}
// Tickets (auth-required)
tickets := r.Group("/account/tickets")
tickets.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
{
tickets.GET("/", accountTicketHandlers.List) // GET /account/tickets
tickets.GET("/add", accountTicketHandlers.AddGet) // GET /account/tickets/add
tickets.POST("/add", accountTicketHandlers.AddPost) // POST /account/tickets/add
tickets.GET("/", accountTicketHandler.List) // GET /account/tickets
tickets.GET("/add", accountTicketHandler.AddGet) // GET /account/tickets/add
tickets.POST("/add", accountTicketHandler.AddPost) // POST /account/tickets/add
}
}

View File

@@ -0,0 +1,177 @@
// ToDo: not currently used and need to carve out sql
package messagesvc
import (
"context"
"database/sql"
"errors"
"time"
accountMessageHandler "synlotto-website/internal/handlers/account/messages"
)
// Service implements accountMessageHandler.MessageService.
type Service struct {
DB *sql.DB
Dialect string // "postgres", "mysql", "sqlite" (affects INSERT id retrieval)
Now func() time.Time
Timeout time.Duration
}
func New(db *sql.DB, opts ...func(*Service)) *Service {
s := &Service{
DB: db,
Dialect: "mysql", // sane default for LastInsertId (works for mysql/sqlite)
Now: time.Now,
Timeout: 5 * time.Second,
}
for _, opt := range opts {
opt(s)
}
return s
}
// WithDialect sets SQL dialect hints: "postgres" uses RETURNING id.
func WithDialect(d string) func(*Service) { return func(s *Service) { s.Dialect = d } }
// WithTimeout overrides per-call context timeout.
func WithTimeout(d time.Duration) func(*Service) { return func(s *Service) { s.Timeout = d } }
func (s *Service) ListInbox(userID int64) ([]accountMessageHandler.Message, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
const q = `
SELECT id, from_email, to_email, subject, body, is_read, is_archived, created_at
FROM messages
WHERE user_id = ? AND is_archived = FALSE
ORDER BY created_at DESC`
rows, err := s.DB.QueryContext(s.rebind(ctx), s.bind(q), userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []accountMessageHandler.Message
for rows.Next() {
var m accountMessageHandler.Message
if err := rows.Scan(&m.ID, &m.From, &m.To, &m.Subject, &m.Body, &m.IsRead, &m.IsArchived, &m.CreatedAt); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
func (s *Service) ListArchived(userID int64) ([]accountMessageHandler.Message, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
const q = `
SELECT id, from_email, to_email, subject, body, is_read, is_archived, created_at
FROM messages
WHERE user_id = ? AND is_archived = TRUE
ORDER BY created_at DESC`
rows, err := s.DB.QueryContext(s.rebind(ctx), s.bind(q), userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []accountMessageHandler.Message
for rows.Next() {
var m accountMessageHandler.Message
if err := rows.Scan(&m.ID, &m.From, &m.To, &m.Subject, &m.Body, &m.IsRead, &m.IsArchived, &m.CreatedAt); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
func (s *Service) GetByID(userID, id int64) (*accountMessageHandler.Message, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
const q = `
SELECT id, from_email, to_email, subject, body, is_read, is_archived, created_at
FROM messages
WHERE user_id = ? AND id = ?`
var m accountMessageHandler.Message
err := s.DB.QueryRowContext(s.rebind(ctx), s.bind(q), userID, id).
Scan(&m.ID, &m.From, &m.To, &m.Subject, &m.Body, &m.IsRead, &m.IsArchived, &m.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &m, nil
}
func (s *Service) Create(userID int64, in accountMessageHandler.CreateMessageInput) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
switch s.Dialect {
case "postgres":
const q = `
INSERT INTO messages (user_id, from_email, to_email, subject, body, is_read, is_archived, created_at)
VALUES ($1, $2, $3, $4, $5, FALSE, FALSE, NOW())
RETURNING id`
var id int64
if err := s.DB.QueryRowContext(ctx, q, userID, "", in.To, in.Subject, in.Body).Scan(&id); err != nil {
return 0, err
}
return id, nil
default: // mysql/sqlite
const q = `
INSERT INTO messages (user_id, from_email, to_email, subject, body, is_read, is_archived, created_at)
VALUES (?, ?, ?, ?, ?, FALSE, FALSE, CURRENT_TIMESTAMP)`
res, err := s.DB.ExecContext(s.rebind(ctx), q, userID, "", in.To, in.Subject, in.Body)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
}
// --- small helpers ---
// bind replaces ? with $1.. for Postgres if needed.
// We keep queries written with ? for brevity and adapt here.
func (s *Service) bind(q string) string {
if s.Dialect != "postgres" {
return q
}
// cheap replacer for positional params:
n := 0
out := make([]byte, 0, len(q)+10)
for i := 0; i < len(q); i++ {
if q[i] == '?' {
n++
out = append(out, '$')
out = append(out, []byte(intToStr(n))...)
continue
}
out = append(out, q[i])
}
return string(out)
}
func (s *Service) rebind(ctx context.Context) context.Context { return ctx }
// intToStr avoids fmt for tiny helper
func intToStr(n int) string {
if n == 0 {
return "0"
}
var b [12]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}

View File

@@ -0,0 +1,80 @@
// ToDo: carve out sql
package notifysvc
import (
"context"
"database/sql"
"errors"
"time"
accountNotificationHandler "synlotto-website/internal/handlers/account/notifications"
)
type Service struct {
DB *sql.DB
Now func() time.Time
Timeout time.Duration
}
func New(db *sql.DB, opts ...func(*Service)) *Service {
s := &Service{
DB: db,
Now: time.Now,
Timeout: 5 * time.Second,
}
for _, opt := range opts {
opt(s)
}
return s
}
func WithTimeout(d time.Duration) func(*Service) { return func(s *Service) { s.Timeout = d } }
// List returns newest-first notifications for a user.
func (s *Service) List(userID int64) ([]accountNotificationHandler.Notification, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
const q = `
SELECT id, title, body, is_read, created_at
FROM notifications
WHERE user_id = ?
ORDER BY created_at DESC`
rows, err := s.DB.QueryContext(ctx, q, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []accountNotificationHandler.Notification
for rows.Next() {
var n accountNotificationHandler.Notification
if err := rows.Scan(&n.ID, &n.Title, &n.Body, &n.IsRead, &n.CreatedAt); err != nil {
return nil, err
}
out = append(out, n)
}
return out, rows.Err()
}
func (s *Service) GetByID(userID, id int64) (*accountNotificationHandler.Notification, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
const q = `
SELECT id, title, body, is_read, created_at
FROM notifications
WHERE user_id = ? AND id = ?`
var n accountNotificationHandler.Notification
err := s.DB.QueryRowContext(ctx, q, userID, id).
Scan(&n.ID, &n.Title, &n.Body, &n.IsRead, &n.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &n, nil
}