Messages now sending/loading and populating on message dropdown

This commit is contained in:
2025-10-31 12:08:38 +00:00
parent 776ea53a66
commit 8529116ad2
13 changed files with 136 additions and 81 deletions

View File

@@ -18,7 +18,3 @@ func mustUserID(c *gin.Context) int64 {
// Fallback for stubs: // Fallback for stubs:
return 1 return 1
} }
type strconvNumErr struct{}
func (e *strconvNumErr) Error() string { return "invalid number" }

View File

@@ -18,14 +18,9 @@ import (
// using ONLY session data (no DB) so 404/500 pages don't crash and still // using ONLY session data (no DB) so 404/500 pages don't crash and still
// look "logged in" when a session exists. // look "logged in" when a session exists.
func RenderStatus(c *gin.Context, sessions *scs.SessionManager, status int) { func RenderStatus(c *gin.Context, sessions *scs.SessionManager, status int) {
// Synthesize minimal TemplateData from session only r := c.Request
var data models.TemplateData uid := int64(0)
if v := sessions.Get(r.Context(), sessionkeys.UserID); v != nil {
ctx := c.Request.Context()
// Read minimal user snapshot from session
var uid int64
if v := sessions.Get(ctx, sessionkeys.UserID); v != nil {
switch t := v.(type) { switch t := v.(type) {
case int64: case int64:
uid = t uid = t
@@ -33,22 +28,22 @@ func RenderStatus(c *gin.Context, sessions *scs.SessionManager, status int) {
uid = int64(t) uid = int64(t)
} }
} }
// --- build minimal template data from session
var data models.TemplateData
if uid > 0 { if uid > 0 {
// username and is_admin are optional but make navbar correct uname := ""
var uname string if v := sessions.Get(r.Context(), sessionkeys.Username); v != nil {
if v := sessions.Get(ctx, sessionkeys.Username); v != nil {
if s, ok := v.(string); ok { if s, ok := v.(string); ok {
uname = s uname = s
} }
} }
var isAdmin bool isAdmin := false
if v := sessions.Get(ctx, sessionkeys.IsAdmin); v != nil { if v := sessions.Get(r.Context(), sessionkeys.IsAdmin); v != nil {
if b, ok := v.(bool); ok { if b, ok := v.(bool); ok {
isAdmin = b isAdmin = b
} }
} }
// Build a lightweight user; avoids DB lookups in error paths
data.User = &models.User{ data.User = &models.User{
Id: uid, Id: uid,
Username: uname, Username: uname,
@@ -57,15 +52,11 @@ func RenderStatus(c *gin.Context, sessions *scs.SessionManager, status int) {
data.IsAdmin = isAdmin data.IsAdmin = isAdmin
} }
// Turn into the template context map (adds site meta, funcs, etc.) ctxMap := templateHelpers.TemplateContext(c.Writer, r, data)
ctxMap := templateHelpers.TemplateContext(c.Writer, c.Request, data) if f := sessions.PopString(r.Context(), sessionkeys.Flash); f != "" {
// Flash (SCS)
if f := sessions.PopString(ctx, sessionkeys.Flash); f != "" {
ctxMap["Flash"] = f ctxMap["Flash"] = f
} }
// Template paths (layout-first)
pagePath := fmt.Sprintf("web/templates/error/%d.html", status) pagePath := fmt.Sprintf("web/templates/error/%d.html", status)
if _, err := os.Stat(pagePath); err != nil { if _, err := os.Stat(pagePath); err != nil {
c.String(status, http.StatusText(status)) c.String(status, http.StatusText(status))
@@ -86,11 +77,13 @@ func RenderStatus(c *gin.Context, sessions *scs.SessionManager, status int) {
func NoRoute(sessions *scs.SessionManager) gin.HandlerFunc { func NoRoute(sessions *scs.SessionManager) gin.HandlerFunc {
return func(c *gin.Context) { RenderStatus(c, sessions, http.StatusNotFound) } return func(c *gin.Context) { RenderStatus(c, sessions, http.StatusNotFound) }
} }
func NoMethod(sessions *scs.SessionManager) gin.HandlerFunc { func NoMethod(sessions *scs.SessionManager) gin.HandlerFunc {
return func(c *gin.Context) { RenderStatus(c, sessions, http.StatusMethodNotAllowed) } return func(c *gin.Context) { RenderStatus(c, sessions, http.StatusMethodNotAllowed) }
} }
func Recovery(sessions *scs.SessionManager) gin.RecoveryFunc { func Recovery(sessions *scs.SessionManager) gin.RecoveryFunc {
return func(c *gin.Context, _ interface{}) { return func(c *gin.Context, rec interface{}) {
RenderStatus(c, sessions, http.StatusInternalServerError) RenderStatus(c, sessions, http.StatusInternalServerError)
} }
} }

View File

@@ -0,0 +1,26 @@
// internal/http/middleware/errorlog.go
package middleware
import (
"time"
"synlotto-website/internal/logging"
"github.com/gin-gonic/gin"
)
func ErrorLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
if len(c.Errors) == 0 {
return
}
for _, e := range c.Errors {
logging.Info("❌ %s %s -> %d in %v: %v",
c.Request.Method, c.FullPath(), c.Writer.Status(),
time.Since(start), e.Err)
}
}
}

View File

@@ -69,7 +69,8 @@ func RegisterAccountRoutes(app *bootstrap.App) {
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("/archived", msgH.ArchivedList) // renders archived.html
messages.GET("/:id", msgH.ReadGet) // renders read.html messages.GET("/read", msgH.ReadGet)
} }
// Notifications (auth-required) // Notifications (auth-required)

View File

@@ -151,7 +151,7 @@ func Load(configPath string) (*App, error) {
srv := &http.Server{ srv := &http.Server{
Addr: addr, Addr: addr,
Handler: handler, Handler: handler,
ReadHeaderTimeout: 10 * time.Second, // ToDo: consider moving to config ReadHeaderTimeout: cfg.HttpServer.ReadHeaderTimeout,
} }
app.Handler = handler app.Handler = handler

View File

@@ -30,6 +30,8 @@
package config package config
import "time"
// Config represents all runtime configuration for the application. // Config represents all runtime configuration for the application.
// Loaded from JSON and passed into bootstrap for wiring platform components. // Loaded from JSON and passed into bootstrap for wiring platform components.
type Config struct { type Config struct {
@@ -55,6 +57,7 @@ type Config struct {
Port int `json:"port"` Port int `json:"port"`
Address string `json:"address"` Address string `json:"address"`
ProductionMode bool `json:"productionMode"` // controls Secure cookie flag ProductionMode bool `json:"productionMode"` // controls Secure cookie flag
ReadHeaderTimeout time.Duration `json:"readHeaderTimeout"` // config in nanoseconds
} `json:"httpServer"` } `json:"httpServer"`
// Remote licensing API service configuration // Remote licensing API service configuration

View File

@@ -8,9 +8,14 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"fmt"
"time" "time"
"synlotto-website/internal/logging"
domain "synlotto-website/internal/domain/messages" domain "synlotto-website/internal/domain/messages"
"github.com/go-sql-driver/mysql"
) )
// Service implements domain.Service. // Service implements domain.Service.
@@ -43,8 +48,8 @@ func (s *Service) ListInbox(userID int64) ([]domain.Message, error) {
defer cancel() defer cancel()
q := ` q := `
SELECT id, senderId, recipientId, subject, message, is_read, is_archived, created_at SELECT id, senderId, recipientId, subject, body, is_read, is_archived, created_at
FROM users_messages FROM user_messages
WHERE recipientId = ? AND is_archived = FALSE WHERE recipientId = ? AND is_archived = FALSE
ORDER BY created_at DESC` ORDER BY created_at DESC`
q = s.bind(q) q = s.bind(q)
@@ -72,8 +77,8 @@ func (s *Service) ListArchived(userID int64) ([]domain.Message, error) {
defer cancel() defer cancel()
q := ` q := `
SELECT id, senderId, recipientId, subject, message, is_read, is_archived, created_at SELECT id, senderId, recipientId, subject, body, is_read, is_archived, created_at
FROM users_messages FROM user_messages
WHERE recipientId = ? AND is_archived = TRUE WHERE recipientId = ? AND is_archived = TRUE
ORDER BY created_at DESC` ORDER BY created_at DESC`
q = s.bind(q) q = s.bind(q)
@@ -100,8 +105,8 @@ func (s *Service) GetByID(userID, id int64) (*domain.Message, error) {
defer cancel() defer cancel()
q := ` q := `
SELECT id, senderId, recipientId, subject, message, is_read, is_archived, created_at SELECT id, senderId, recipientId, subject, body, is_read, is_archived, created_at
FROM users_messages FROM user_messages
WHERE recipientId = ? AND id = ?` WHERE recipientId = ? AND id = ?`
q = s.bind(q) q = s.bind(q)
@@ -117,34 +122,66 @@ func (s *Service) GetByID(userID, id int64) (*domain.Message, error) {
return &m, nil return &m, nil
} }
func (s *Service) Create(userID int64, in domain.CreateMessageInput) (int64, error) { func (s *Service) Create(senderID int64, in domain.CreateMessageInput) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout) ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel() defer cancel()
switch s.Dialect { // ✅ make sure this matches your current table/column names
case "postgres":
const q = ` const q = `
INSERT INTO messages (id, senderId, recipientId, subject, message, is_read, is_archived, created_at) INSERT INTO user_messages
VALUES ($1, $2, $3, $4, $5, FALSE, FALSE, NOW()) (senderId, recipientId, subject, body, is_read, is_archived, created_at)
RETURNING id` VALUES
var id int64 (?, ?, ?, ?, 0, 0, CURRENT_TIMESTAMP)
if err := s.DB.QueryRowContext(ctx, q, userID, "", in.RecipientID, in.Subject, in.Body).Scan(&id); err != nil { `
return 0, err
} // 👀 Log the SQL and arguments (truncate body in logs if you prefer)
return id, nil logging.Info("🧪 SQL Exec: %s | args: senderId=%d recipientId=%d subject=%q body_len=%d", compactSQL(q), senderID, in.RecipientID, in.Subject, len(in.Body))
default: // mysql/sqlite
const q = ` res, err := s.DB.ExecContext(ctx, q, senderID, in.RecipientID, in.Subject, in.Body)
INSERT INTO messages (id, senderId, recipientId, subject, message is_read, is_archived, created_at)
VALUES (?, ?, ?, ?, ?, FALSE, FALSE, CURRENT_TIMESTAMP)`
res, err := s.DB.ExecContext(ctx, q, userID, "", in.RecipientID, in.Subject, in.Body)
if err != nil { if err != nil {
return 0, err // Surface MySQL code/message (very helpful for FK #1452 etc.)
var me *mysql.MySQLError
if errors.As(err, &me) {
wrapped := fmt.Errorf("insert user_messages: mysql #%d %s | args(senderId=%d, recipientId=%d, subject=%q, body_len=%d)",
me.Number, me.Message, senderID, in.RecipientID, in.Subject, len(in.Body))
logging.Info("❌ %v", wrapped)
return 0, wrapped
} }
return res.LastInsertId() wrapped := fmt.Errorf("insert user_messages: %w | args(senderId=%d, recipientId=%d, subject=%q, body_len=%d)",
err, senderID, in.RecipientID, in.Subject, len(in.Body))
logging.Info("❌ %v", wrapped)
return 0, wrapped
} }
id, err := res.LastInsertId()
if err != nil {
wrapped := fmt.Errorf("lastInsertId user_messages: %w", err)
logging.Info("❌ %v", wrapped)
return 0, wrapped
}
logging.Info("✅ Inserted message id=%d", id)
return id, nil
}
// compactSQL removes newlines/extra spaces for cleaner logs
func compactSQL(s string) string {
out := make([]rune, 0, len(s))
space := false
for _, r := range s {
if r == '\n' || r == '\t' || r == '\r' || r == ' ' {
if !space {
out = append(out, ' ')
space = true
}
continue
}
space = false
out = append(out, r)
}
return string(out)
} }
// bind replaces '?' with '$1..' only for Postgres. For MySQL/SQLite it returns q unchanged.
func (s *Service) bind(q string) string { func (s *Service) bind(q string) string {
if s.Dialect != "postgres" { if s.Dialect != "postgres" {
return q return q

View File

@@ -4,10 +4,10 @@ import (
"database/sql" "database/sql"
) )
func SendMessage(db *sql.DB, senderID, recipientID int, subject, message string) error { func SendMessage(db *sql.DB, senderID, recipientID int, subject, body string) error {
_, err := db.Exec(` _, err := db.Exec(`
INSERT INTO users_messages (senderId, recipientId, subject, message) INSERT INTO user_messages (senderId, recipientId, subject, body)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
`, senderID, recipientID, subject, message) `, senderID, recipientID, subject, body)
return err return err
} }

View File

@@ -9,7 +9,7 @@ import (
func GetMessageCount(db *sql.DB, userID int) (int, error) { func GetMessageCount(db *sql.DB, userID int) (int, error) {
var count int var count int
err := db.QueryRow(` err := db.QueryRow(`
SELECT COUNT(*) FROM users_messages SELECT COUNT(*) FROM user_messages
WHERE recipientId = ? AND is_read = FALSE AND is_archived = FALSE WHERE recipientId = ? AND is_read = FALSE AND is_archived = FALSE
`, userID).Scan(&count) `, userID).Scan(&count)
return count, err return count, err
@@ -17,8 +17,8 @@ func GetMessageCount(db *sql.DB, userID int) (int, error) {
func GetRecentMessages(db *sql.DB, userID int, limit int) []models.Message { func GetRecentMessages(db *sql.DB, userID int, limit int) []models.Message {
rows, err := db.Query(` rows, err := db.Query(`
SELECT id, senderId, recipientId, subject, message, is_read, created_at SELECT id, senderId, recipientId, subject, body, is_read, created_at
FROM users_messages FROM user_messages
WHERE recipientId = ? AND is_archived = FALSE WHERE recipientId = ? AND is_archived = FALSE
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ? LIMIT ?
@@ -49,8 +49,8 @@ func GetRecentMessages(db *sql.DB, userID int, limit int) []models.Message {
func GetMessageByID(db *sql.DB, userID, messageID int) (*models.Message, error) { func GetMessageByID(db *sql.DB, userID, messageID int) (*models.Message, error) {
row := db.QueryRow(` row := db.QueryRow(`
SELECT id, senderId, recipientId, subject, message, is_read, created_at SELECT id, senderId, recipientId, subject, body, is_read, created_at
FROM users_messages FROM user_messages
WHERE id = ? AND recipientId = ? WHERE id = ? AND recipientId = ?
`, messageID, userID) `, messageID, userID)
@@ -65,8 +65,8 @@ func GetMessageByID(db *sql.DB, userID, messageID int) (*models.Message, error)
func GetArchivedMessages(db *sql.DB, userID int, page, perPage int) []models.Message { func GetArchivedMessages(db *sql.DB, userID int, page, perPage int) []models.Message {
offset := (page - 1) * perPage offset := (page - 1) * perPage
rows, err := db.Query(` rows, err := db.Query(`
SELECT id, senderId, recipientId, subject, message, is_read, created_at, archived_at SELECT id, senderId, recipientId, subject, body, is_read, created_at, archived_at
FROM users_messages FROM user_messages
WHERE recipientId = ? AND is_archived = TRUE WHERE recipientId = ? AND is_archived = TRUE
ORDER BY archived_at DESC ORDER BY archived_at DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
@@ -94,8 +94,8 @@ func GetArchivedMessages(db *sql.DB, userID int, page, perPage int) []models.Mes
func GetInboxMessages(db *sql.DB, userID int, page, perPage int) []models.Message { func GetInboxMessages(db *sql.DB, userID int, page, perPage int) []models.Message {
offset := (page - 1) * perPage offset := (page - 1) * perPage
rows, err := db.Query(` rows, err := db.Query(`
SELECT id, senderId, recipientId, subject, message, is_read, created_at SELECT id, senderId, recipientId, subject, body, is_read, created_at
FROM users_messages FROM user_messages
WHERE recipientId = ? AND is_archived = FALSE WHERE recipientId = ? AND is_archived = FALSE
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
@@ -122,7 +122,7 @@ func GetInboxMessages(db *sql.DB, userID int, page, perPage int) []models.Messag
func GetInboxMessageCount(db *sql.DB, userID int) int { func GetInboxMessageCount(db *sql.DB, userID int) int {
var count int var count int
err := db.QueryRow(` err := db.QueryRow(`
SELECT COUNT(*) FROM users_messages SELECT COUNT(*) FROM user_messages
WHERE recipientId = ? AND is_archived = FALSE WHERE recipientId = ? AND is_archived = FALSE
`, userID).Scan(&count) `, userID).Scan(&count)
if err != nil { if err != nil {

View File

@@ -7,7 +7,7 @@ import (
func ArchiveMessage(db *sql.DB, userID, messageID int) error { func ArchiveMessage(db *sql.DB, userID, messageID int) error {
_, err := db.Exec(` _, err := db.Exec(`
UPDATE users_messages UPDATE user_messages
SET is_archived = TRUE, archived_at = CURRENT_TIMESTAMP SET is_archived = TRUE, archived_at = CURRENT_TIMESTAMP
WHERE id = ? AND recipientId = ? WHERE id = ? AND recipientId = ?
`, messageID, userID) `, messageID, userID)
@@ -16,7 +16,7 @@ func ArchiveMessage(db *sql.DB, userID, messageID int) error {
func MarkMessageAsRead(db *sql.DB, messageID, userID int) error { func MarkMessageAsRead(db *sql.DB, messageID, userID int) error {
result, err := db.Exec(` result, err := db.Exec(`
UPDATE users_messages UPDATE user_messages
SET is_read = TRUE SET is_read = TRUE
WHERE id = ? AND recipientId = ? WHERE id = ? AND recipientId = ?
`, messageID, userID) `, messageID, userID)
@@ -36,7 +36,7 @@ func MarkMessageAsRead(db *sql.DB, messageID, userID int) error {
func RestoreMessage(db *sql.DB, userID, messageID int) error { func RestoreMessage(db *sql.DB, userID, messageID int) error {
_, err := db.Exec(` _, err := db.Exec(`
UPDATE users_messages UPDATE user_messages
SET is_archived = FALSE, archived_at = NULL SET is_archived = FALSE, archived_at = NULL
WHERE id = ? AND recipientId = ? WHERE id = ? AND recipientId = ?
`, messageID, userID) `, messageID, userID)

View File

@@ -140,20 +140,20 @@ CREATE TABLE IF NOT EXISTS my_tickets (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- USERS MESSAGES -- USERS MESSAGES
CREATE TABLE IF NOT EXISTS users_messages ( CREATE TABLE IF NOT EXISTS user_messages (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
senderId BIGINT UNSIGNED NOT NULL, senderId BIGINT UNSIGNED NOT NULL,
recipientId BIGINT UNSIGNED NOT NULL, recipientId BIGINT UNSIGNED NOT NULL,
subject VARCHAR(255) NOT NULL, subject VARCHAR(255) NOT NULL,
message MEDIUMTEXT, body MEDIUMTEXT,
is_read TINYINT(1) NOT NULL DEFAULT 0, is_read TINYINT(1) NOT NULL DEFAULT 0,
is_archived TINYINT(1) NOT NULL DEFAULT 0, is_archived TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
archived_at DATETIME NULL, archived_at DATETIME NULL,
CONSTRAINT fk_users_messages_sender CONSTRAINT fk_user_messages_sender
FOREIGN KEY (senderId) REFERENCES users(id) FOREIGN KEY (senderId) REFERENCES users(id)
ON UPDATE CASCADE ON DELETE CASCADE, ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_users_messages_recipient CONSTRAINT fk_user_messages_recipient
FOREIGN KEY (recipientId) REFERENCES users(id) FOREIGN KEY (recipientId) REFERENCES users(id)
ON UPDATE CASCADE ON DELETE CASCADE ON UPDATE CASCADE ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -7,7 +7,7 @@
{{ range .Messages }} {{ range .Messages }}
<li class="list-group-item d-flex justify-content-between align-items-center"> <li class="list-group-item d-flex justify-content-between align-items-center">
<div> <div>
<a href="/account/messages/{{ .ID }}" class="fw-bold text-dark">{{ .Subject }}</a><br> <a href="/account/messages/read?={{ .ID }}" class="fw-bold text-dark">{{ .Subject }}</a><br>
<small class="text-muted">{{ .CreatedAt.Format "02 Jan 2006 15:04" }}</small> <small class="text-muted">{{ .CreatedAt.Format "02 Jan 2006 15:04" }}</small>
</div> </div>
<form method="POST" action="/account/messages/archive" class="m-0"> <form method="POST" action="/account/messages/archive" class="m-0">
@@ -19,7 +19,6 @@
{{ end }} {{ end }}
</ul> </ul>
<!-- (Optional) Pagination if you have helpers wired -->
<nav> <nav>
<ul class="pagination"> <ul class="pagination">
{{ if gt .CurrentPage 1 }} {{ if gt .CurrentPage 1 }}

View File

@@ -94,7 +94,7 @@
<i class="bi bi-person-circle me-2 fs-4 text-secondary"></i> <i class="bi bi-person-circle me-2 fs-4 text-secondary"></i>
<div> <div>
<div class="fw-semibold">{{ $m.Subject }}</div> <div class="fw-semibold">{{ $m.Subject }}</div>
<small class="text-muted">{{ truncate $m.Message 40 }}</small> <small class="text-muted">{{ truncate $m.Body 40 }}</small>
</div> </div>
</div> </div>
</a> </a>