Compare commits
5 Commits
244b882f11
...
eba25a4fb5
| Author | SHA1 | Date | |
|---|---|---|---|
| eba25a4fb5 | |||
| e6654fc1b4 | |||
| ddafdd0468 | |||
| 5fcb4fb016 | |||
| 71c8d4d06c |
152
internal/handlers/account/tickets/add.go
Normal file
152
internal/handlers/account/tickets/add.go
Normal 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
|
||||||
|
}
|
||||||
69
internal/handlers/account/tickets/list.go
Normal file
69
internal/handlers/account/tickets/list.go
Normal 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)
|
||||||
|
}
|
||||||
39
internal/handlers/account/tickets/types.go
Normal file
39
internal/handlers/account/tickets/types.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user