Compare commits

...

27 Commits

Author SHA1 Message Date
cc759ec694 Fix csrf 2025-11-02 09:50:50 +00:00
f0fc70eac6 Add in mark as reaad button to list view, use ajax to preform the action without page refresh. 2025-11-02 09:11:48 +00:00
61ad033520 Fix archiving and unarchiving functionality. 2025-11-01 22:37:47 +00:00
9dc01f925a Changes to pagination and fixing archive messages in progress 2025-10-31 22:55:04 +00:00
8529116ad2 Messages now sending/loading and populating on message dropdown 2025-10-31 12:08:38 +00:00
776ea53a66 Formatting 2025-10-31 12:00:43 +00:00
5880d1ca43 Fix reading of messages. 2025-10-31 12:00:08 +00:00
da365aa9ef Remove unused functions. 2025-10-31 11:57:39 +00:00
5177194895 Add sender 2025-10-31 09:45:20 +00:00
a7a5169c67 Fix model issues. 2025-10-30 22:19:48 +00:00
262536135d Still working through messages and notifications. 2025-10-30 17:22:52 +00:00
8650b1fd63 Continued work on messages and notifications. 2025-10-30 11:11:22 +00:00
b41e92629b Continued work around getting messages and notifications cleaned up since moving to MySQL and changing to Gin, SCS, NoSurf. 2025-10-29 15:22:05 +00:00
0b2883a494 todo comment 2025-10-29 15:21:20 +00:00
5520685504 minor update to footer. 2025-10-29 15:21:07 +00:00
e2b30c0234 minor formatting and text 2025-10-29 15:19:24 +00:00
07f7a50b77 ToDo job 2025-10-29 15:19:07 +00:00
f458250d3a correct package name 2025-10-29 11:38:05 +00:00
f2cb283158 todo for a later date 2025-10-29 11:37:50 +00:00
b9bc29d5bc fix loading of ticket add page 2025-10-29 11:37:35 +00:00
b6b5207d43 Fleshing out some routes from notifications and messages 2025-10-29 10:43:48 +00:00
34918d770f Fix for tim.Time change to tickets model includes date helper. 2025-10-29 10:00:58 +00:00
eba25a4fb5 comment model. 2025-10-29 09:47:51 +00:00
e6654fc1b4 User specific lottery ticket creation 2025-10-29 09:47:35 +00:00
ddafdd0468 current duplicate check uses IS ? which is fragile in MySQL. Using the NULL-safe equality operator <=> instead. 2025-10-29 09:29:51 +00:00
5fcb4fb016 change the field to time.Time for correctness 2025-10-29 09:29:10 +00:00
71c8d4d06c fix typo 2025-10-29 08:54:19 +00:00
47 changed files with 1961 additions and 240 deletions

View File

@@ -0,0 +1,25 @@
package domainMessages
import (
"synlotto-website/internal/models"
)
type Message = models.Message
type CreateMessageInput struct {
SenderID int64
RecipientID int64 `form:"recipientId" binding:"required,numeric"`
Subject string `form:"subject" binding:"required,max=200"`
Body string `form:"body" binding:"required"`
}
type MessageService interface {
ListInbox(userID int64) ([]Message, error)
ListArchived(userID int64) ([]Message, error)
GetByID(userID, id int64) (*Message, error)
Create(userID int64, in CreateMessageInput) (int64, error)
Archive(userID, id int64) error
Unarchive(userID, id int64) error
MarkRead(userID, id int64) error
//MarkUnread(userID, id int64) error
}

View File

@@ -0,0 +1,14 @@
package domainMessages
import (
"synlotto-website/internal/models"
)
// ToDo: Should be taken from model.
type Notification = models.Notification
// ToDo: Should interfaces be else where?
type NotificationService interface {
List(userID int64) ([]Notification, error)
GetByID(userID, id int64) (*Notification, error)
}

View File

@@ -0,0 +1,152 @@
// Package accountMessageHandler
// Path: /internal/handlers/account/messages
// File: archive.go
package accountMessageHandler
import (
"bytes"
"database/sql"
"errors"
"net/http"
"strconv"
templateHandlers "synlotto-website/internal/handlers/template"
templateHelpers "synlotto-website/internal/helpers/template"
httpErrors "synlotto-website/internal/http/error"
"synlotto-website/internal/logging"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
"github.com/justinas/nosurf"
)
// GET /account/messages/archived
// Renders: web/templates/account/messages/archived.html
func (h *AccountMessageHandlers) ArchivedList(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
userID := mustUserID(c)
// 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")
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 {
httpErrors.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/archived
func (h *AccountMessageHandlers) RestoreArchived(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 {
sm.Put(c.Request.Context(), "flash", "Invalid message id.")
c.Redirect(http.StatusSeeOther, "/account/messages/archive")
return
}
if err := h.Svc.Unarchive(userID, id); err != nil {
logging.Info("❌ restore/unarchive error: %v", err)
// If no rows affected, show friendly flash; otherwise generic message.
if errors.Is(err, sql.ErrNoRows) {
sm.Put(c.Request.Context(), "flash", "Message not found or not permitted.")
} else {
sm.Put(c.Request.Context(), "flash", "Could not restore message.")
}
c.Redirect(http.StatusSeeOther, "/account/messages/archive")
return
}
sm.Put(c.Request.Context(), "flash", "Message restored.")
c.Redirect(http.StatusSeeOther, "/account/messages/archive")
}

View File

@@ -0,0 +1,20 @@
// Package accountMessageHandler
// Path: /internal/handlers/account/messages
// File: list.go
// ToDo: helpers for reading getting messages shouldn't really be here. ---
package accountMessageHandler
import (
"github.com/gin-gonic/gin"
)
func mustUserID(c *gin.Context) int64 {
if v, ok := c.Get("userID"); ok {
if id, ok2 := v.(int64); ok2 {
return id
}
}
// Fallback for stubs:
return 1
}

View File

@@ -0,0 +1,173 @@
// Package accountMessageHandler
// Path: /internal/handlers/account/messages
// File: read.go
// ToDo: Remove SQL
package accountMessageHandler
import (
"bytes"
"database/sql"
"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"
"github.com/gin-gonic/gin"
"github.com/justinas/nosurf"
)
// GET /account/messages
// Renders: web/templates/account/messages/index.html
func (h *AccountMessageHandlers) List(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
userID := mustUserID(c)
// --- 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 = 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
}
// --- Data ---
msgsAll, err := h.Svc.ListInbox(userID)
if err != nil {
logging.Info("❌ list inbox error: %v", err)
c.String(http.StatusInternalServerError, "Failed to load messages")
return
}
// Temporary in-memory slice (until LIMIT/OFFSET is added)
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]
// --- Template context ---
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"] = "Messages"
ctx["Messages"] = msgs
ctx["CurrentPage"] = page
ctx["TotalPages"] = totalPages
ctx["TotalCount"] = totalCount
ctx["PageRange"] = templateHelpers.MakePageRange(1, totalPages)
// --- Render ---
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/messages/index.html")
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 messages page")
return
}
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)
sm := app.SessionManager
userID := mustUserID(c)
idStr := c.Query("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
errors.RenderStatus(c, sm, http.StatusNotFound)
return
}
msg, err := h.Svc.GetByID(userID, id)
if err != nil || msg == nil {
errors.RenderStatus(c, sm, http.StatusNotFound)
return
}
data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request)
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data)
ctx["CSRFToken"] = nosurf.Token(c.Request)
ctx["Title"] = msg.Subject
ctx["Message"] = msg
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/messages/read.html")
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 message")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
}
func (h *AccountMessageHandlers) MarkReadPost(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 {
sm.Put(c.Request.Context(), "flash", "Invalid message id.")
c.Redirect(http.StatusSeeOther, c.Request.Referer()) // back to where they came from
return
}
if err := h.Svc.MarkRead(userID, id); err != nil {
logging.Info("❌ MarkRead error: %v", err)
if err == sql.ErrNoRows {
sm.Put(c.Request.Context(), "flash", "Message not found or not permitted.")
} else {
sm.Put(c.Request.Context(), "flash", "Could not mark message as read.")
}
c.Redirect(http.StatusSeeOther, "/account/messages")
return
}
sm.Put(c.Request.Context(), "flash", "Message marked as read.")
// Redirect back to referer when possible so UX is smooth.
if ref := c.Request.Referer(); ref != "" {
c.Redirect(http.StatusSeeOther, ref)
} else {
c.Redirect(http.StatusSeeOther, "/account/messages")
}
}

View File

