Compare commits

...

5 Commits

12 changed files with 440 additions and 122 deletions

View File

@@ -0,0 +1,26 @@
// Package accountMessageHandler
// Path: /internal/handlers/account/messages
// File: archive.go
package accountMessageHandler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// GET /account/messages/archived
// Renders: web/templates/account/messages/archived.html
func (h *AccountMessageHandlers) ArchivedList(c *gin.Context) {
userID := mustUserID(c)
msgs, err := h.Svc.ListArchived(userID)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to load archived messages"})
return
}
c.HTML(http.StatusOK, "account/messages/archived.html", gin.H{
"title": "Archived Messages",
"messages": msgs,
})
}

View File

@@ -1,98 +1,14 @@
// Package accountMessageHandler
// Path: /internal/handlers/account/messages
// File: list.go
// ToDo: helpers for reading getting messages shouldn't really be here. ---
package accountMessageHandler package accountMessageHandler
import ( import (
"net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// GET /account/messages
// Renders: web/templates/account/messages/index.html
func (h *AccountMessageHandlers) List(c *gin.Context) {
userID := mustUserID(c) // replace with your auth/user extraction
msgs, err := h.Svc.ListInbox(userID)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to load messages"})
return
}
c.HTML(http.StatusOK, "account/messages/index.html", gin.H{
"title": "Messages",
"messages": msgs,
})
}
// GET /account/messages/add
// Renders: web/templates/account/messages/send.html
func (h *AccountMessageHandlers) AddGet(c *gin.Context) {
c.HTML(http.StatusOK, "account/messages/send.html", gin.H{
"title": "Send Message",
})
}
// POST /account/messages/add
func (h *AccountMessageHandlers) AddPost(c *gin.Context) {
userID := mustUserID(c)
var in CreateMessageInput
if err := c.ShouldBind(&in); err != nil {
// Re-render form with validation errors
c.HTML(http.StatusBadRequest, "account/messages/send.html", gin.H{
"title": "Send Message",
"error": "Please correct the errors below.",
"form": in,
})
return
}
if _, err := h.Svc.Create(userID, in); err != nil {
c.HTML(http.StatusInternalServerError, "account/messages/send.html", gin.H{
"title": "Send Message",
"error": "Could not send message.",
"form": in,
})
return
}
// Redirect back to inbox
c.Redirect(http.StatusSeeOther, "/account/messages")
}
// --- Optional extras since you have read.html and archived.html ---
// GET /account/messages/:id
// Renders: web/templates/account/messages/read.html
func (h *AccountMessageHandlers) ReadGet(c *gin.Context) {
userID := mustUserID(c)
id, err := parseIDParam(c, "id")
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
msg, err := h.Svc.GetByID(userID, id)
if err != nil || msg == nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
c.HTML(http.StatusOK, "account/messages/read.html", gin.H{
"title": msg.Subject,
"message": msg,
})
}
// GET /account/messages/archived
// Renders: web/templates/account/messages/archived.html
func (h *AccountMessageHandlers) ArchivedList(c *gin.Context) {
userID := mustUserID(c)
msgs, err := h.Svc.ListArchived(userID)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to load archived messages"})
return
}
c.HTML(http.StatusOK, "account/messages/archived.html", gin.H{
"title": "Archived Messages",
"messages": msgs,
})
}
// --- helpers ---
func mustUserID(c *gin.Context) int64 { func mustUserID(c *gin.Context) int64 {
// Pull from your auth middleware/session. Panic-unsafe alternative: // Pull from your auth middleware/session. Panic-unsafe alternative:
if v, ok := c.Get("userID"); ok { if v, ok := c.Get("userID"); ok {

View File

@@ -0,0 +1,46 @@
// Package accountMessageHandler
// Path: /internal/handlers/account/messages
// File: read.go
package accountMessageHandler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// GET /account/messages
// Renders: web/templates/account/messages/index.html
func (h *AccountMessageHandlers) List(c *gin.Context) {
userID := mustUserID(c)
msgs, err := h.Svc.ListInbox(userID)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to load messages"})
return
}
c.HTML(http.StatusOK, "account/messages/index.html", gin.H{
"title": "Messages",
"messages": msgs,
})
}
// GET /account/messages/:id
// Renders: web/templates/account/messages/read.html
func (h *AccountMessageHandlers) ReadGet(c *gin.Context) {
userID := mustUserID(c)
id, err := parseIDParam(c, "id")
if err != nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
msg, err := h.Svc.GetByID(userID, id)
if err != nil || msg == nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
c.HTML(http.StatusOK, "account/messages/read.html", gin.H{
"title": msg.Subject,
"message": msg,
})
}

View File

@@ -0,0 +1,44 @@
// Package accountMessageHandler
// Path: /internal/handlers/account/messages
// File: send.go
package accountMessageHandler
import (
"net/http"
"github.com/gin-gonic/gin"
)
// GET /account/messages/add
// Renders: web/templates/account/messages/send.html
func (h *AccountMessageHandlers) AddGet(c *gin.Context) {
c.HTML(http.StatusOK, "account/messages/send.html", gin.H{
"title": "Send Message",
})
}
// POST /account/messages/add
func (h *AccountMessageHandlers) AddPost(c *gin.Context) {
userID := mustUserID(c)
var in CreateMessageInput
if err := c.ShouldBind(&in); err != nil {
// Re-render form with validation errors
c.HTML(http.StatusBadRequest, "account/messages/send.html", gin.H{
"title": "Send Message",
"error": "Please correct the errors below.",
"form": in,
})
return
}
if _, err := h.Svc.Create(userID, in); err != nil {
c.HTML(http.StatusInternalServerError, "account/messages/send.html", gin.H{
"title": "Send Message",
"error": "Could not send message.",
"form": in,
})
return
}
// Redirect back to inbox
c.Redirect(http.StatusSeeOther, "/account/messages")
}

View File

@@ -1,3 +1,7 @@
// Package accountMessageHandler
// Path: /internal/handlers/account/messages
// File: types.go
package accountMessageHandler package accountMessageHandler
import "time" import "time"

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

@@ -21,10 +21,10 @@
package routes package routes
import ( import (
accountHandlers "synlotto-website/internal/handlers/account" accountHandler "synlotto-website/internal/handlers/account"
accountMessageHandlers "synlotto-website/internal/handlers/account/messages" accoutMessageHandler "synlotto-website/internal/handlers/account/messages"
accountNotificationHandlers "synlotto-website/internal/handlers/account/notifications" accountNotificationHandler "synlotto-website/internal/handlers/account/notifications"
accountTicketHandlers "synlotto-website/internal/handlers/account/tickets" 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"
@@ -37,45 +37,45 @@ func RegisterAccountRoutes(app *bootstrap.App) {
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)
} }
// Auth-required account actions // 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.POST("/logout", accountHandler.Logout)
accAuth.GET("/logout", accountHandlers.Logout) // optional accAuth.GET("/logout", accountHandler.Logout) // optional
} }
// Messages (auth-required) // Messages (auth-required)
messages := r.Group("/account/messages") messages := r.Group("/account/messages")
messages.Use(middleware.AuthMiddleware(), middleware.RequireAuth()) messages.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
{ {
messages.GET("/", accountMessageHandlers.List) messages.GET("/", accoutMessageHandler.List)
messages.GET("/add", accountMessageHandlers.AddGet) messages.GET("/add", accoutMessageHandler.AddGet)
messages.POST("/add", accountMessageHandlers.AddPost) messages.POST("/add", accoutMessageHandler.AddPost)
messages.GET("/archived", accountMessageHandlers.ArchivedList) // renders archived.html messages.GET("/archived", accoutMessageHandler.ArchivedList) // renders archived.html
messages.GET("/:id", accountMessageHandlers.ReadGet) // renders read.html messages.GET("/:id", accoutMessageHandler.ReadGet) // renders read.html
} }
// Notifications (auth-required) // Notifications (auth-required)
notifications := r.Group("/account/notifications") notifications := r.Group("/account/notifications")
notifications.Use(middleware.AuthMiddleware(), middleware.RequireAuth()) notifications.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
{ {
notifications.GET("/", accountNotificationHandlers.List) notifications.GET("/", accountNotificationHandler.List)
notifications.GET("/:id", accountNotificationHandlers.ReadGet) // renders read.html notifications.GET("/:id", accountNotificationHandler.ReadGet) // renders read.html
} }
// Tickets (auth-required) // Tickets (auth-required)
tickets := r.Group("/account/tickets") tickets := r.Group("/account/tickets")
tickets.Use(middleware.AuthMiddleware(), middleware.RequireAuth()) tickets.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
{ {
tickets.GET("/", accountTicketHandlers.List) // GET /account/tickets tickets.GET("/", accountTicketHandler.List) // GET /account/tickets
tickets.GET("/add", accountTicketHandlers.AddGet) // GET /account/tickets/add tickets.GET("/add", accountTicketHandler.AddGet) // GET /account/tickets/add
tickets.POST("/add", accountTicketHandlers.AddPost) // POST /account/tickets/add tickets.POST("/add", accountTicketHandler.AddPost) // POST /account/tickets/add
} }
} }

