Compare commits

...

5 Commits

7 changed files with 353 additions and 36 deletions

View File

@@ -0,0 +1,152 @@
// 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"
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) {
c.HTML(http.StatusOK, "account/tickets/add_ticket.html", gin.H{
"title": "Add Ticket",
"csrfToken": nosurf.Token(c.Request),
})
}
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 tickets
WHERE user_id = ?
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

@@ -1,7 +1,28 @@
// 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" accountHandlers "synlotto-website/internal/handlers/account"
accountTicketHandlers "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,6 +31,7 @@ import (
func RegisterAccountRoutes(app *bootstrap.App) { func RegisterAccountRoutes(app *bootstrap.App) {
r := app.Router r := app.Router
// Public account pages
acc := r.Group("/account") acc := r.Group("/account")
acc.Use(middleware.PublicOnly()) acc.Use(middleware.PublicOnly())
{ {
@@ -19,9 +41,20 @@ func RegisterAccountRoutes(app *bootstrap.App) {
acc.POST("/signup", accountHandlers.SignupPost) acc.POST("/signup", accountHandlers.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", accountHandlers.Logout)
accAuth.GET("/logout", accountHandlers.Logout) // optional
}
// Tickets (auth-required)
tickets := r.Group("/account/tickets")
tickets.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
{
tickets.GET("/", accountTicketHandlers.List) // GET /account/tickets
tickets.GET("/add", accountTicketHandlers.AddGet) // GET /account/tickets/add
tickets.POST("/add", accountTicketHandlers.AddPost) // POST /account/tickets/add
}
} }

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

@@ -161,7 +161,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

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