@@ -0,0 +1,104 @@
// Package accountMessageHandler
// Path: /internal/handlers/account/messages
// File: send.go
package accountMessageHandler
import (
"net/http"
domain "synlotto-website/internal/domain/messages"
templateHandlers "synlotto-website/internal/handlers/template"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/logging"
"synlotto-website/internal/models"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
"github.com/justinas/nosurf"
)
// GET /account/messages/send
// Renders: web/templates/account/messages/send.html
func (h *AccountMessageHandlers) SendGet(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
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"] = "Send Message"
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/messages/send.html")
c.Status(http.StatusOK)
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil {
logging.Info("❌ Template render error: %v", err)
c.String(http.StatusInternalServerError, "Error rendering send message page")
}
}
// POST /account/messages/send
func (h *AccountMessageHandlers) SendPost(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
userID := mustUserID(c)
var in domain.CreateMessageInput
if err := c.ShouldBind(&in); err != nil {
// Re-render form with validation errors
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"] = "Send Message"
ctx["Error"] = "Please correct the errors below."
ctx["Form"] = in
tmpl := templateHelpers.LoadTemplateFiles(
"layout.html",
"web/templates/account/messages/send.html",
)
c.Status(http.StatusBadRequest)
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil {
logging.Info("❌ Template render error: %v", err)
c.String(http.StatusInternalServerError, "Error rendering send message page")
}
return
}
if _, err := h.Svc.Create(userID, in); err != nil {
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, models.TemplateData{})
if f := sm.PopString(c.Request.Context(), "flash"); f != "" {
ctx["Flash"] = f
}
ctx["CSRFToken"] = nosurf.Token(c.Request)
ctx["Title"] = "Send Message"
ctx["Error"] = "Could not send message."
ctx["Form"] = in
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/messages/send.html")
c.Status(http.StatusInternalServerError)
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil {
logging.Info("❌ Template render error: %v", err)
c.String(http.StatusInternalServerError, "Error rendering send message page")
}
return
}
sm.Put(c.Request.Context(), "flash", "Message sent!")
// Redirect back to inbox
c.Redirect(http.StatusSeeOther, "/account/messages")
}

View File

@@ -0,0 +1,11 @@
// Package accountMessageHandler
// Path: /internal/handlers/account/messages
// File: types.go
package accountMessageHandler
import domain "synlotto-website/internal/domain/messages"
type AccountMessageHandlers struct {
Svc domain.MessageService
}

View File

@@ -0,0 +1,75 @@
package accountNotificationHandler
import (
"net/http"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/logging"
"synlotto-website/internal/models"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
"github.com/justinas/nosurf"
)
// ToDo: functional also in messages needs to come out
func mustUserID(c *gin.Context) int64 {
// Pull from your auth middleware/session. Panic-unsafe alternative:
if v, ok := c.Get("userID"); ok {
if id, ok2 := v.(int64); ok2 {
return id
}
}
// Fallback for stubs:
return 1
}
// ToDo: functional also in messages needs to come out
func atoi64(s string) (int64, error) {
// small helper to keep imports focused
// replace with strconv.ParseInt in real code
var n int64
for _, ch := range []byte(s) {
if ch < '0' || ch > '9' {
return 0, &strconvNumErr{}
}
n = n*10 + int64(ch-'0')
}
return n, nil
}
type strconvNumErr struct{}
func (e *strconvNumErr) Error() string { return "invalid number" }
// GET /account/notifications/:id
// Renders: web/templates/account/notifications/read.html
func (h *AccountNotificationHandlers) List(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
userID := mustUserID(c)
notes, err := h.Svc.List(userID) // or ListAll/ListUnread use your method name
if err != nil {
logging.Info("❌ list notifications error: %v", err)
c.String(http.StatusInternalServerError, "Failed to load notifications")
return
}
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, models.TemplateData{})
if f := sm.PopString(c.Request.Context(), "flash"); f != "" {
ctx["Flash"] = f
}
ctx["CSRFToken"] = nosurf.Token(c.Request)
ctx["Title"] = "Notifications"
ctx["Notifications"] = notes
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/notifications/index.html")
c.Status(http.StatusOK)
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil {
logging.Info("❌ Template render error: %v", err)
c.String(http.StatusInternalServerError, "Error rendering notifications page")
}
}

View File

@@ -0,0 +1,58 @@
// internal/handlers/account/notifications/read.go
package accountNotificationHandler
import (
"net/http"
templateHelpers "synlotto-website/internal/helpers/template"
"synlotto-website/internal/logging"
"synlotto-website/internal/models"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
"github.com/justinas/nosurf"
)
// ToDo: functional also in messages needs to come out
func parseIDParam(c *gin.Context, name string) (int64, error) {
// typical atoi wrapper
// (implement: strconv.ParseInt(c.Param(name), 10, 64))
return atoi64(c.Param(name))
}
func (h *AccountNotificationHandlers) ReadGet(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
userID := mustUserID(c)
id, err := parseIDParam(c, "id")
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
n, err := h.Svc.GetByID(userID, id)
if err != nil || n == nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, models.TemplateData{})
if f := sm.PopString(c.Request.Context(), "flash"); f != "" {
ctx["Flash"] = f
}
ctx["CSRFToken"] = nosurf.Token(c.Request)
ctx["Title"] = n.Title // or Subject/Heading depending on your struct
ctx["Notification"] = n
tmpl := templateHelpers.LoadTemplateFiles(
"layout.html",
"web/templates/account/notifications/read.html",
)
c.Status(http.StatusOK)
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil {
logging.Info("❌ Template render error: %v", err)
c.String(http.StatusInternalServerError, "Error rendering notification")
}
}

View File

@@ -0,0 +1,7 @@
package accountNotificationHandler
import domain "synlotto-website/internal/domain/notifications"
type AccountNotificationHandlers struct {
Svc domain.NotificationService
}

View File

@@ -0,0 +1,161 @@
// Package accountTicketHandlers
// Path: /internal/handlers/account/tickets/
// File: add.go
//
// Purpose
// Renders & processes the Add Ticket form for authenticated users.
//
// Responsibilities
// 1) Validate user input (game type, draw date, balls and optional bonuses)
// 2) Convert string form values into typed model fields
// 3) Save through storage layer (InsertTicket)
// 4) Prevent DB access from unauthenticated contexts
// 5) Use PRG pattern (POST/Redirect/GET)
//
// Notes
// - No direct SQL here — storage package enforces constraints
// - CSRF provided via nosurf
// - TODO: Replace inline session key with central sessionkeys.UserID
package accountTicketHandlers
import (
"net/http"
"strconv"
"time"
templateHandlers "synlotto-website/internal/handlers/template"
templateHelpers "synlotto-website/internal/helpers/template"
ticketStorage "synlotto-website/internal/storage/tickets"
"synlotto-website/internal/models"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
"github.com/justinas/nosurf"
)
// TODO: Replace with centralized key from sessionkeys package
const sessionKeyUserID = "UserID"
func AddGet(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request)
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data)
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/tickets/add_ticket.html")
c.Header("Content-Type", "text/html; charset=utf-8")
if err := tmpl.ExecuteTemplate(c.Writer, "account/tickets/add_ticket.html", ctx); err != nil {
c.String(http.StatusInternalServerError, "render error: %v", err)
return
}
c.Status(http.StatusOK)
}
func AddPost(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
var f addForm
_ = c.ShouldBind(&f)
f.Errors = map[string]string{}
// Validate required fields
if f.GameType == "" {
f.Errors["game"] = "Game type is required."
}
if f.DrawDate == "" {
f.Errors["draw_date"] = "Draw date is required."
}
balls, ballErrs := parseBalls(f.Ball1, f.Ball2, f.Ball3, f.Ball4, f.Ball5)
for k, v := range ballErrs {
f.Errors[k] = v
}
var drawDate time.Time
if f.DrawDate != "" {
if d, err := time.Parse("2006-01-02", f.DrawDate); err == nil {
drawDate = d
} else {
f.Errors["draw_date"] = "Invalid date (use YYYY-MM-DD)."
}
}
var bonus1Ptr, bonus2Ptr *int
if f.Bonus1 != "" {
if n, err := strconv.Atoi(f.Bonus1); err == nil {
bonus1Ptr = &n
} else {
f.Errors["bonus1"] = "Bonus 1 must be a number."
}
}
if f.Bonus2 != "" {
if n, err := strconv.Atoi(f.Bonus2); err == nil {
bonus2Ptr = &n
} else {
f.Errors["bonus2"] = "Bonus 2 must be a number."
}
}
if len(f.Errors) > 0 {
f.CSRFToken = nosurf.Token(c.Request)
c.HTML(http.StatusUnprocessableEntity, "account/tickets/add_ticket.html", gin.H{
"title": "Add Ticket",
"form": f,
})
return
}
// Build the ticket model expected by ticketStorage.InsertTicket
ticket := models.Ticket{
GameType: f.GameType,
DrawDate: drawDate,
Ball1: balls[0],
Ball2: balls[1],
Ball3: balls[2],
Ball4: balls[3],
Ball5: balls[4],
Bonus1: bonus1Ptr,
Bonus2: bonus2Ptr,
// TODO: populate UserID from session when per-user tickets enabled
}
if err := ticketStorage.InsertTicket(app.DB, ticket); err != nil {
// optional: set flash and re-render
f.Errors["form"] = "Could not save ticket. Please try again."
f.CSRFToken = nosurf.Token(c.Request)
c.HTML(http.StatusInternalServerError, "account/tickets/add_ticket.html", gin.H{
"title": "Add Ticket",
"form": f,
})
return
}
c.Redirect(http.StatusSeeOther, "/account/tickets")
}
// helpers
func parseBalls(b1, b2, b3, b4, b5 string) ([5]int, map[string]string) {
errs := map[string]string{}
toInt := func(name, v string) (int, bool) {
n, err := strconv.Atoi(v)
if err != nil {
errs[name] = "Must be a number."
return 0, false
}
return n, true
}
var out [5]int
ok := true
if out[0], ok = toInt("ball1", b1); !ok {
}
if out[1], ok = toInt("ball2", b2); !ok {
}
if out[2], ok = toInt("ball3", b3); !ok {
}
if out[3], ok = toInt("ball4", b4); !ok {
}
if out[4], ok = toInt("ball5", b5); !ok {
}
return out, errs
}