View File

@@ -0,0 +1,177 @@
// ToDo: not currently used and need to carve out sql
package messagesvc
import (
"context"
"database/sql"
"errors"
"time"
accountMessageHandler "synlotto-website/internal/handlers/account/messages"
)
// Service implements accountMessageHandler.MessageService.
type Service struct {
DB *sql.DB
Dialect string // "postgres", "mysql", "sqlite" (affects INSERT id retrieval)
Now func() time.Time
Timeout time.Duration
}
func New(db *sql.DB, opts ...func(*Service)) *Service {
s := &Service{
DB: db,
Dialect: "mysql", // sane default for LastInsertId (works for mysql/sqlite)
Now: time.Now,
Timeout: 5 * time.Second,
}
for _, opt := range opts {
opt(s)
}
return s
}
// WithDialect sets SQL dialect hints: "postgres" uses RETURNING id.
func WithDialect(d string) func(*Service) { return func(s *Service) { s.Dialect = d } }
// WithTimeout overrides per-call context timeout.
func WithTimeout(d time.Duration) func(*Service) { return func(s *Service) { s.Timeout = d } }
func (s *Service) ListInbox(userID int64) ([]accountMessageHandler.Message, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
const q = `
SELECT id, from_email, to_email, subject, body, is_read, is_archived, created_at
FROM messages
WHERE user_id = ? AND is_archived = FALSE
ORDER BY created_at DESC`
rows, err := s.DB.QueryContext(s.rebind(ctx), s.bind(q), userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []accountMessageHandler.Message
for rows.Next() {
var m accountMessageHandler.Message
if err := rows.Scan(&m.ID, &m.From, &m.To, &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) ([]accountMessageHandler.Message, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
const q = `
SELECT id, from_email, to_email, subject, body, is_read, is_archived, created_at
FROM messages
WHERE user_id = ? AND is_archived = TRUE
ORDER BY created_at DESC`
rows, err := s.DB.QueryContext(s.rebind(ctx), s.bind(q), userID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []accountMessageHandler.Message
for rows.Next() {
var m accountMessageHandler.Message
if err := rows.Scan(&m.ID, &m.From, &m.To, &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) GetByID(userID, id int64) (*accountMessageHandler.Message, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
const q = `
SELECT id, from_email, to_email, subject, body, is_read, is_archived, created_at
FROM messages
WHERE user_id = ? AND id = ?`
var m accountMessageHandler.Message
err := s.DB.QueryRowContext(s.rebind(ctx), s.bind(q), userID, id).
Scan(&m.ID, &m.From, &m.To, &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(userID int64, in accountMessageHandler.CreateMessageInput) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
defer cancel()
switch s.Dialect {
case "postgres":
const q = `
INSERT INTO messages (user_id, from_email, to_email, subject, body, is_read, is_archived, created_at)
VALUES ($1, $2, $3, $4, $5, FALSE, FALSE, NOW())
RETURNING id`
var id int64
if err := s.DB.QueryRowContext(ctx, q, userID, "", in.To, in.Subject, in.Body).Scan(&id); err != nil {
return 0, err
}
return id, nil
default: // mysql/sqlite
const q = `
INSERT INTO messages (user_id, from_email, to_email, subject, body, is_read, is_archived, created_at)
VALUES (?, ?, ?, ?, ?, FALSE, FALSE, CURRENT_TIMESTAMP)`
res, err := s.DB.ExecContext(s.rebind(ctx), q, userID, "", in.To, in.Subject, in.Body)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
}
// --- small helpers ---
// bind replaces ? with $1.. for Postgres if needed.
// We keep queries written with ? for brevity and adapt here.
func (s *Service) bind(q string) string {
if s.Dialect != "postgres" {
return q
}
// cheap replacer for positional params:
n := 0
out := make([]byte, 0, len(q)+10)
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) rebind(ctx context.Context) context.Context { return ctx }
// intToStr avoids fmt for tiny helper
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:])
}

View File

@@ -0,0 +1,80 @@
// ToDo: carve out sql
package notifysvc
import (
"context"
"database/sql"
"errors"
"time"
accountNotificationHandler "synlotto-website/internal/handlers/account/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.
func (s *Service) List(userID int64) ([]accountNotificationHandler.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 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 []accountNotificationHandler.Notification
for rows.Next() {
var n accountNotificationHandler.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) (*accountNotificationHandler.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 user_id = ? AND id = ?`
var n accountNotificationHandler.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

@@ -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>
The content and operations of this website have not been approved or endorsed by {{ $lotteryOperator }} or the {{ $commisionName }}.
</small> </small>
</footer> </footer>
{{ end }} {{ end }}

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>
<p>
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.
</p>
<p>
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.
</p>
<div class="grid"> <div class="grid">
<div class="card"> <div class="card">
<h3>Top 5 (since {{.Since}})</h3> <h3>Top 5 (since {{.Since}})</h3>
<table> <table>
<thead><tr><th>Number</th><th>Frequency</th></tr></thead> <thead>
<tr>
<th>Number</th>
<th>Frequency</th>
</tr>
</thead>
<tbody> <tbody>
{{range .TopSince}} {{range .TopSince}}
<tr><td>{{.Number}}</td><td>{{.Frequency}}</td></tr> <tr>
<td>{{.Number}}</td>
<td>{{.Frequency}}</td>
</tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div>
{{end}} {{end}}