diff --git a/internal/handlers/account/tickets/add.go b/internal/handlers/account/tickets/add.go new file mode 100644 index 0000000..84d0dcf --- /dev/null +++ b/internal/handlers/account/tickets/add.go @@ -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 +} diff --git a/internal/handlers/account/tickets/list.go b/internal/handlers/account/tickets/list.go new file mode 100644 index 0000000..506fd5e --- /dev/null +++ b/internal/handlers/account/tickets/list.go @@ -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) +} diff --git a/internal/handlers/account/tickets/types.go b/internal/handlers/account/tickets/types.go new file mode 100644 index 0000000..bc002bb --- /dev/null +++ b/internal/handlers/account/tickets/types.go @@ -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 + 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 +} diff --git a/internal/http/routes/accountroutes.go b/internal/http/routes/accountroutes.go index 049ca49..56882c3 100644 --- a/internal/http/routes/accountroutes.go +++ b/internal/http/routes/accountroutes.go @@ -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 + } }