View File

@@ -0,0 +1,69 @@
// Package accountTicketHandlers
// Path: /internal/handlers/account/tickets/
// File: list.go
//
// Purpose
// List all tickets belonging to the currently authenticated user.
//
// Responsibilities
// - Validate session context
// - Query DB for tickets filtered by user_id
// - Transform rows into template-safe values
//
// TODO
// - Move SQL query into storage layer (read model)
// - Support pagination or date filtering
package accountTicketHandlers
import (
"net/http"
"synlotto-website/internal/platform/bootstrap"
"github.com/gin-gonic/gin"
"github.com/justinas/nosurf"
)
func List(c *gin.Context) {
app := c.MustGet("app").(*bootstrap.App)
sm := app.SessionManager
userIDAny := sm.Get(c.Request.Context(), sessionKeyUserID)
userID, ok := userIDAny.(int64)
if !ok || userID == 0 {
c.Redirect(http.StatusSeeOther, "/account/login")
return
}
rows, err := app.DB.QueryContext(c.Request.Context(), `
SELECT id, numbers, game, price, purchased_at, created_at
FROM my_tickets
WHERE userId = ?
ORDER BY purchased_at DESC, id DESC
`, userID)
if err != nil {
c.HTML(http.StatusInternalServerError, "account/tickets/my_tickets.html", gin.H{
"title": "My Tickets",
"err": "Could not load your tickets.",
})
return
}
defer rows.Close()
var items []ticketRow
for rows.Next() {
var t ticketRow
if err := rows.Scan(&t.ID, &t.Numbers, &t.Game, &t.Price, &t.PurchasedAt, &t.CreatedAt); err != nil {
continue
}
items = append(items, t)
}
view := gin.H{
"title": "My Tickets",
"tickets": items,
"csrfToken": nosurf.Token(c.Request), // useful if list page has inline delete in future
}
c.HTML(http.StatusOK, "account/tickets/my_tickets.html", view)
}

View File

@@ -0,0 +1,39 @@
// Package accountTicketHandlers
// Path: /internal/handlers/account/tickets/
// File: types.go
//
// Purpose
// Form and view models for ticket create + list flows.
// These types are not persisted directly.
//
// Notes
// Mapping exists only from request → model → template
package accountTicketHandlers
import "time"
// Add Ticket form structure
type addForm struct {
GameType string `form:"game"` // e.g. "Lotto", "EuroMillions"
DrawDate string `form:"draw_date"` // yyyy-mm-dd from <input type="date">
Ball1 string `form:"ball1"`
Ball2 string `form:"ball2"`
Ball3 string `form:"ball3"`
Ball4 string `form:"ball4"`
Ball5 string `form:"ball5"`
Bonus1 string `form:"bonus1"` // optional
Bonus2 string `form:"bonus2"` // optional
Errors map[string]string
CSRFToken string
}
// Ticket list renderer (subset of DB ticket fields)
type ticketRow struct {
ID int64
Numbers string
Game *string
Price *string
PurchasedAt time.Time
CreatedAt time.Time
}

View File

