User specific lottery ticket creation

This commit is contained in:
2025-10-29 09:47:35 +00:00
parent ddafdd0468
commit e6654fc1b4
4 changed files with 296 additions and 3 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
import (
accountHandlers "synlotto-website/internal/handlers/account"
accountTicketHandlers "synlotto-website/internal/handlers/account/tickets"
"synlotto-website/internal/http/middleware"
"synlotto-website/internal/platform/bootstrap"
@@ -10,6 +31,7 @@ import (
func RegisterAccountRoutes(app *bootstrap.App) {
r := app.Router
// Public account pages
acc := r.Group("/account")
acc.Use(middleware.PublicOnly())
{
@@ -19,9 +41,20 @@ func RegisterAccountRoutes(app *bootstrap.App) {
acc.POST("/signup", accountHandlers.SignupPost)
}
// Protected logout
// Auth-required account actions
accAuth := r.Group("/account")
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
}
}