@@ -145,16 +145,24 @@ func SyndicateLogTicketHandler(app *bootstrap.App) http.HandlerFunc {
case http.MethodPost: case http.MethodPost:
gameType := r.FormValue("game_type") gameType := r.FormValue("game_type")
drawDate := r.FormValue("draw_date") drawDateStr := r.FormValue("draw_date")
method := r.FormValue("purchase_method") method := r.FormValue("purchase_method")
err := ticketStorage.InsertTicket(app.DB, models.Ticket{ dt, err := helpers.ParseDrawDate(drawDateStr)
if err != nil {
templateHelpers.SetFlash(r, "Invalid draw date")
http.Redirect(w, r, fmt.Sprintf("/syndicate/view?id=%d", syndicateId), http.StatusSeeOther)
return
}
err = ticketStorage.InsertTicket(app.DB, models.Ticket{
UserId: userID, UserId: userID,
GameType: gameType, GameType: gameType,
DrawDate: drawDate, DrawDate: dt,
PurchaseMethod: method, PurchaseMethod: method,
SyndicateId: &syndicateId, SyndicateId: &syndicateId,
}) })
if err != nil { if err != nil {
templateHelpers.SetFlash(r, "Failed to add ticket.") templateHelpers.SetFlash(r, "Failed to add ticket.")
} else { } else {

View File

@@ -74,10 +74,18 @@ func AddTicket(app *bootstrap.App) http.HandlerFunc {
} }
game := r.FormValue("game_type") game := r.FormValue("game_type")
drawDate := r.FormValue("draw_date") drawDateStr := r.FormValue("draw_date")
purchaseMethod := r.FormValue("purchase_method") purchaseMethod := r.FormValue("purchase_method")
purchaseDate := r.FormValue("purchase_date") purchaseDate := r.FormValue("purchase_date")
purchaseTime := r.FormValue("purchase_time") purchaseTime := r.FormValue("purchase_time")
dt, err := helpers.ParseDrawDate(drawDateStr)
if err != nil {
http.Error(w, "Invalid draw date", http.StatusBadRequest)
return
}
drawDateDB := helpers.FormatDrawDate(dt) // "YYYY-MM-DD"
if purchaseTime != "" { if purchaseTime != "" {
purchaseDate += "T" + purchaseTime purchaseDate += "T" + purchaseTime
} }
@@ -165,7 +173,7 @@ func AddTicket(app *bootstrap.App) http.HandlerFunc {
purchase_method, purchase_date, image_path purchase_method, purchase_date, image_path
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
userID, game, drawDate, userID, game, drawDateDB,
b[0], b[1], b[2], b[3], b[4], b[5], b[0], b[1], b[2], b[3], b[4], b[5],
bo[0], bo[1], bo[0], bo[1],
purchaseMethod, purchaseDate, imagePath, purchaseMethod, purchaseDate, imagePath,
@@ -195,10 +203,18 @@ func SubmitTicket(app *bootstrap.App) http.HandlerFunc {
} }
game := r.FormValue("game_type") game := r.FormValue("game_type")
drawDate := r.FormValue("draw_date") drawDateStr := r.FormValue("draw_date")
purchaseMethod := r.FormValue("purchase_method") purchaseMethod := r.FormValue("purchase_method")
purchaseDate := r.FormValue("purchase_date") purchaseDate := r.FormValue("purchase_date")
purchaseTime := r.FormValue("purchase_time") purchaseTime := r.FormValue("purchase_time")
dt, err := helpers.ParseDrawDate(drawDateStr)
if err != nil {
http.Error(w, "Invalid draw date", http.StatusBadRequest)
return
}
drawDateDB := helpers.FormatDrawDate(dt)
if purchaseTime != "" { if purchaseTime != "" {
purchaseDate += "T" + purchaseTime purchaseDate += "T" + purchaseTime
} }
@@ -253,7 +269,7 @@ func SubmitTicket(app *bootstrap.App) http.HandlerFunc {
purchase_method, purchase_date, image_path purchase_method, purchase_date, image_path
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
userID, game, drawDate, userID, game, drawDateDB,
b[0], b[1], b[2], b[3], b[4], b[5], b[0], b[1], b[2], b[3], b[4], b[5],
bo[0], bo[1], bo[0], bo[1],
purchaseMethod, purchaseDate, imagePath, purchaseMethod, purchaseDate, imagePath,
@@ -299,6 +315,7 @@ func GetMyTickets(app *bootstrap.App) http.HandlerFunc {
for rows.Next() { for rows.Next() {
var t models.Ticket var t models.Ticket
var drawDateStr string // ← add
var b1, b2, b3, b4, b5, b6, bo1, bo2 sql.NullInt64 var b1, b2, b3, b4, b5, b6, bo1, bo2 sql.NullInt64
var matchedMain, matchedBonus sql.NullInt64 var matchedMain, matchedBonus sql.NullInt64
var prizeTier sql.NullString var prizeTier sql.NullString
@@ -307,7 +324,7 @@ func GetMyTickets(app *bootstrap.App) http.HandlerFunc {
var prizeAmount sql.NullFloat64 var prizeAmount sql.NullFloat64
if err := rows.Scan( if err := rows.Scan(
&t.Id, &t.GameType, &t.DrawDate, &t.Id, &t.GameType, &drawDateStr, // ← was &t.DrawDate
&b1, &b2, &b3, &b4, &b5, &b6, &b1, &b2, &b3, &b4, &b5, &b6,
&bo1, &bo2, &bo1, &bo2,
&t.PurchaseMethod, &t.PurchaseDate, &t.ImagePath, &t.Duplicate, &t.PurchaseMethod, &t.PurchaseDate, &t.ImagePath, &t.Duplicate,
@@ -317,6 +334,11 @@ func GetMyTickets(app *bootstrap.App) http.HandlerFunc {
continue continue
} }
// Parse into time.Time (UTC)
if dt, err := helpers.ParseDrawDate(drawDateStr); err == nil {
t.DrawDate = dt
}
// Normalize fields // Normalize fields
t.Ball1 = int(b1.Int64) t.Ball1 = int(b1.Int64)
t.Ball2 = int(b2.Int64) t.Ball2 = int(b2.Int64)
@@ -351,7 +373,7 @@ func GetMyTickets(app *bootstrap.App) http.HandlerFunc {
t.BonusBalls = helpers.BuildBonusSlice(t) t.BonusBalls = helpers.BuildBonusSlice(t)
// Fetch matching draw info // Fetch matching draw info
draw := draws.GetDrawResultForTicket(app.DB, t.GameType, t.DrawDate) draw := draws.GetDrawResultForTicket(app.DB, t.GameType, helpers.FormatDrawDate(t.DrawDate))
t.MatchedDraw = draw t.MatchedDraw = draw
tickets = append(tickets, t) tickets = append(tickets, t)

View File

@@ -177,6 +177,6 @@ func RestoreMessageHandler(app *bootstrap.App) http.HandlerFunc {
templateHelpers.SetFlash(r, "Message restored.") templateHelpers.SetFlash(r, "Message restored.")
} }
http.Redirect(w, r, "/account/messages/archived", http.StatusSeeOther) http.Redirect(w, r, "/account/messages/archive", http.StatusSeeOther)
} }
} }

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,

31
internal/helpers/dates.go Normal file
View File

@@ -0,0 +1,31 @@
package helpers
import (
"fmt"
"time"
)
var drawDateLayouts = []string{
time.RFC3339, // 2006-01-02T15:04:05Z07:00
"2006-01-02", // 2025-10-29
"2006-01-02 15:04", // 2025-10-29 20:30
"2006-01-02 15:04:05", // 2025-10-29 20:30:59
}
// ParseDrawDate tries multiple layouts and returns UTC.
func ParseDrawDate(s string) (time.Time, error) {
for _, l := range drawDateLayouts {
if t, err := time.ParseInLocation(l, s, time.Local); err == nil {
return t.UTC(), nil
}
}
return time.Time{}, fmt.Errorf("cannot parse draw date: %q", s)
}
// FormatDrawDate normalizes a time to the storage format you use in SQL (date only).
func FormatDrawDate(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format("2006-01-02")
}

View File

@@ -47,6 +47,7 @@ func TemplateContext(w http.ResponseWriter, r *http.Request, data models.Templat
} }
} }
// ToDo the funcs need breaking up getting large
func TemplateFuncs() template.FuncMap { func TemplateFuncs() template.FuncMap {
return template.FuncMap{ return template.FuncMap{
"plus1": func(i int) int { return i + 1 }, "plus1": func(i int) int { return i + 1 },

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

@@ -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

@@ -1,7 +1,30 @@
// Package routes
// Path: /internal/http/routes
// File: accountroutes.go
//
// Purpose
// Defines all /account route groups including:
//
// - Public authentication pages (login, signup)
// - Protected session actions (logout)
// - Auth-protected ticket management pages
//
// Responsibilities (as implemented here)
// 1) PublicOnly guard on login/signup pages
// 2) RequireAuth guard on logout and tickets pages
// 3) Clean REST path structure for tickets ("/account/tickets")
//
// Notes
// - AuthMiddleware must come before RequireAuth
// - Ticket routes rely on authenticated user context
package routes package routes
import ( import (
accountHandlers "synlotto-website/internal/handlers/account" accountHandler "synlotto-website/internal/handlers/account"
accountMsgHandlers "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/http/middleware"
"synlotto-website/internal/platform/bootstrap" "synlotto-website/internal/platform/bootstrap"
@@ -10,18 +33,62 @@ import (
func RegisterAccountRoutes(app *bootstrap.App) { func RegisterAccountRoutes(app *bootstrap.App) {
r := app.Router r := app.Router
// Instantiate handlers that have method receivers
messageSvc := app.Services.Messages
msgH := &accountMsgHandlers.AccountMessageHandlers{Svc: messageSvc}
notificationSvc := app.Services.Notifications
notifH := &accountNotificationHandler.AccountNotificationHandlers{Svc: notificationSvc}
// ticketSvc := app.Services.TicketService
// ticketH := &accountTickets.AccountTicketHandlers{Svc: ticketSvc}
// Public account pages
acc := r.Group("/account") acc := r.Group("/account")
acc.Use(middleware.PublicOnly()) acc.Use(middleware.PublicOnly())
{ {
acc.GET("/login", accountHandlers.LoginGet) acc.GET("/login", accountHandler.LoginGet)
acc.POST("/login", accountHandlers.LoginPost) acc.POST("/login", accountHandler.LoginPost)
acc.GET("/signup", accountHandlers.SignupGet) acc.GET("/signup", accountHandler.SignupGet)
acc.POST("/signup", accountHandlers.SignupPost) acc.POST("/signup", accountHandler.SignupPost)
} }
// Protected logout // Auth-required account actions
accAuth := r.Group("/account") accAuth := r.Group("/account")
accAuth.Use(middleware.AuthMiddleware(), middleware.RequireAuth()) accAuth.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
accAuth.POST("/logout", accountHandlers.Logout) {
accAuth.GET("/logout", accountHandlers.Logout) //ToDo: keep if you still support GET? 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("/", msgH.List)
messages.GET("/read", msgH.ReadGet)
messages.GET("/send", msgH.SendGet)
messages.POST("/send", msgH.SendPost)
messages.GET("/archive", msgH.ArchivedList) // view archived messages
messages.POST("/archive", msgH.ArchivePost) // archive a message
messages.POST("/restore", msgH.RestoreArchived)
messages.POST("/mark-read", msgH.MarkReadPost)
}
// Notifications (auth-required)
notifications := r.Group("/account/notifications")
notifications.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
{
notifications.GET("/", notifH.List)
notifications.GET("/:id", notifH.ReadGet) // renders read.html
}
// Tickets (auth-required)
tickets := r.Group("/account/tickets")
tickets.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
{
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,17 @@
package models
import (
"time"
)
type Message struct {
ID int
SenderId int
RecipientId int
Subject string
Body string
IsRead bool
IsArchived bool
CreatedAt time.Time
ArchivedAt *time.Time
}

View File

@@ -0,0 +1,12 @@
package models
import "time"
type Notification struct {
ID int
UserId int
Title string
Body string
IsRead bool
CreatedAt time.Time
}

View File

@@ -1,29 +1,50 @@
// Package models
// Path: internal/models/
// File: ticket.go
//
// Purpose
// Canonical persistence model for tickets as stored in DB,
// plus display helpers populated at read time.
//
// Responsibilities
// - Represents input values for ticket creation
// - Stores normalized draw fields for comparison
// - Optional fields (bonus, syndicate) use pointer types
//
// Notes
// - Read-only display fields must not be persisted directly
// - TODO: enforce UserID presence once per-user tickets are fully enabled
package models package models
import "time"
type Ticket struct { type Ticket struct {
Id int Id int // Persistent DB primary key
UserId int UserId int // FK to users(id) when multi-user enabled
SyndicateId *int SyndicateId *int // Optional FK if purchased via syndicate
GameType string GameType string // Lottery type (e.g., Lotto)
DrawDate string DrawDate time.Time // Stored as UTC datetime to avoid timezone issues
Ball1 int Ball1 int
Ball2 int Ball2 int
Ball3 int Ball3 int
Ball4 int Ball4 int
Ball5 int Ball5 int
Ball6 int Ball6 int // Only if game type requires
// Optional bonus balls
Bonus1 *int Bonus1 *int
Bonus2 *int Bonus2 *int
PurchaseMethod string PurchaseMethod string
PurchaseDate string PurchaseDate string // TODO: convert to time.Time
ImagePath string ImagePath string
Duplicate bool Duplicate bool // Calculated during insert
MatchedMain int MatchedMain int
MatchedBonus int MatchedBonus int
PrizeTier string PrizeTier string
IsWinner bool IsWinner bool
// Used only for display these are not stored in the DB, they mirror MatchTicket structure but are populated on read. // Non-DB display helpers populated in read model
Balls []int Balls []int
BonusBalls []int BonusBalls []int
MatchedDraw DrawResult MatchedDraw DrawResult

View File

@@ -13,25 +13,3 @@ type User struct {
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }
// ToDo: should be in a notification model?
type Notification struct {
ID int
UserId int
Subject string
Body string
IsRead bool
CreatedAt time.Time
}
// ToDo: should be in a message model?
type Message struct {
ID int
SenderId int
RecipientId int
Subject string
Message string
IsRead bool
CreatedAt time.Time
ArchivedAt *time.Time
}

View File

@@ -58,8 +58,12 @@ import (
"net/http" "net/http"
"time" "time"
domainMsgs "synlotto-website/internal/domain/messages"
domainNotifs "synlotto-website/internal/domain/notifications"
weberr "synlotto-website/internal/http/error" weberr "synlotto-website/internal/http/error"
databasePlatform "synlotto-website/internal/platform/database" databasePlatform "synlotto-website/internal/platform/database"
messagesvc "synlotto-website/internal/platform/services/messages"
notifysvc "synlotto-website/internal/platform/services/notifications"
"synlotto-website/internal/platform/config" "synlotto-website/internal/platform/config"
"synlotto-website/internal/platform/csrf" "synlotto-website/internal/platform/csrf"
@@ -78,6 +82,11 @@ type App struct {
Router *gin.Engine Router *gin.Engine
Handler http.Handler Handler http.Handler
Server *http.Server Server *http.Server
Services struct {
Messages domainMsgs.MessageService
Notifications domainNotifs.NotificationService
}
} }
func Load(configPath string) (*App, error) { func Load(configPath string) (*App, error) {
@@ -119,6 +128,9 @@ func Load(configPath string) (*App, error) {
Router: router, Router: router,
} }
app.Services.Messages = messagesvc.New(db)
app.Services.Notifications = notifysvc.New(db)
// Inject *App into Gin context for handler access // Inject *App into Gin context for handler access
router.Use(func(c *gin.Context) { router.Use(func(c *gin.Context) {
c.Set("app", app) c.Set("app", app)
@@ -139,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
@@ -161,7 +173,7 @@ func openMySQL(cfg config.Config) (*sql.DB, error) {
escapedPass, escapedPass,
dbCfg.Server, dbCfg.Server,
dbCfg.Port, dbCfg.Port,
dbCfg.DatabaseNamed, dbCfg.DatabaseName,
) )
db, err := sql.Open("mysql", dsn) db, err := sql.Open("mysql", dsn)

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 {
@@ -52,9 +54,10 @@ type Config struct {
// HTTP server exposure and security toggles // HTTP server exposure and security toggles
HttpServer struct { HttpServer 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

@@ -0,0 +1,301 @@
// Package messagesvc
// Path: /internal/platform/services/messages
// File: service.go
package messagesvc
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"synlotto-website/internal/logging"
domain "synlotto-website/internal/domain/messages"
"github.com/go-sql-driver/mysql"
)
// Service implements domain.Service.
type Service struct {
DB *sql.DB
Dialect string // "postgres", "mysql", "sqlite"
Now func() time.Time
Timeout time.Duration
}
func New(db *sql.DB, opts ...func(*Service)) *Service {
s := &Service{
DB: db,
Dialect: "mysql", // default; works with LastInsertId
Now: time.Now,
Timeout: 5 * time.Second,
}
for _, opt := range opts {
opt(s)
}
return s
}
// Ensure *Service satisfies the domain interface.
var _ domain.MessageService = (*Service)(nil)
func (s *Service) ListInbox(userID int64) ([]domain.Message, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
q := `
SELECT id, senderId, recipientId, subject, body, is_read, is_archived, created_at
FROM user_messages
WHERE recipientId = ? AND is_archived = FALSE
ORDER BY created_at DESC`
q = s.bind(q)
rows, err := s.DB.QueryContext(ctx, q, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []domain.Message
for rows.Next() {
var m domain.Message
if err := rows.Scan(&m.ID, &m.SenderId, &m.RecipientId, &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) ([]domain.Message, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
q := `
SELECT id, senderId, recipientId, subject, body,
is_read, is_archived, created_at, archived_at
FROM user_messages
WHERE recipientId = ? AND is_archived = TRUE
ORDER BY created_at DESC`
q = s.bind(q)
rows, err := s.DB.QueryContext(ctx, q, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []domain.Message
for rows.Next() {
var m domain.Message
var archived sql.NullTime
if err := rows.Scan(
&m.ID,
&m.SenderId,
&m.RecipientId,
&m.Subject,
&m.Body,
&m.IsRead,
&m.IsArchived,
&m.CreatedAt,
&archived,
); err != nil {
return nil, err
}
if archived.Valid {
t := archived.Time
m.ArchivedAt = &t
} else {
m.ArchivedAt = nil
}
out = append(out, m)
}
return out, rows.Err()
}
func (s *Service) GetByID(userID, id int64) (*domain.Message, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
q := `
SELECT id, senderId, recipientId, subject, body, is_read, is_archived, created_at
FROM user_messages
WHERE recipientId = ? AND id = ?`
q = s.bind(q)
var m domain.Message
err := s.DB.QueryRowContext(ctx, q, userID, id).
Scan(&m.ID, &m.SenderId, &m.RecipientId, &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(senderID int64, in domain.CreateMessageInput) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
// ✅ make sure this matches your current table/column names
const q = `
INSERT INTO user_messages
(senderId, recipientId, subject, body, is_read, is_archived, created_at)
VALUES
(?, ?, ?, ?, 0, 0, CURRENT_TIMESTAMP)
`
// 👀 Log the SQL and arguments (truncate body in logs if you prefer)
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))
res, err := s.DB.ExecContext(ctx, q, senderID, in.RecipientID, in.Subject, in.Body)
if err != nil {
// 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
}
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
}
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)
}
func (s *Service) bind(q string) string {
if s.Dialect != "postgres" {
return q
}
n := 0
out := make([]byte, 0, len(q)+8)
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) 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"
}
var b [12]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
func (s *Service) Unarchive(userID, id int64) error {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
q := `
UPDATE user_messages
SET is_archived = 0, archived_at = NULL
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 (s *Service) MarkRead(userID, id int64) error {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
q := `
UPDATE user_messages
SET is_read = 1
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
}

View File

@@ -0,0 +1,85 @@
// Package notifysvc
// Path: /internal/platform/services/notifications
// File: service.go
// ToDo: carve out sql
package notifysvc
import (
"context"
"database/sql"
"errors"
"time"
domain "synlotto-website/internal/domain/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.
// ToDo:table is users_notification, where as messages is plural, this table seems oto use user_id reather than userId need to unify. Do i want to prefix with users/user
func (s *Service) List(userID int64) ([]domain.Notification, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
const q = `
SELECT id, title, body, is_read, created_at
FROM users_notification
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 []domain.Notification
for rows.Next() {
var n domain.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) (*domain.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 userId = ? AND id = ?`
var n domain.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
}

View File

@@ -1,5 +1,6 @@
package services package services
// ToDo: these aren't really "services"
import ( import (
"database/sql" "database/sql"
"log" "log"

View File

@@ -32,6 +32,10 @@ func RunTicketMatching(db *sql.DB, triggeredBy string) (models.MatchRunStats, er
var pending []models.Ticket var pending []models.Ticket
for rows.Next() { for rows.Next() {
var t models.Ticket var t models.Ticket
var drawDateStr string
if dt, err := helpers.ParseDrawDate(drawDateStr); err == nil {
t.DrawDate = dt
}
var b1, b2, b3, b4, b5, b6, bo1, bo2 sql.NullInt64 var b1, b2, b3, b4, b5, b6, bo1, bo2 sql.NullInt64
if err := rows.Scan( if err := rows.Scan(
&t.Id, &t.GameType, &t.DrawDate, &t.Id, &t.GameType, &t.DrawDate,
@@ -58,7 +62,7 @@ func RunTicketMatching(db *sql.DB, triggeredBy string) (models.MatchRunStats, er
BonusBalls: helpers.BuildBonusSlice(t), BonusBalls: helpers.BuildBonusSlice(t),
} }
draw := drawsSvc.GetDrawResultForTicket(db, t.GameType, t.DrawDate) draw := drawsSvc.GetDrawResultForTicket(db, t.GameType, helpers.FormatDrawDate(t.DrawDate))
if draw.DrawID == 0 { if draw.DrawID == 0 {
// No draw yet → skip // No draw yet → skip
continue continue

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 ?
@@ -36,7 +36,7 @@ func GetRecentMessages(db *sql.DB, userID int, limit int) []models.Message {
&m.SenderId, &m.SenderId,
&m.RecipientId, &m.RecipientId,
&m.Subject, &m.Subject,
&m.Message, &m.Body,
&m.IsRead, &m.IsRead,
&m.CreatedAt, &m.CreatedAt,
) )
@@ -49,13 +49,13 @@ 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)
var m models.Message var m models.Message
err := row.Scan(&m.ID, &m.SenderId, &m.RecipientId, &m.Subject, &m.Message, &m.IsRead, &m.CreatedAt) err := row.Scan(&m.ID, &m.SenderId, &m.RecipientId, &m.Subject, &m.Body, &m.IsRead, &m.CreatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -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 ?
@@ -81,7 +81,7 @@ func GetArchivedMessages(db *sql.DB, userID int, page, perPage int) []models.Mes
var m models.Message var m models.Message
err := rows.Scan( err := rows.Scan(
&m.ID, &m.SenderId, &m.RecipientId, &m.ID, &m.SenderId, &m.RecipientId,
&m.Subject, &m.Message, &m.IsRead, &m.Subject, &m.Body, &m.IsRead,
&m.CreatedAt, &m.ArchivedAt, &m.CreatedAt, &m.ArchivedAt,
) )
if err == nil { if err == nil {
@@ -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 ?
@@ -110,7 +110,7 @@ func GetInboxMessages(db *sql.DB, userID int, page, perPage int) []models.Messag
var m models.Message var m models.Message
err := rows.Scan( err := rows.Scan(
&m.ID, &m.SenderId, &m.RecipientId, &m.ID, &m.SenderId, &m.RecipientId,
&m.Subject, &m.Message, &m.IsRead, &m.CreatedAt, &m.Subject, &m.Body, &m.IsRead, &m.CreatedAt,
) )
if err == nil { if err == nil {
messages = append(messages, m) messages = append(messages, m)
@@ -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

@@ -29,7 +29,7 @@ CREATE TABLE audit_registration (
ON DELETE CASCADE ON DELETE CASCADE
); );
-- THUNDERBALL RESULTS -- THUNDERBALL RESULTS // ToDo: Ballset should be a TINYINT
CREATE TABLE IF NOT EXISTS results_thunderball ( CREATE TABLE IF NOT EXISTS results_thunderball (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
draw_date DATE NOT NULL UNIQUE, draw_date DATE NOT NULL UNIQUE,
@@ -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

@@ -16,7 +16,7 @@ func GetNotificationByID(db *sql.DB, userID, notificationID int) (*models.Notifi
`, notificationID, userID) `, notificationID, userID)
var n models.Notification var n models.Notification
err := row.Scan(&n.ID, &n.UserId, &n.Subject, &n.Body, &n.IsRead) err := row.Scan(&n.ID, &n.UserId, &n.Title, &n.Body, &n.IsRead)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -27,7 +27,7 @@ func GetNotificationCount(db *sql.DB, userID int) int {
var count int var count int
err := db.QueryRow(` err := db.QueryRow(`
SELECT COUNT(*) FROM users_notification SELECT COUNT(*) FROM users_notification
WHERE user_id = ? AND is_read = FALSE`, userID).Scan(&count) WHERE user_Id = ? AND is_read = FALSE`, userID).Scan(&count)
if err != nil { if err != nil {
log.Println("⚠️ Failed to count notifications:", err) log.Println("⚠️ Failed to count notifications:", err)
@@ -41,7 +41,7 @@ func GetRecentNotifications(db *sql.DB, userID int, limit int) []models.Notifica
rows, err := db.Query(` rows, err := db.Query(`
SELECT id, subject, body, is_read, created_at SELECT id, subject, body, is_read, created_at
FROM users_notification FROM users_notification
WHERE user_id = ? WHERE user_Id = ?
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ?`, userID, limit) LIMIT ?`, userID, limit)
if err != nil { if err != nil {
@@ -54,7 +54,7 @@ func GetRecentNotifications(db *sql.DB, userID int, limit int) []models.Notifica
for rows.Next() { for rows.Next() {
var n models.Notification var n models.Notification
if err := rows.Scan(&n.ID, &n.Subject, &n.Body, &n.IsRead, &n.CreatedAt); err == nil { if err := rows.Scan(&n.ID, &n.Title, &n.Body, &n.IsRead, &n.CreatedAt); err == nil {
notifications = append(notifications, n) notifications = append(notifications, n)
} }
} }

View File

@@ -7,7 +7,6 @@ import (
"synlotto-website/internal/models" "synlotto-website/internal/models"
) )
// ToDo: Has both insert and select need to break into read and write.
func InsertTicket(db *sql.DB, ticket models.Ticket) error { func InsertTicket(db *sql.DB, ticket models.Ticket) error {
var bonus1Val interface{} var bonus1Val interface{}
var bonus2Val interface{} var bonus2Val interface{}
@@ -24,14 +23,18 @@ func InsertTicket(db *sql.DB, ticket models.Ticket) error {
bonus2Val = nil bonus2Val = nil
} }
query := ` // Use NULL-safe equality <=> for possible NULLs
SELECT COUNT(*) FROM my_tickets const dupQuery = `
WHERE game_type = ? AND draw_date = ? SELECT COUNT(*) FROM my_tickets
AND ball1 = ? AND ball2 = ? AND ball3 = ? WHERE game_type = ?
AND ball4 = ? AND ball5 = ? AND bonus1 IS ? AND bonus2 IS ?;` AND draw_date = ?
AND ball1 = ? AND ball2 = ? AND ball3 = ?
AND ball4 = ? AND ball5 = ?
AND bonus1 <=> ? AND bonus2 <=> ?;
`
var count int var count int
err := db.QueryRow(query, if err := db.QueryRow(dupQuery,
ticket.GameType, ticket.GameType,
ticket.DrawDate, ticket.DrawDate,
ticket.Ball1, ticket.Ball1,
@@ -41,30 +44,30 @@ func InsertTicket(db *sql.DB, ticket models.Ticket) error {
ticket.Ball5, ticket.Ball5,
bonus1Val, bonus1Val,
bonus2Val, bonus2Val,
).Scan(&count) ).Scan(&count); err != nil {
return err
}
isDuplicate := count > 0 isDuplicate := count > 0
insert := ` const insert = `
INSERT INTO my_tickets ( INSERT INTO my_tickets (
game_type, draw_date, game_type, draw_date,
ball1, ball2, ball3, ball4, ball5, ball1, ball2, ball3, ball4, ball5,
bonus1, bonus2, duplicate bonus1, bonus2, duplicate
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);` ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
`
_, err = db.Exec(insert, _, err := db.Exec(insert,
ticket.GameType, ticket.DrawDate, ticket.GameType, ticket.DrawDate,
ticket.Ball1, ticket.Ball2, ticket.Ball3, ticket.Ball1, ticket.Ball2, ticket.Ball3,
ticket.Ball4, ticket.Ball5, ticket.Ball4, ticket.Ball5,
bonus1Val, bonus2Val, bonus1Val, bonus2Val,
isDuplicate, isDuplicate,
) )
if err != nil { if err != nil {
log.Println("❌ Failed to insert ticket:", err) log.Println("❌ Failed to insert ticket:", err)
} else if isDuplicate { } else if isDuplicate {
log.Println("⚠️ Duplicate ticket detected and flagged.") log.Println("⚠️ Duplicate ticket detected and flagged.")
} }
return err return err
} }

View File

@@ -7,38 +7,45 @@
<div class="card mb-3"> <div class="card mb-3">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">{{ .Subject }}</h5> <h5 class="card-title">{{ .Subject }}</h5>
<p class="card-text">{{ .Message }}</p> <p class="card-text">{{ .Body }}</p>
<p class="card-text"> <p class="card-text">
<small class="text-muted">Archived: {{ .ArchivedAt.Format "02 Jan 2006 15:04" }}</small> <small class="text-muted">
Archived:
{{ with .ArchivedAt }}
{{ .Format "02 Jan 2006 15:04" }}
{{ else }}
{{ end }}
</small>
</p> </p>
<form method="POST" action="/account/messages/restore" class="m-0">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<input type="hidden" name="id" value="{{ .ID }}">
<button type="submit" class="btn btn-sm btn-outline-success">Restore</button>
</form>
</div> </div>
</div> </div>
<form method="POST" action="/account/messages/restore" class="m-0">
{{ $.CSRFField }}
<input type="hidden" name="id" value="{{ .ID }}">
<button type="submit" class="btn btn-sm btn-outline-success">Restore</button>
</form>
{{ end }} {{ end }}
<!-- Pagination Controls --> <!-- Pagination Controls (keep if your funcs exist) -->
<nav> <nav>
<ul class="pagination"> <ul class="pagination">
{{ if gt .Page 1 }} {{ if gt .CurrentPage 1 }}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="?page={{ minus1 .Page }}">Previous</a> <a class="page-link" href="?page={{ sub .CurrentPage 1 }}">Previous</a>
</li> </li>
{{ end }} {{ end }}
{{ if .HasMore }} {{ if lt .CurrentPage .TotalPages }}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="?page={{ plus1 .Page }}">Next</a> <a class="page-link" href="?page={{ add .CurrentPage 1 }}">Next</a>
</li> </li>
{{ end }} {{ end }}
</ul> </ul>
</nav> </nav>
{{ else }} {{ else }}
<div class="alert alert-info text-center">No archived messages.</div> <div class="alert alert-info text-center">No archived messages.</div>
{{ end }} {{ end }}
<a href="/account/messages" class="btn btn-secondary mt-3">Back to Inbox</a> <a href="/account/messages" class="btn btn-secondary mt-3">Back to Inbox</a>
</div> </div>
{{ end }} {{ end }}

View File

@@ -1,55 +1,139 @@
{{ define "content" }} {{ define "content" }}
<!-- Todo lists messages but doesn't show which ones have been read and unread-->
<div class="container py-5"> <div class="container py-5">
<h2>Your Inbox</h2> <h2>Your Inbox</h2>
{{ if .Messages }} {{ if .Messages }}
<ul class="list-group mb-4"> <ul class="list-group mb-4">
{{ 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 {{ if .IsRead }}read{{ end }}" data-msg-id="{{ .ID }}">
<div> <div>
<a href="/account/messages/read?id={{ .ID }}" class="fw-bold text-dark">{{ .Subject }}</a> <a href="/account/messages/read?id={{ .ID }}" class="fw-bold text-dark">{{ .Subject }}</a><br>
<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?id={{ .ID }}" class="m-0">
{{ $.CSRFField }}
<input type="hidden" name="id" value="{{ .ID }}">
<button type="submit" class="btn btn-sm btn-outline-secondary">Archive</button>
</form>
</li>
{{ end }}
</ul>
<!-- Pagination -->
<nav>
<ul class="pagination">
{{ if gt .CurrentPage 1 }}
<li class="page-item">
<a class="page-link" href="?page={{ sub .CurrentPage 1 }}">Previous</a>
</li>
{{ end }}
{{ range $i := .PageRange }} <div class="d-flex gap-2 align-items-center">
<li class="page-item {{ if eq $i $.CurrentPage }}active{{ end }}">
<a class="page-link" href="?page={{ $i }}">{{ $i }}</a>
</li>
{{ end }}
{{ if lt .CurrentPage .TotalPages }} {{/* Archive form (existing) */}}
<li class="page-item"> <form method="POST" action="/account/messages/archive" class="m-0">
<a class="page-link" href="?page={{ add .CurrentPage 1 }}">Next</a> <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
</li> <input type="hidden" name="id" value="{{ .ID }}">
{{ end }} <button type="submit" class="btn btn-sm btn-outline-secondary">Archive</button>
</ul> </form>
</nav>
{{/* Mark-read: only show when unread */}}
{{ if not .IsRead }}
<!-- Non-AJAX fallback form (submit will refresh) -->
<form method="POST" action="/account/messages/mark-read" class="m-0 d-inline-block mark-read-form">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<input type="hidden" name="id" value="{{ .ID }}">
<button type="submit" class="btn btn-sm btn-outline-primary mark-read-btn"
data-msg-id="{{ .ID }}"
data-csrf="{{ $.CSRFToken }}">Mark read</button>
</form>
{{ end }}
</div>
</li>
{{ end }}
</ul>
<nav>
<ul class="pagination">
{{ if gt .CurrentPage 1 }}
<li class="page-item">
<a class="page-link" href="?page={{ sub .CurrentPage 1 }}">Previous</a>
</li>
{{ end }}
{{ range $i := .PageRange }}
<li class="page-item {{ if eq $i $.CurrentPage }}active{{ end }}">
<a class="page-link" href="?page={{ $i }}">{{ $i }}</a>
</li>
{{ end }}
{{ if lt .CurrentPage .TotalPages }}
<li class="page-item">
<a class="page-link" href="?page={{ add .CurrentPage 1 }}">Next</a>
</li>
{{ end }}
</ul>
</nav>
{{ else }} {{ else }}
<div class="alert alert-info">No messages found.</div> <div class="alert alert-info text-center">No messages found.</div>
{{ end }} {{ end }}
<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>
{{/* AJAX enhancement: unobtrusive — safe fallback to regular form when JS disabled */}}
<script>
;(function(){
// Ensure browser supports fetch + FormData; otherwise we fallback to regular form submit.
if (!window.fetch || !window.FormData) return;
// Helper to decrement topbar message count badge (assumes badge element id="message-count")
function decrementMessageCount() {
var el = document.getElementById('message-count');
if (!el) return;
var current = parseInt(el.textContent || el.innerText || '0', 10) || 0;
var next = Math.max(0, current - 1);
if (next <= 0) {
// remove badge or hide it
el.remove();
} else {
el.textContent = String(next);
}
}
// Handle clicks on mark-read buttons, submit via fetch, update DOM
document.addEventListener('click', function(e){
var btn = e.target.closest('.mark-read-btn');
if (!btn) return;
// Prevent the default form POST (non-AJAX fallback)
e.preventDefault();
var msgID = btn.dataset.msgId;
var csrf = btn.dataset.csrf;
if (!msgID) {
// fallback to normal submit if something's wrong
var frm = btn.closest('form');
if (frm) frm.submit();
return;
}
// Build urlencoded body like a regular form
var body = new URLSearchParams();
body.append('id', msgID);
if (csrf) body.append('csrf_token', csrf);
fetch('/account/messages/mark-read', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: body.toString(),
credentials: 'same-origin'
}).then(function(resp){
if (resp.ok) {
// UI update: remove the mark-read button, give item a .read class, update topbar count
var li = document.querySelector('li[data-msg-id="' + msgID + '"]');
if (li) {
li.classList.add('read');
// remove any mark-read form/button inside
var form = li.querySelector('.mark-read-form');
if (form) form.remove();
}
decrementMessageCount();
} else {
// If server returned non-2xx, fall back to full reload to show flash
resp.text().then(function(){ window.location.reload(); }).catch(function(){ window.location.reload(); });
}
}).catch(function(){ window.location.reload(); });
}, false);
})();
</script>
{{ end }} {{ end }}

View File

@@ -4,12 +4,65 @@
<h2>{{ .Message.Subject }}</h2> <h2>{{ .Message.Subject }}</h2>
<p class="text-muted">Received: {{ .Message.CreatedAt.Format "02 Jan 2006 15:04" }}</p> <p class="text-muted">Received: {{ .Message.CreatedAt.Format "02 Jan 2006 15:04" }}</p>
<hr> <hr>
<p>{{ .Message.Message }}</p> <p>{{ .Message.Body }}</p>
<a href="/account/messages" class="btn btn-secondary mt-4">Back to Inbox</a> <a href="/account/messages/archive?id={{ .Message.ID }}" class="btn btn-outline-danger mt-3">Archive</a>
<div class="mt-4">
<button id="mark-read-btn" data-id="{{ .Message.ID }}" class="btn btn-outline-success">Mark As Read</button>
<form method="POST" action="/account/messages/archive" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<input type="hidden" name="id" value="{{ .Message.ID }}">
<button type="submit" class="btn btn-outline-danger">Archive</button>
</form>
<a href="/account/messages" class="btn btn-secondary">Back to Inbox</a>
</div>
{{ else }} {{ else }}
<div class="alert alert-danger text-center"> <div class="alert alert-danger text-center">
Message not found or access denied. Message not found or access denied.
</div> </div>
{{ end }} {{ end }}
</div> </div>
<script>
document.addEventListener("DOMContentLoaded", function () {
const btn = document.getElementById("mark-read-btn");
if (!btn) return;
btn.addEventListener("click", async function () {
const id = this.dataset.id;
const res = await fetch("/account/messages/mark-read", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: new URLSearchParams({
id: id,
csrf_token: "{{ $.CSRFToken }}"
})
});
if (res.ok) {
this.classList.remove("btn-outline-success");
this.classList.add("btn-success");
this.textContent = "Marked As Read ✔";
const badge = document.getElementById("message-count");
if (badge) {
let count = parseInt(badge.textContent);
if (!isNaN(count)) {
count = Math.max(count - 1, 0);
if (count === 0) {
badge.remove();
} else {
badge.textContent = count;
}
}
}
} else {
alert("Failed to mark as read.");
}
});
});
</script>
{{ end }} {{ end }}

View File

@@ -1,27 +1,35 @@
{{ define "content" }} {{ define "content" }}
<div class="container py-5"> <div class="container py-5">
<h2>Send a Message</h2> <h2>Send a Message</h2>
{{ if .Flash }} {{ if .Flash }}
<div class="alert alert-info">{{ .Flash }}</div> <div class="alert alert-info">{{ .Flash }}</div>
{{ end }} {{ end }}
{{ if .Error }}
<div class="alert alert-danger">{{ .Error }}</div>
{{ end }}
<form method="POST" action="/account/messages/send"> <form method="POST" action="/account/messages/send">
{{ .CSRFField }} <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
<div class="mb-3"> <div class="mb-3">
<label for="recipient_id" class="form-label">Recipient User ID</label> <label for="recipientId" class="form-label">Recipient User ID</label>
<input type="number" class="form-control" name="recipient_id" required> <input type="number" class="form-control" name="recipientId" value="{{ with .Form }}{{ .RecipientID }}{{ end }}" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="subject" class="form-label">Subject</label> <label for="subject" class="form-label">Subject</label>
<input type="text" class="form-control" name="subject" required> <input type="text" class="form-control" name="subject" value="{{ with .Form }}{{ .Subject }}{{ end }}" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="message" class="form-label">Message</label> <label for="body" class="form-label">Message</label>
<textarea class="form-control" name="message" rows="5" required></textarea> <textarea class="form-control" name="body" rows="5" required>{{ with .Form }}{{ .Body }}{{ end }}</textarea>
</div> </div>
<button type="submit" class="btn btn-primary">Send</button> <button type="submit" class="btn btn-primary">Send</button>
</form> </form>
<a href="/account/messages" class="btn btn-secondary mt-3">Back to Inbox</a> <a href="/account/messages" class="btn btn-secondary mt-3">Back to Inbox</a>
</div> </div>
{{ end }} {{ end }}

View File

@@ -3,7 +3,7 @@
<h2>Log My Ticket</h2> <h2>Log My Ticket</h2>
<form method="POST" action="/account/tickets/add_ticket" enctype="multipart/form-data" id="ticketForm"> <form method="POST" action="/account/tickets/add_ticket" enctype="multipart/form-data" id="ticketForm">
{{ .csrfField }} <input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
<div class="form-section"> <div class="form-section">
<label>Game: <label>Game:
@@ -49,7 +49,7 @@
</div> </div>
<div id="ticketLinesContainer"> <div id="ticketLinesContainer">
<!-- JS will insert ticket lines here --> <!-- todo, maybe ajax so it doesnt refresh?-->
</div> </div>
<div class="form-section"> <div class="form-section">

View File

@@ -12,6 +12,8 @@
| <a href="/legal/privacy">Privacy Policy</a> | | <a href="/legal/privacy">Privacy Policy</a> |
<a href="/legal/terms">Terms & Conditions</a> | <a href="/legal/terms">Terms & Conditions</a> |
<a href="/contact">Contact Us</a> <a href="/contact">Contact Us</a>
<br>
</small> </small>
</footer> </footer>
{{ end }} {{ end }}

View File

@@ -31,7 +31,8 @@
aria-expanded="false"> aria-expanded="false">
<i class="bi bi-bell fs-5 position-relative"> <i class="bi bi-bell fs-5 position-relative">
{{ if gt .NotificationCount 0 }} {{ if gt .NotificationCount 0 }}
<span class="position-absolute top-0 start-0 translate-middle badge rounded-pill bg-warning text-dark badge-small"> <span id="notification-count"
class="position-absolute top-0 start-0 translate-middle badge rounded-pill bg-warning text-dark badge-small">
{{ if gt .NotificationCount 15 }}15+{{ else }}{{ .NotificationCount }}{{ end }} {{ if gt .NotificationCount 15 }}15+{{ else }}{{ .NotificationCount }}{{ end }}
</span> </span>
{{ end }} {{ end }}
@@ -41,7 +42,6 @@
aria-labelledby="notificationDropdown"> aria-labelledby="notificationDropdown">
<li class="dropdown-header text-center fw-bold">Notifications</li> <li class="dropdown-header text-center fw-bold">Notifications</li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
{{ $total := len .Notifications }} {{ $total := len .Notifications }}
{{ range $i, $n := .Notifications }} {{ range $i, $n := .Notifications }}
<li class="px-3 py-2"> <li class="px-3 py-2">
@@ -55,15 +55,11 @@
</div> </div>
</a> </a>
</li> </li>
{{ if lt (add $i 1) $total }} {{ if lt (add $i 1) $total }}<li><hr class="dropdown-divider"></li>{{ end }}
<li><hr class="dropdown-divider"></li>
{{ end }} {{ end }}
{{ end }}
{{ if not .Notifications }} {{ if not .Notifications }}
<li class="text-center text-muted py-2">No notifications</li> <li class="text-center text-muted py-2">No notifications</li>
{{ end }} {{ end }}
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li class="text-center"><a href="/account/notifications" class="dropdown-item">View all notifications</a></li> <li class="text-center"><a href="/account/notifications" class="dropdown-item">View all notifications</a></li>
</ul> </ul>
@@ -75,7 +71,8 @@
aria-expanded="false"> aria-expanded="false">
<i class="bi bi-envelope fs-5 position-relative"> <i class="bi bi-envelope fs-5 position-relative">
{{ if gt .MessageCount 0 }} {{ if gt .MessageCount 0 }}
<span class="position-absolute top-0 start-0 translate-middle badge rounded-pill bg-danger text-dark badge-small"> <span id="message-count"
class="position-absolute top-0 start-0 translate-middle badge rounded-pill bg-danger text-dark badge-small">
{{ if gt .MessageCount 15 }}15+{{ else }}{{ .MessageCount }}{{ end }} {{ if gt .MessageCount 15 }}15+{{ else }}{{ .MessageCount }}{{ end }}
</span> </span>
{{ end }} {{ end }}
@@ -85,7 +82,6 @@
aria-labelledby="messageDropdown"> aria-labelledby="messageDropdown">
<li class="dropdown-header text-center fw-bold">Messages</li> <li class="dropdown-header text-center fw-bold">Messages</li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
{{ if .Messages }} {{ if .Messages }}
{{ range $i, $m := .Messages }} {{ range $i, $m := .Messages }}
<li class="px-3 py-2"> <li class="px-3 py-2">
@@ -94,7 +90,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>
@@ -103,15 +99,15 @@
{{ else }} {{ else }}
<li class="text-center text-muted py-2">No messages</li> <li class="text-center text-muted py-2">No messages</li>
{{ end }} {{ end }}
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li class="text-center"><a href="/account/messages" class="dropdown-item">View all messages</a></li> <li class="text-center"><a href="/account/messages" class="dropdown-item">View all messages</a></li>
</ul> </ul>
</div> </div>
<!-- User Greeting/Dropdown --> <!-- User Dropdown -->
<div class="dropdown"> <div class="dropdown">
<a class="nav-link dropdown-toggle text-dark" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false"> <a class="nav-link dropdown-toggle text-dark" href="#" id="userDropdown" role="button"
data-bs-toggle="dropdown" aria-expanded="false">
Hello, {{ .User.Username }} Hello, {{ .User.Username }}
</a> </a>
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="userDropdown"> <ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="userDropdown">

View File

@@ -1,18 +1,39 @@
{{ define "content" }} {{ define "content" }}
<div class="wrap"> <div class="wrap">
<h1>Thunderball Statistics</h1> <h1>Thunderball Statistics</h1>
<p>
Discover everything you need to supercharge your Thunderball picks! Explore which numbers have been drawn the most, which ones are the rarest,
and even which lucky pairs and triplets keep showing up again and again. You can also check out which numbers are long overdue for a win perfect
for anyone playing the waiting game!
</p>
<div class="grid"> <p>
<div class="card"> All statistics are based on every draw from <i><b>9th May 2010</b></i> right through to (since {{.LastDraw}}), covering the current ball pool of 139.
<h3>Top 5 (since {{.Since}})</h3> </p>
<table>
<thead><tr><th>Number</th><th>Frequency</th></tr></thead> <p>
<tbody> Feeling curious about the earlier era of Thunderball draws? Dive into the historical Thunderball statistics to uncover data from before 9th May 2010, back when the ball pool ranged from 134.
{{range .TopSince}} </p>
<tr><td>{{.Number}}</td><td>{{.Frequency}}</td></tr> <div class="grid">
{{end}} <div class="card">
</tbody> <h3>Top 5 (since {{.Since}})</h3>
</table> <table>
</div> <thead>
<tr>
<th>Number</th>
<th>Frequency</th>
</tr>
</thead>
<tbody>
{{range .TopSince}}
<tr>
<td>{{.Number}}</td>
<td>{{.Frequency}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div> </div>
{{end}} {{end}}