Refactor and remove sqlite and replace with MySQL

This commit is contained in:
2025-10-23 18:43:31 +01:00
parent d53e27eea8
commit 21ebc9c34b
139 changed files with 1013 additions and 529 deletions

View File

@@ -0,0 +1,141 @@
package handlers
import (
"database/sql"
"log"
"net/http"
"time"
httpHelpers "synlotto-website/helpers/http"
securityHelpers "synlotto-website/helpers/security"
templateHelpers "synlotto-website/helpers/template"
"synlotto-website/logging"
"synlotto-website/models"
"synlotto-website/storage"
"github.com/gorilla/csrf"
)
func Login(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
session, _ := httpHelpers.GetSession(w, r)
if _, ok := session.Values["user_id"].(int); ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
tmpl := templateHelpers.LoadTemplateFiles("login.html", "templates/account/login.html")
data := models.TemplateData{}
context := templateHelpers.TemplateContext(w, r, data)
context["csrfField"] = csrf.TemplateField(r)
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
logging.Info("❌ Template render error:", err)
http.Error(w, "Error rendering login page", http.StatusInternalServerError)
}
return
}
username := r.FormValue("username")
password := r.FormValue("password")
// ToDo: this outputs password in clear text remove or obscure!
logging.Info("🔐 Login attempt - Username: %s, Password: %s", username, password)
user := storage.GetUserByUsername(db, username)
if user == nil {
logging.Info("❌ User not found: %s", username)
storage.LogLoginAttempt(r, username, false)
session, _ := httpHelpers.GetSession(w, r)
session.Values["flash"] = "Invalid username or password."
session.Save(r, w)
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
return
}
if !securityHelpers.CheckPasswordHash(user.PasswordHash, password) {
logging.Info("❌ Password mismatch for user: %s", username)
storage.LogLoginAttempt(r, username, false)
session, _ := httpHelpers.GetSession(w, r)
session.Values["flash"] = "Invalid username or password."
session.Save(r, w)
log.Printf("login has did it")
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
return
}
logging.Info("✅ Login successful for user: %s", username)
storage.LogLoginAttempt(r, username, true)
session, _ := httpHelpers.GetSession(w, r)
for k := range session.Values {
delete(session.Values, k)
}
session.Values["user_id"] = user.Id
session.Values["last_activity"] = time.Now().UTC()
if r.FormValue("remember") == "on" {
session.Options.MaxAge = 60 * 60 * 24 * 30
} else {
session.Options.MaxAge = 0
}
if err := session.Save(r, w); err != nil {
logging.Info("❌ Failed to save session: %v", err)
} else {
logging.Info("✅ Session saved for user: %s", username)
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
}
func Logout(w http.ResponseWriter, r *http.Request) {
session, _ := httpHelpers.GetSession(w, r)
for k := range session.Values {
delete(session.Values, k)
}
session.Values["flash"] = "You've been logged out."
session.Options.MaxAge = 5
err := session.Save(r, w)
if err != nil {
logging.Error("❌ Logout session save failed:", err)
}
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
}
func Signup(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
tmpl := templateHelpers.LoadTemplateFiles("signup.html", "templates/account/signup.html")
tmpl.ExecuteTemplate(w, "layout", map[string]interface{}{
"csrfField": csrf.TemplateField(r),
})
return
}
username := r.FormValue("username")
password := r.FormValue("password")
hashed, err := securityHelpers.HashPassword(password)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
err = models.CreateUser(username, hashed)
if err != nil {
http.Error(w, "Could not create user", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
}

View File

@@ -0,0 +1,96 @@
package handlers
import (
"database/sql"
"log"
"net/http"
templateHelpers "synlotto-website/helpers/template"
"synlotto-website/middleware"
"synlotto-website/models"
)
type AdminLogEntry struct {
AccessedAt string
UserID int
Path string
IP string
UserAgent string
}
func AdminAccessLogHandler(db *sql.DB) http.HandlerFunc {
return middleware.Auth(true)(func(w http.ResponseWriter, r *http.Request) {
data := models.TemplateData{}
context := templateHelpers.TemplateContext(w, r, data)
rows, err := db.Query(`
SELECT accessed_at, user_id, path, ip, user_agent
FROM admin_access_log
ORDER BY accessed_at DESC
LIMIT 100
`)
if err != nil {
log.Println("⚠️ Failed to load admin access logs:", err)
http.Error(w, "Error loading logs", http.StatusInternalServerError)
return
}
defer rows.Close()
var logs []AdminLogEntry // ToDo should be in models
for rows.Next() {
var entry AdminLogEntry
if err := rows.Scan(&entry.AccessedAt, &entry.UserID, &entry.Path, &entry.IP, &entry.UserAgent); err != nil {
log.Println("⚠️ Scan failed:", err)
continue
}
logs = append(logs, entry)
}
context["AuditLogs"] = logs
tmpl := templateHelpers.LoadTemplateFiles("access_log.html", "templates/admin/logs/access_log.html")
_ = tmpl.ExecuteTemplate(w, "layout", context)
})
}
func AuditLogHandler(db *sql.DB) http.HandlerFunc {
return middleware.Auth(true)(func(w http.ResponseWriter, r *http.Request) {
data := models.TemplateData{}
context := templateHelpers.TemplateContext(w, r, data)
rows, err := db.Query(`
SELECT timestamp, user_id, action, ip, user_agent
FROM audit_log
ORDER BY timestamp DESC
LIMIT 100
`)
if err != nil {
log.Println("❌ Failed to load audit log:", err)
http.Error(w, "Could not load audit log", http.StatusInternalServerError)
return
}
defer rows.Close()
var logs []models.AuditEntry
for rows.Next() {
var entry models.AuditEntry
err := rows.Scan(&entry.Timestamp, &entry.UserID, &entry.Action, &entry.IP, &entry.UserAgent)
if err != nil {
log.Println("⚠️ Failed to scan row:", err)
continue
}
logs = append(logs, entry)
}
context["AuditLogs"] = logs
tmpl := templateHelpers.LoadTemplateFiles("audit.html", "templates/admin/logs/audit.html")
err = tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
log.Println("❌ Failed to render audit page:", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
})
}

View File

@@ -0,0 +1,76 @@
package handlers
import (
"database/sql"
"log"
"net/http"
httpHelpers "synlotto-website/helpers/http"
securityHelpers "synlotto-website/helpers/security"
templateHelpers "synlotto-website/helpers/template"
"synlotto-website/models"
"synlotto-website/storage"
)
var (
total, winners int
prizeSum float64
)
func AdminDashboardHandler(db *sql.DB) http.HandlerFunc {
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r)
if !ok {
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
return
}
user := storage.GetUserByID(db, userID)
if user == nil {
http.Error(w, "User not found", http.StatusUnauthorized)
return
}
data := models.TemplateData{}
context := templateHelpers.TemplateContext(w, r, data)
context["User"] = user
context["IsAdmin"] = user.IsAdmin
// Missing messages, notifications, potentially syndicate notifictions if that becomes a new top bar icon.
db.QueryRow(`SELECT COUNT(*), SUM(CASE WHEN is_winner THEN 1 ELSE 0 END), SUM(prize_amount) FROM my_tickets`).Scan(&total, &winners, &prizeSum)
context["Stats"] = map[string]interface{}{
"TotalTickets": total,
"TotalWinners": winners,
"TotalPrizeAmount": prizeSum,
}
rows, err := db.Query(`
SELECT run_at, triggered_by, tickets_matched, winners_found, COALESCE(notes, '')
FROM log_ticket_matching
ORDER BY run_at DESC LIMIT 10
`)
if err != nil {
log.Println("⚠️ Failed to load logs:", err)
}
defer rows.Close()
var logs []models.MatchLog
for rows.Next() {
var logEntry models.MatchLog
err := rows.Scan(&logEntry.RunAt, &logEntry.TriggeredBy, &logEntry.TicketsMatched, &logEntry.WinnersFound, &logEntry.Notes)
if err != nil {
log.Println("⚠️ Failed to scan log row:", err)
continue
}
logs = append(logs, logEntry)
}
context["MatchLogs"] = logs
tmpl := templateHelpers.LoadTemplateFiles("dashboard.html", "templates/admin/dashboard.html")
err = tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
http.Error(w, "Failed to render dashboard", http.StatusInternalServerError)
}
})
}

View File

@@ -0,0 +1,111 @@
package handlers
import (
"database/sql"
"log"
"net/http"
httpHelpers "synlotto-website/helpers/http"
templateHelpers "synlotto-website/helpers/template"
"synlotto-website/models"
)
func NewDrawHandler(db *sql.DB) http.HandlerFunc {
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
data := models.TemplateData{}
context := templateHelpers.TemplateContext(w, r, data)
if r.Method == http.MethodPost {
game := r.FormValue("game_type")
date := r.FormValue("draw_date")
machine := r.FormValue("machine")
ballset := r.FormValue("ball_set")
_, err := db.Exec(`INSERT INTO results_thunderball (game_type, draw_date, machine, ball_set) VALUES (?, ?, ?, ?)`,
game, date, machine, ballset)
if err != nil {
http.Error(w, "Failed to add draw", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
return
}
tmpl := templateHelpers.LoadTemplateFiles("new_draw", "templates/admin/draws/new_draw.html")
tmpl.ExecuteTemplate(w, "layout", context)
})
}
func ModifyDrawHandler(db *sql.DB) http.HandlerFunc {
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
id := r.FormValue("id")
_, err := db.Exec(`UPDATE results_thunderball SET game_type=?, draw_date=?, ball_set=?, machine=? WHERE id=?`,
r.FormValue("game_type"), r.FormValue("draw_date"), r.FormValue("ball_set"), r.FormValue("machine"), id)
if err != nil {
http.Error(w, "Update failed", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
return
}
// For GET: load draw by ID (pseudo-code)
// id := r.URL.Query().Get("id")
// query DB, pass into context.Draw
})
}
func DeleteDrawHandler(db *sql.DB) http.HandlerFunc {
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
id := r.FormValue("id")
_, err := db.Exec(`DELETE FROM results_thunderball WHERE id = ?`, id)
if err != nil {
http.Error(w, "Delete failed", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/dashboard", http.StatusSeeOther)
return
}
})
}
func ListDrawsHandler(db *sql.DB) http.HandlerFunc {
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
data := models.TemplateData{}
context := templateHelpers.TemplateContext(w, r, data)
draws := []models.DrawSummary{}
rows, err := db.Query(`
SELECT r.id, r.game_type, r.draw_date, r.ball_set, r.machine,
(SELECT COUNT(1) FROM prizes_thunderball p WHERE p.draw_date = r.draw_date) as prize_exists
FROM results_thunderball r
ORDER BY r.draw_date DESC
`)
if err != nil {
http.Error(w, "Failed to query draws", http.StatusInternalServerError)
return
}
defer rows.Close()
for rows.Next() {
var d models.DrawSummary
var prizeFlag int
if err := rows.Scan(&d.Id, &d.GameType, &d.DrawDate, &d.BallSet, &d.Machine, &prizeFlag); err != nil {
log.Println("⚠️ Draw scan failed:", err)
continue
}
d.PrizeSet = prizeFlag > 0
draws = append(draws, d)
}
context["Draws"] = draws
tmpl := templateHelpers.LoadTemplateFiles("list.html", "templates/admin/draws/list.html")
tmpl.ExecuteTemplate(w, "layout", context)
})
}

View File

@@ -0,0 +1,130 @@
package handlers
import (
"database/sql"
"fmt"
"log"
"net/http"
"net/url"
"strconv"
templateHelpers "synlotto-website/helpers/template"
services "synlotto-website/services/tickets"
"synlotto-website/models"
)
func AdminTriggersHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := models.TemplateData{}
context := templateHelpers.TemplateContext(w, r, data)
if flash := r.URL.Query().Get("flash"); flash != "" {
context["Flash"] = flash
}
if r.Method == http.MethodPost {
action := r.FormValue("action")
flashMsg := ""
switch action {
case "match":
stats, err := services.RunTicketMatching(db, "manual")
if err != nil {
http.Error(w, "Matching failed: "+err.Error(), http.StatusInternalServerError)
return
}
flashMsg = fmt.Sprintf("✅ Matched %d tickets, %d winners.", stats.TicketsMatched, stats.WinnersFound)
case "prizes":
err := services.UpdateMissingPrizes(db)
if err != nil {
http.Error(w, "Prize update failed: "+err.Error(), http.StatusInternalServerError)
return
}
flashMsg = "✅ Missing prizes updated."
case "refresh_prizes":
err := services.RefreshTicketPrizes(db)
if err != nil {
http.Error(w, "Refresh failed: "+err.Error(), http.StatusInternalServerError)
return
}
flashMsg = "✅ Ticket prizes refreshed."
case "run_all":
stats, err := services.RunTicketMatching(db, "manual")
if err != nil {
http.Error(w, "Matching failed: "+err.Error(), http.StatusInternalServerError)
return
}
err = services.UpdateMissingPrizes(db)
if err != nil {
http.Error(w, "Prize update failed: "+err.Error(), http.StatusInternalServerError)
return
}
flashMsg = fmt.Sprintf("✅ Matched %d tickets, %d winners. Prizes updated.", stats.TicketsMatched, stats.WinnersFound)
default:
flashMsg = "⚠️ Unknown action."
}
http.Redirect(w, r, "/admin/triggers?flash="+url.QueryEscape(flashMsg), http.StatusSeeOther)
return
}
tmpl := templateHelpers.LoadTemplateFiles("triggers.html", "templates/admin/triggers.html")
err := tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
log.Println("Template error:", err)
http.Error(w, "Failed to load page", http.StatusInternalServerError)
}
}
}
func MatchTicketsHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
stats, err := services.RunTicketMatching(db, "manual")
if err != nil {
http.Error(w, "Matching failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/triggers?flash=Matched "+
strconv.Itoa(stats.TicketsMatched)+" tickets, "+
strconv.Itoa(stats.WinnersFound)+" winners.", http.StatusSeeOther)
}
}
func UpdateMissingPrizesHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
err := services.UpdateMissingPrizes(db)
if err != nil {
http.Error(w, "Prize update failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/triggers?flash=Updated missing prize data.", http.StatusSeeOther)
}
}
func RunAllHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
stats, err := services.RunTicketMatching(db, "manual")
if err != nil {
http.Error(w, "Matching failed: "+err.Error(), http.StatusInternalServerError)
return
}
err = services.UpdateMissingPrizes(db)
if err != nil {
http.Error(w, "Prize update failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/triggers?flash=Matched "+
strconv.Itoa(stats.TicketsMatched)+" tickets, "+
strconv.Itoa(stats.WinnersFound)+" winners. Prizes updated.", http.StatusSeeOther)
}
}

View File

@@ -0,0 +1,70 @@
package handlers
import (
"database/sql"
"fmt"
"net/http"
"strconv"
httpHelpers "synlotto-website/helpers/http"
templateHelpers "synlotto-website/helpers/template"
"synlotto-website/models"
)
func AddPrizesHandler(db *sql.DB) http.HandlerFunc {
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
data := models.TemplateData{}
if r.Method == http.MethodGet {
tmpl := templateHelpers.LoadTemplateFiles("add_prizes.html", "templates/admin/draws/prizes/add_prizes.html")
tmpl.ExecuteTemplate(w, "layout", templateHelpers.TemplateContext(w, r, data))
return
}
drawDate := r.FormValue("draw_date")
values := make([]interface{}, 0)
for i := 1; i <= 9; i++ {
val, _ := strconv.Atoi(r.FormValue(fmt.Sprintf("prize%d_per_winner", i)))
values = append(values, val)
}
stmt := `INSERT INTO prizes_thunderball (
draw_date, prize1_per_winner, prize2_per_winner, prize3_per_winner,
prize4_per_winner, prize5_per_winner, prize6_per_winner,
prize7_per_winner, prize8_per_winner, prize9_per_winner
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := db.Exec(stmt, append([]interface{}{drawDate}, values...)...)
if err != nil {
http.Error(w, "Insert failed: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin/draws", http.StatusSeeOther)
})
}
func ModifyPrizesHandler(db *sql.DB) http.HandlerFunc {
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
data := models.TemplateData{}
if r.Method == http.MethodGet {
tmpl := templateHelpers.LoadTemplateFiles("modify_prizes.html", "templates/admin/draws/prizes/modify_prizes.html")
tmpl.ExecuteTemplate(w, "layout", templateHelpers.TemplateContext(w, r, data))
return
}
drawDate := r.FormValue("draw_date")
for i := 1; i <= 9; i++ {
key := fmt.Sprintf("prize%d_per_winner", i)
val, _ := strconv.Atoi(r.FormValue(key))
_, err := db.Exec("UPDATE prizes_thunderball SET "+key+" = ? WHERE draw_date = ?", val, drawDate)
if err != nil {
http.Error(w, "Update failed: "+err.Error(), http.StatusInternalServerError)
return
}
}
http.Redirect(w, r, "/admin/draws", http.StatusSeeOther)
})
}

View File

@@ -0,0 +1,8 @@
package handlers
import (
"synlotto-website/models"
)
var Draws []models.ThunderballResult
var MyTickets []models.Ticket

25
internal/handlers/home.go Normal file
View File

@@ -0,0 +1,25 @@
package handlers
import (
"database/sql"
"log"
"net/http"
templateHandlers "synlotto-website/handlers/template"
templateHelpers "synlotto-website/helpers/template"
)
func Home(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := templateHandlers.BuildTemplateData(db, w, r)
context := templateHelpers.TemplateContext(w, r, data)
tmpl := templateHelpers.LoadTemplateFiles("index.html", "templates/index.html")
err := tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
log.Println("❌ Template render error:", err)
http.Error(w, "Error rendering homepage", http.StatusInternalServerError)
}
}
}

View File

@@ -0,0 +1,60 @@
package handlers
import (
"database/sql"
"log"
"net/http"
templateHelpers "synlotto-website/helpers/template"
"synlotto-website/helpers"
"synlotto-website/models"
"synlotto-website/storage"
)
func NewDraw(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := models.TemplateData{}
context := templateHelpers.TemplateContext(w, r, data)
context["Page"] = "new_draw"
context["Data"] = nil
tmpl := templateHelpers.LoadTemplateFiles("new_draw.html", "templates/admin/draws/new_draw.html") // ToDo: may need removing or moving add draw should be admin functionality and only when manually required. Potential live drawing of numbers in the future.
err := tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
log.Println("❌ Template error:", err)
http.Error(w, "Error rendering form", http.StatusInternalServerError)
}
}
}
func Submit(db *sql.DB, w http.ResponseWriter, r *http.Request) {
log.Println("📝 Form submission received")
_ = r.ParseForm()
draw := models.ThunderballResult{
DrawDate: r.FormValue("date"),
Machine: r.FormValue("machine"),
BallSet: helpers.Atoi(r.FormValue("ballSet")),
Ball1: helpers.Atoi(r.FormValue("ball1")),
Ball2: helpers.Atoi(r.FormValue("ball2")),
Ball3: helpers.Atoi(r.FormValue("ball3")),
Ball4: helpers.Atoi(r.FormValue("ball4")),
Ball5: helpers.Atoi(r.FormValue("ball5")),
Thunderball: helpers.Atoi(r.FormValue("thunderball")),
}
err := storage.InsertThunderballResult(db, draw)
if err != nil {
log.Println("❌ Failed to insert draw:", err)
http.Error(w, "Failed to save draw", http.StatusInternalServerError)
return
}
log.Printf("📅 %s | 🛠 %s | 🎱 %d | 🔢 %d,%d,%d,%d,%d | ⚡ %d\n",
draw.DrawDate, draw.Machine, draw.BallSet,
draw.Ball1, draw.Ball2, draw.Ball3, draw.Ball4, draw.Ball5, draw.Thunderball)
http.Redirect(w, r, "/", http.StatusSeeOther)
}

View File

@@ -0,0 +1,203 @@
package handlers
import (
"database/sql"
"fmt"
"log"
"net/http"
templateHandlers "synlotto-website/handlers/template"
securityHelpers "synlotto-website/helpers/security"
templateHelpers "synlotto-website/helpers/template"
"synlotto-website/helpers"
"synlotto-website/models"
"synlotto-website/storage"
)
func CreateSyndicateHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
data := templateHandlers.BuildTemplateData(db, w, r)
context := templateHelpers.TemplateContext(w, r, data)
tmpl := templateHelpers.LoadTemplateFiles("create-syndicate.html", "templates/syndicate/create.html")
tmpl.ExecuteTemplate(w, "layout", context)
case http.MethodPost:
name := r.FormValue("name")
description := r.FormValue("description")
userId, ok := securityHelpers.GetCurrentUserID(r)
if !ok || name == "" {
templateHelpers.SetFlash(w, r, "Invalid data submitted")
http.Redirect(w, r, "/syndicate/create", http.StatusSeeOther)
return
}
_, err := storage.CreateSyndicate(db, userId, name, description)
if err != nil {
log.Printf("❌ CreateSyndicate failed: %v", err)
templateHelpers.SetFlash(w, r, "Failed to create syndicate")
} else {
templateHelpers.SetFlash(w, r, "Syndicate created successfully")
}
http.Redirect(w, r, "/syndicate", http.StatusSeeOther)
default:
templateHelpers.RenderError(w, r, http.StatusMethodNotAllowed)
}
}
}
func ListSyndicatesHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r)
if !ok {
templateHelpers.RenderError(w, r, 403) // ToDo need to make this use the handler so i dont need to define errors.
return
}
managed := storage.GetSyndicatesByOwner(db, userID)
member := storage.GetSyndicatesByMember(db, userID)
managedMap := make(map[int]bool)
for _, s := range managed {
managedMap[s.ID] = true
}
var filteredJoined []models.Syndicate
for _, s := range member {
if !managedMap[s.ID] {
filteredJoined = append(filteredJoined, s)
}
}
data := templateHandlers.BuildTemplateData(db, w, r)
context := templateHelpers.TemplateContext(w, r, data)
context["ManagedSyndicates"] = managed
context["JoinedSyndicates"] = filteredJoined
tmpl := templateHelpers.LoadTemplateFiles("syndicates.html", "templates/syndicate/index.html")
tmpl.ExecuteTemplate(w, "layout", context)
}
}
func ViewSyndicateHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r)
if !ok {
templateHelpers.RenderError(w, r, 403)
return
}
syndicateID := helpers.Atoi(r.URL.Query().Get("id"))
syndicate, err := storage.GetSyndicateByID(db, syndicateID)
if err != nil || syndicate == nil {
templateHelpers.RenderError(w, r, 404)
return
}
isManager := userID == syndicate.OwnerID
isMember := storage.IsSyndicateMember(db, syndicateID, userID)
if !isManager && !isMember {
templateHelpers.RenderError(w, r, 403)
return
}
members := storage.GetSyndicateMembers(db, syndicateID)
data := templateHandlers.BuildTemplateData(db, w, r)
context := templateHelpers.TemplateContext(w, r, data)
context["Syndicate"] = syndicate
context["Members"] = members
context["IsManager"] = isManager
tmpl := templateHelpers.LoadTemplateFiles("syndicate-view.html", "templates/syndicate/view.html")
tmpl.ExecuteTemplate(w, "layout", context)
}
}
func SyndicateLogTicketHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r)
if !ok {
templateHelpers.RenderError(w, r, 403)
return
}
syndicateId := helpers.Atoi(r.URL.Query().Get("id"))
syndicate, err := storage.GetSyndicateByID(db, syndicateId)
if err != nil || syndicate.OwnerID != userID {
templateHelpers.RenderError(w, r, 403)
return
}
switch r.Method {
case http.MethodGet:
data := templateHandlers.BuildTemplateData(db, w, r)
context := templateHelpers.TemplateContext(w, r, data)
context["Syndicate"] = syndicate
tmpl := templateHelpers.LoadTemplateFiles("syndicate-log-ticket.html", "templates/syndicate/log_ticket.html")
tmpl.ExecuteTemplate(w, "layout", context)
case http.MethodPost:
gameType := r.FormValue("game_type")
drawDate := r.FormValue("draw_date")
method := r.FormValue("purchase_method")
err := storage.InsertTicket(db, models.Ticket{
UserId: userID,
GameType: gameType,
DrawDate: drawDate,
PurchaseMethod: method,
SyndicateId: &syndicateId,
// ToDo image path
})
if err != nil {
templateHelpers.SetFlash(w, r, "Failed to add ticket.")
} else {
templateHelpers.SetFlash(w, r, "Ticket added for syndicate.")
}
http.Redirect(w, r, fmt.Sprintf("/syndicate/view?id=%d", syndicateId), http.StatusSeeOther)
default:
templateHelpers.RenderError(w, r, 405)
}
}
}
func SyndicateTicketsHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r)
if !ok {
templateHelpers.RenderError(w, r, 403)
return
}
syndicateID := helpers.Atoi(r.URL.Query().Get("id"))
if syndicateID == 0 {
templateHelpers.RenderError(w, r, 400)
return
}
if !storage.IsSyndicateMember(db, syndicateID, userID) {
templateHelpers.RenderError(w, r, 403)
return
}
tickets := storage.GetSyndicateTickets(db, syndicateID)
data := templateHandlers.BuildTemplateData(db, w, r)
context := templateHelpers.TemplateContext(w, r, data)
context["SyndicateID"] = syndicateID
context["Tickets"] = tickets
tmpl := templateHelpers.LoadTemplateFiles("syndicate-tickets.html", "templates/syndicate/tickets.html")
tmpl.ExecuteTemplate(w, "layout", context)
}
}

View File

@@ -0,0 +1,226 @@
package handlers
import (
"database/sql"
"fmt"
"net/http"
"strconv"
"time"
templateHandlers "synlotto-website/handlers/template"
securityHelpers "synlotto-website/helpers/security"
templateHelpers "synlotto-website/helpers/template"
"synlotto-website/helpers"
"synlotto-website/storage"
)
func SyndicateInviteHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r)
if !ok {
templateHelpers.RenderError(w, r, http.StatusForbidden)
return
}
switch r.Method {
case http.MethodGet:
syndicateID := helpers.Atoi(r.URL.Query().Get("id"))
data := templateHandlers.BuildTemplateData(db, w, r)
context := templateHelpers.TemplateContext(w, r, data)
context["SyndicateID"] = syndicateID
tmpl := templateHelpers.LoadTemplateFiles("invite-syndicate.html", "templates/syndicate/invite.html")
err := tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
templateHelpers.RenderError(w, r, 500)
}
case http.MethodPost:
syndicateID := helpers.Atoi(r.FormValue("syndicate_id"))
username := r.FormValue("username")
err := storage.InviteToSyndicate(db, userID, syndicateID, username)
if err != nil {
templateHelpers.SetFlash(w, r, "Failed to send invite: "+err.Error())
} else {
templateHelpers.SetFlash(w, r, "Invite sent successfully.")
}
http.Redirect(w, r, "/syndicate/view?id="+strconv.Itoa(syndicateID), http.StatusSeeOther)
default:
templateHelpers.RenderError(w, r, http.StatusMethodNotAllowed)
}
}
}
func ViewInvitesHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r)
if !ok {
templateHelpers.RenderError(w, r, 403)
return
}
invites := storage.GetPendingInvites(db, userID)
data := templateHandlers.BuildTemplateData(db, w, r)
context := templateHelpers.TemplateContext(w, r, data)
context["Invites"] = invites
tmpl := templateHelpers.LoadTemplateFiles("invites.html", "templates/syndicate/invites.html")
tmpl.ExecuteTemplate(w, "layout", context)
}
}
func AcceptInviteHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
inviteID := helpers.Atoi(r.URL.Query().Get("id"))
userID, ok := securityHelpers.GetCurrentUserID(r)
if !ok {
templateHelpers.RenderError(w, r, 403)
return
}
err := storage.AcceptInvite(db, inviteID, userID)
if err != nil {
templateHelpers.SetFlash(w, r, "Failed to accept invite")
} else {
templateHelpers.SetFlash(w, r, "You have joined the syndicate")
}
http.Redirect(w, r, "/syndicate", http.StatusSeeOther)
}
}
func DeclineInviteHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
inviteID := helpers.Atoi(r.URL.Query().Get("id"))
_ = storage.UpdateInviteStatus(db, inviteID, "declined")
http.Redirect(w, r, "/syndicate/invites", http.StatusSeeOther)
}
}
func CreateInviteToken(db *sql.DB, syndicateID, invitedByID int, ttlHours int) (string, error) {
token, err := securityHelpers.GenerateSecureToken()
if err != nil {
return "", err
}
expires := time.Now().Add(time.Duration(ttlHours) * time.Hour)
_, err = db.Exec(`
INSERT INTO syndicate_invite_tokens (syndicate_id, token, invited_by_user_id, expires_at)
VALUES (?, ?, ?, ?)
`, syndicateID, token, invitedByID, expires)
return token, err
}
func AcceptInviteToken(db *sql.DB, token string, userID int) error {
var syndicateID int
var expiresAt, acceptedAt sql.NullTime
err := db.QueryRow(`
SELECT syndicate_id, expires_at, accepted_at
FROM syndicate_invite_tokens
WHERE token = ?
`, token).Scan(&syndicateID, &expiresAt, &acceptedAt)
if err != nil {
return fmt.Errorf("invalid or expired token")
}
if acceptedAt.Valid || expiresAt.Time.Before(time.Now()) {
return fmt.Errorf("token already used or expired")
}
_, err = db.Exec(`
INSERT INTO syndicate_members (syndicate_id, user_id, role, status, joined_at)
VALUES (?, ?, 'member', 'active', CURRENT_TIMESTAMP)
`, syndicateID, userID)
if err != nil {
return err
}
_, err = db.Exec(`
UPDATE syndicate_invite_tokens
SET accepted_by_user_id = ?, accepted_at = CURRENT_TIMESTAMP
WHERE token = ?
`, userID, token)
return err
}
func GenerateInviteLinkHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r)
if !ok {
templateHelpers.RenderError(w, r, http.StatusForbidden)
return
}
syndicateID := helpers.Atoi(r.URL.Query().Get("id"))
token, err := CreateInviteToken(db, syndicateID, userID, 48)
if err != nil {
templateHelpers.SetFlash(w, r, "Failed to generate invite link.")
http.Redirect(w, r, "/syndicate/view?id="+strconv.Itoa(syndicateID), http.StatusSeeOther)
return
}
origin := r.Host
if r.TLS != nil {
origin = "https://" + origin
} else {
origin = "http://" + origin
}
inviteLink := fmt.Sprintf("%s/syndicate/join?token=%s", origin, token)
templateHelpers.SetFlash(w, r, "Invite link created: "+inviteLink)
http.Redirect(w, r, "/syndicate/view?id="+strconv.Itoa(syndicateID), http.StatusSeeOther)
}
}
func JoinSyndicateWithTokenHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r)
if !ok {
templateHelpers.RenderError(w, r, http.StatusForbidden)
return
}
token := r.URL.Query().Get("token")
if token == "" {
templateHelpers.SetFlash(w, r, "Invalid or missing invite token.")
http.Redirect(w, r, "/syndicate", http.StatusSeeOther)
return
}
err := AcceptInviteToken(db, token, userID)
if err != nil {
templateHelpers.SetFlash(w, r, "Failed to join syndicate: "+err.Error())
} else {
templateHelpers.SetFlash(w, r, "You have joined the syndicate!")
}
http.Redirect(w, r, "/syndicate", http.StatusSeeOther)
}
}
func ManageInviteTokensHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r)
if !ok {
templateHelpers.RenderError(w, r, 403)
return
}
syndicateID := helpers.Atoi(r.URL.Query().Get("id"))
if !storage.IsSyndicateManager(db, syndicateID, userID) {
templateHelpers.RenderError(w, r, 403)
return
}
tokens := storage.GetInviteTokensForSyndicate(db, syndicateID)
data := templateHandlers.BuildTemplateData(db, w, r)
context := templateHelpers.TemplateContext(w, r, data)
context["Tokens"] = tokens
context["SyndicateID"] = syndicateID
tmpl := templateHelpers.LoadTemplateFiles("invite-links.html", "templates/syndicate/invite_links.html")
tmpl.ExecuteTemplate(w, "layout", context)
}
}

View File

@@ -0,0 +1,375 @@
package handlers
import (
"database/sql"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"time"
httpHelpers "synlotto-website/helpers/http"
securityHelpers "synlotto-website/helpers/security"
templateHelpers "synlotto-website/helpers/template"
draws "synlotto-website/services/draws"
"synlotto-website/helpers"
"synlotto-website/models"
"github.com/gorilla/csrf"
)
func AddTicket(db *sql.DB) http.HandlerFunc {
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
rows, err := db.Query(`
SELECT DISTINCT draw_date
FROM results_thunderball
ORDER BY draw_date DESC
`)
if err != nil {
log.Println("❌ Failed to load draw dates:", err)
http.Error(w, "Unable to load draw dates", http.StatusInternalServerError)
return
}
defer rows.Close()
var drawDates []string
for rows.Next() {
var date string
if err := rows.Scan(&date); err == nil {
drawDates = append(drawDates, date)
}
}
data := models.TemplateData{}
context := templateHelpers.TemplateContext(w, r, data)
context["csrfField"] = csrf.TemplateField(r)
context["DrawDates"] = drawDates
tmpl := templateHelpers.LoadTemplateFiles("add_ticket.html", "templates/account/tickets/add_ticket.html")
err = tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
log.Println("❌ Template render error:", err)
http.Error(w, "Error rendering form", http.StatusInternalServerError)
}
return
}
err := r.ParseMultipartForm(10 << 20)
if err != nil {
http.Error(w, "Invalid form", http.StatusBadRequest)
log.Println("❌ Failed to parse form:", err)
return
}
userID, ok := securityHelpers.GetCurrentUserID(r)
if !ok {
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
return
}
game := r.FormValue("game_type")
drawDate := r.FormValue("draw_date")
purchaseMethod := r.FormValue("purchase_method")
purchaseDate := r.FormValue("purchase_date")
purchaseTime := r.FormValue("purchase_time")
if purchaseTime != "" {
purchaseDate += "T" + purchaseTime
}
imagePath := ""
file, handler, err := r.FormFile("ticket_image")
if err == nil && handler != nil {
defer file.Close()
filename := fmt.Sprintf("uploads/ticket_%d_%s", time.Now().UnixNano(), handler.Filename)
out, err := os.Create(filename)
if err == nil {
defer out.Close()
io.Copy(out, file)
imagePath = filename
}
}
var ballCount, bonusCount int
switch game {
case "Thunderball":
ballCount, bonusCount = 5, 1
case "Lotto":
ballCount, bonusCount = 6, 0
case "EuroMillions":
ballCount, bonusCount = 5, 2
case "SetForLife":
ballCount, bonusCount = 5, 1
default:
http.Error(w, "Unsupported game type", http.StatusBadRequest)
return
}
balls := make([][]int, ballCount)
bonuses := make([][]int, bonusCount)
for i := 1; i <= ballCount; i++ {
field := fmt.Sprintf("ball%d[]", i)
balls[i-1] = helpers.ParseIntSlice(r.Form[field])
log.Printf("🔢 %s: %v", field, balls[i-1])
}
for i := 1; i <= bonusCount; i++ {
field := fmt.Sprintf("bonus%d[]", i)
bonuses[i-1] = helpers.ParseIntSlice(r.Form[field])
log.Printf("🎯 %s: %v", field, bonuses[i-1])
}
lineCount := 0
if len(balls) > 0 {
lineCount = len(balls[0])
}
log.Println("🧾 Total lines to insert:", lineCount)
for i := 0; i < lineCount; i++ {
b := make([]int, 6)
bo := make([]int, 2)
valid := true
for j := 0; j < ballCount; j++ {
if j < len(balls) && i < len(balls[j]) {
b[j] = balls[j][i]
if b[j] == 0 {
valid = false
}
}
}
for j := 0; j < bonusCount; j++ {
if j < len(bonuses) && i < len(bonuses[j]) {
bo[j] = bonuses[j][i]
if bo[j] == 0 {
valid = false
}
}
}
if !valid {
log.Printf("⚠️ Skipping invalid line %d (incomplete values)", i+1)
continue
}
_, err := db.Exec(`
INSERT INTO my_tickets (
userId, game_type, draw_date,
ball1, ball2, ball3, ball4, ball5, ball6,
bonus1, bonus2,
purchase_method, purchase_date, image_path
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
userID, game, drawDate,
b[0], b[1], b[2], b[3], b[4], b[5],
bo[0], bo[1],
purchaseMethod, purchaseDate, imagePath,
)
if err != nil {
log.Println("❌ Failed to insert ticket line:", err)
} else {
log.Printf("✅ Ticket line %d saved", i+1) // ToDo create audit
}
}
http.Redirect(w, r, "/tickets", http.StatusSeeOther)
})
}
func SubmitTicket(db *sql.DB) http.HandlerFunc {
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
err := r.ParseMultipartForm(10 << 20)
if err != nil {
http.Error(w, "Invalid form", http.StatusBadRequest)
return
}
userID, ok := securityHelpers.GetCurrentUserID(r)
if !ok {
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
return
}
game := r.FormValue("game_type")
drawDate := r.FormValue("draw_date")
purchaseMethod := r.FormValue("purchase_method")
purchaseDate := r.FormValue("purchase_date")
purchaseTime := r.FormValue("purchase_time")
if purchaseTime != "" {
purchaseDate += "T" + purchaseTime
}
imagePath := ""
file, handler, err := r.FormFile("ticket_image")
if err == nil && handler != nil {
defer file.Close()
filename := fmt.Sprintf("uploads/ticket_%d_%s", time.Now().UnixNano(), handler.Filename)
out, err := os.Create(filename)
if err == nil {
defer out.Close()
io.Copy(out, file)
imagePath = filename
}
}
ballCount := 6
bonusCount := 2
balls := make([][]int, ballCount)
bonuses := make([][]int, bonusCount)
for i := 1; i <= ballCount; i++ {
balls[i-1] = helpers.ParseIntSlice(r.Form["ball"+strconv.Itoa(i)])
}
for i := 1; i <= bonusCount; i++ {
bonuses[i-1] = helpers.ParseIntSlice(r.Form["bonus"+strconv.Itoa(i)])
}
lineCount := len(balls[0])
for i := 0; i < lineCount; i++ {
var b [6]int
var bo [2]int
for j := 0; j < ballCount; j++ {
if j < len(balls) && i < len(balls[j]) {
b[j] = balls[j][i]
}
}
for j := 0; j < bonusCount; j++ {
if j < len(bonuses) && i < len(bonuses[j]) {
bo[j] = bonuses[j][i]
}
}
_, err := db.Exec(`
INSERT INTO my_tickets (
user_id, game_type, draw_date,
ball1, ball2, ball3, ball4, ball5, ball6,
bonus1, bonus2,
purchase_method, purchase_date, image_path
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
userID, game, drawDate,
b[0], b[1], b[2], b[3], b[4], b[5],
bo[0], bo[1],
purchaseMethod, purchaseDate, imagePath,
)
if err != nil {
log.Println("❌ Insert failed:", err)
}
}
http.Redirect(w, r, "/tickets", http.StatusSeeOther)
})
}
func GetMyTickets(db *sql.DB) http.HandlerFunc {
return httpHelpers.AuthMiddleware(func(w http.ResponseWriter, r *http.Request) {
data := models.TemplateData{}
var tickets []models.Ticket
context := templateHelpers.TemplateContext(w, r, data)
context["Tickets"] = tickets
userID, ok := securityHelpers.GetCurrentUserID(r)
if !ok {
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
return
}
rows, err := db.Query(`
SELECT id, game_type, draw_date,
ball1, ball2, ball3, ball4, ball5, ball6,
bonus1, bonus2,
purchase_method, purchase_date, image_path, duplicate,
matched_main, matched_bonus, prize_tier, is_winner, prize_label, prize_amount
FROM my_tickets
WHERE userid = ?
ORDER BY draw_date DESC, created_at DESC
`, userID)
if err != nil {
log.Println("❌ Query failed:", err)
http.Error(w, "Could not load tickets", http.StatusInternalServerError)
return
}
defer rows.Close()
for rows.Next() {
var t models.Ticket
var b1, b2, b3, b4, b5, b6, bo1, bo2 sql.NullInt64
var matchedMain, matchedBonus sql.NullInt64
var prizeTier sql.NullString
var isWinner sql.NullBool
var prizeLabel sql.NullString
var prizeAmount sql.NullFloat64
err := rows.Scan(
&t.Id, &t.GameType, &t.DrawDate,
&b1, &b2, &b3, &b4, &b5, &b6,
&bo1, &bo2,
&t.PurchaseMethod, &t.PurchaseDate, &t.ImagePath, &t.Duplicate,
&matchedMain, &matchedBonus, &prizeTier, &isWinner, &prizeLabel, &prizeAmount,
)
if err != nil {
log.Println("⚠️ Failed to scan ticket row:", err)
continue
}
// Build primary number + bonus fields
t.Ball1 = int(b1.Int64)
t.Ball2 = int(b2.Int64)
t.Ball3 = int(b3.Int64)
t.Ball4 = int(b4.Int64)
t.Ball5 = int(b5.Int64)
t.Ball6 = int(b6.Int64)
t.Bonus1 = helpers.IntPtrIfValid(bo1)
t.Bonus2 = helpers.IntPtrIfValid(bo2)
if matchedMain.Valid {
t.MatchedMain = int(matchedMain.Int64)
}
if matchedBonus.Valid {
t.MatchedBonus = int(matchedBonus.Int64)
}
if prizeTier.Valid {
t.PrizeTier = prizeTier.String
}
if isWinner.Valid {
t.IsWinner = isWinner.Bool
}
if prizeLabel.Valid {
t.PrizeLabel = prizeLabel.String
}
if prizeAmount.Valid {
t.PrizeAmount = prizeAmount.Float64
}
// Build balls slices (for template use)
t.Balls = helpers.BuildBallsSlice(t)
t.BonusBalls = helpers.BuildBonusSlice(t)
// 🎯 Get the actual draw info (used to show which numbers matched)
draw := draws.GetDrawResultForTicket(db, t.GameType, t.DrawDate)
t.MatchedDraw = draw
// ✅ DEBUG
log.Printf("✅ Ticket #%d", t.Id)
log.Printf("Balls: %v", t.Balls)
log.Printf("DrawResult: %+v", draw)
tickets = append(tickets, t)
}
tmpl := templateHelpers.LoadTemplateFiles("my_tickets.html", "templates/account/tickets/my_tickets.html")
err = tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
log.Println("❌ Template error:", err)
http.Error(w, "Error rendering page", http.StatusInternalServerError)
}
})
}

View File

@@ -0,0 +1,44 @@
package handlers
import (
"synlotto-website/models"
)
func MatchTicketToDraw(ticket models.MatchTicket, draw models.DrawResult, rules []models.PrizeRule) models.MatchResult {
mainMatches := countMatches(ticket.Balls, draw.Balls)
bonusMatches := countMatches(ticket.BonusBalls, draw.BonusBalls)
prizeTier := getPrizeTier(ticket.GameType, mainMatches, bonusMatches, rules)
isWinner := prizeTier != ""
return models.MatchResult{
MatchedDrawID: draw.DrawID,
MatchedMain: mainMatches,
MatchedBonus: bonusMatches,
PrizeTier: prizeTier,
IsWinner: isWinner,
}
}
func countMatches(a, b []int) int {
m := make(map[int]bool)
for _, n := range b {
m[n] = true
}
match := 0
for _, n := range a {
if m[n] {
match++
}
}
return match
}
func getPrizeTier(game string, main, bonus int, rules []models.PrizeRule) string {
for _, rule := range rules {
if rule.Game == game && rule.MainMatches == main && rule.BonusMatches == bonus {
return rule.Tier
}
}
return ""
}

View File

@@ -0,0 +1,186 @@
package handlers
import (
"database/sql"
"log"
"net/http"
templateHandlers "synlotto-website/handlers/template"
"synlotto-website/helpers"
httpHelpers "synlotto-website/helpers/http"
securityHelpers "synlotto-website/helpers/security"
templateHelpers "synlotto-website/helpers/template"
"synlotto-website/storage"
)
func MessagesInboxHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r)
if !ok {
templateHelpers.RenderError(w, r, 403)
return
}
page := helpers.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
perPage := 10
totalCount := storage.GetInboxMessageCount(db, userID)
totalPages := (totalCount + perPage - 1) / perPage
if totalPages == 0 {
totalPages = 1
}
messages := storage.GetInboxMessages(db, userID, page, perPage)
data := templateHandlers.BuildTemplateData(db, w, r)
context := templateHelpers.TemplateContext(w, r, data)
context["Messages"] = messages
context["CurrentPage"] = page
context["TotalPages"] = totalPages
context["PageRange"] = templateHelpers.PageRange(page, totalPages)
tmpl := templateHelpers.LoadTemplateFiles("messages.html", "templates/account/messages/index.html")
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
// ToDo: Make this load all error pages without defining explictly.
templateHelpers.RenderError(w, r, 500)
}
}
}
func ReadMessageHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := r.URL.Query().Get("id")
messageID := helpers.Atoi(idStr)
session, _ := httpHelpers.GetSession(w, r)
userID, ok := session.Values["user_id"].(int)
if !ok {
templateHelpers.RenderError(w, r, 403)
return
}
message, err := storage.GetMessageByID(db, userID, messageID)
if err != nil {
log.Printf("❌ Message not found: %v", err)
message = nil
} else if !message.IsRead {
_ = storage.MarkMessageAsRead(db, messageID, userID)
}
data := templateHandlers.BuildTemplateData(db, w, r)
context := templateHelpers.TemplateContext(w, r, data)
context["Message"] = message
tmpl := templateHelpers.LoadTemplateFiles("read-message.html", "templates/account/messages/read.html")
tmpl.ExecuteTemplate(w, "layout", context)
}
}
func ArchiveMessageHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := helpers.Atoi(r.URL.Query().Get("id"))
userID, ok := securityHelpers.GetCurrentUserID(r)
if !ok {
templateHelpers.RenderError(w, r, 403)
return
}
err := storage.ArchiveMessage(db, userID, id)
if err != nil {
templateHelpers.SetFlash(w, r, "Failed to archive message.")
} else {
templateHelpers.SetFlash(w, r, "Message archived.")
}
http.Redirect(w, r, "/account/messages", http.StatusSeeOther)
}
}
func ArchivedMessagesHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r)
if !ok {
templateHelpers.RenderError(w, r, 403)
return
}
page := helpers.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
perPage := 10
messages := storage.GetArchivedMessages(db, userID, page, perPage)
hasMore := len(messages) == perPage
data := templateHandlers.BuildTemplateData(db, w, r)
context := templateHelpers.TemplateContext(w, r, data)
context["Messages"] = messages
context["Page"] = page
context["HasMore"] = hasMore
tmpl := templateHelpers.LoadTemplateFiles("archived.html", "templates/account/messages/archived.html")
tmpl.ExecuteTemplate(w, "layout", context)
}
}
func SendMessageHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
data := templateHandlers.BuildTemplateData(db, w, r)
context := templateHelpers.TemplateContext(w, r, data)
tmpl := templateHelpers.LoadTemplateFiles("send-message.html", "templates/account/messages/send.html")
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
templateHelpers.RenderError(w, r, 500)
}
case http.MethodPost:
senderID, ok := securityHelpers.GetCurrentUserID(r)
if !ok {
templateHelpers.RenderError(w, r, 403)
return
}
recipientID := helpers.Atoi(r.FormValue("recipient_id"))
subject := r.FormValue("subject")
body := r.FormValue("message")
if err := storage.SendMessage(db, senderID, recipientID, subject, body); err != nil {
templateHelpers.SetFlash(w, r, "Failed to send message.")
} else {
templateHelpers.SetFlash(w, r, "Message sent.")
}
http.Redirect(w, r, "/account/messages", http.StatusSeeOther)
default:
templateHelpers.RenderError(w, r, 405)
}
}
}
func RestoreMessageHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := helpers.Atoi(r.URL.Query().Get("id"))
userID, ok := securityHelpers.GetCurrentUserID(r)
if !ok {
templateHelpers.RenderError(w, r, 403)
return
}
err := storage.RestoreMessage(db, userID, id)
if err != nil {
templateHelpers.SetFlash(w, r, "Failed to restore message.")
} else {
templateHelpers.SetFlash(w, r, "Message restored.")
}
http.Redirect(w, r, "/account/messages/archived", http.StatusSeeOther)
}
}

View File

@@ -0,0 +1,70 @@
package handlers
import (
"database/sql"
"log"
"net/http"
"strconv"
templateHandlers "synlotto-website/handlers/template"
httpHelpers "synlotto-website/helpers/http"
templateHelpers "synlotto-website/helpers/template"
"synlotto-website/storage"
)
func NotificationsHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := templateHandlers.BuildTemplateData(db, w, r)
context := templateHelpers.TemplateContext(w, r, data)
tmpl := templateHelpers.LoadTemplateFiles("index.html", "templates/account/notifications/index.html")
err := tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
log.Println("❌ Template render error:", err)
http.Error(w, "Error rendering notifications page", http.StatusInternalServerError)
}
}
}
func MarkNotificationReadHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
notificationIDStr := r.URL.Query().Get("id")
notificationID, err := strconv.Atoi(notificationIDStr)
if err != nil {
http.Error(w, "Invalid notification ID", http.StatusBadRequest)
return
}
session, _ := httpHelpers.GetSession(w, r)
userID, ok := session.Values["user_id"].(int)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
notification, err := storage.GetNotificationByID(db, userID, notificationID)
if err != nil {
log.Printf("❌ Notification not found or belongs to another user: %v", err)
notification = nil
} else if !notification.IsRead {
err = storage.MarkNotificationAsRead(db, userID, notificationID)
if err != nil {
log.Printf("⚠️ Failed to mark as read: %v", err)
}
}
data := templateHandlers.BuildTemplateData(db, w, r)
context := templateHelpers.TemplateContext(w, r, data)
context["Notification"] = notification
tmpl := templateHelpers.LoadTemplateFiles("read.html", "templates/account/notifications/read.html")
err = tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
log.Printf("❌ Template render error: %v", err)
http.Error(w, "Template render error", http.StatusInternalServerError)
}
}
}

View File

@@ -0,0 +1,137 @@
package handlers
import (
"database/sql"
"log"
"net"
"net/http"
"regexp"
"sort"
"strconv"
templateHelpers "synlotto-website/helpers/template"
"synlotto-website/helpers"
"synlotto-website/middleware"
"synlotto-website/models"
)
func ResultsThunderball(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
limiter := middleware.GetVisitorLimiter(ip)
if !limiter.Allow() {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
const pageSize = 20
page := 1
offset := 0
query := r.URL.Query().Get("q")
pageStr := r.URL.Query().Get("page")
yearFilter := r.URL.Query().Get("year")
machineFilter := r.URL.Query().Get("machine")
ballSetFilter := r.URL.Query().Get("ballset")
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
offset = (page - 1) * pageSize
}
isValidDate := regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`).MatchString
isValidNumber := regexp.MustCompile(`^\d+$`).MatchString
doSearch := isValidDate(query) || isValidNumber(query)
whereClause := "WHERE 1=1"
args := []interface{}{}
if doSearch {
whereClause += " AND (draw_date = ? OR id = ?)"
args = append(args, query, query)
}
if yearFilter != "" {
whereClause += " AND strftime('%Y', draw_date) = ?"
args = append(args, yearFilter)
}
if machineFilter != "" {
whereClause += " AND machine = ?"
args = append(args, machineFilter)
}
if ballSetFilter != "" {
whereClause += " AND ballset = ?"
args = append(args, ballSetFilter)
}
totalPages, totalResults := templateHelpers.GetTotalPages(db, "results_thunderball", whereClause, args, pageSize)
if page < 1 || page > totalPages {
http.NotFound(w, r)
return
}
querySQL := `
SELECT id, draw_date, machine, ballset, ball1, ball2, ball3, ball4, ball5, thunderball
FROM results_thunderball
` + whereClause + `
ORDER BY id DESC
LIMIT ? OFFSET ?`
argsWithLimit := append(args, pageSize, offset)
rows, err := db.Query(querySQL, argsWithLimit...)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
log.Println("❌ DB error:", err)
return
}
defer rows.Close()
var results []models.ThunderballResult
for rows.Next() {
var res models.ThunderballResult
if err := rows.Scan(
&res.Id, &res.DrawDate, &res.Machine, &res.BallSet,
&res.Ball1, &res.Ball2, &res.Ball3, &res.Ball4, &res.Ball5, &res.Thunderball,
); err != nil {
log.Println("❌ Row scan error:", err)
continue
}
res.SortedBalls = []int{res.Ball1, res.Ball2, res.Ball3, res.Ball4, res.Ball5}
sort.Ints(res.SortedBalls)
results = append(results, res)
}
years, _ := helpers.GetDistinctValues(db, "strftime('%Y', draw_date)")
machines, _ := helpers.GetDistinctValues(db, "machine")
ballsets, _ := helpers.GetDistinctValues(db, "ballset")
var noResultsMsg string
if query != "" && !doSearch {
noResultsMsg = "Invalid search. Please enter a draw number or date (yyyy-mm-dd)."
} else if len(results) == 0 && query != "" {
noResultsMsg = "No results found for \"" + query + "\""
}
tmpl := templateHelpers.LoadTemplateFiles("thunderball.html", "templates/results/thunderball.html")
err = tmpl.ExecuteTemplate(w, "layout", map[string]interface{}{
"Results": results,
"Page": page,
"TotalPages": totalPages,
"TotalResults": totalResults,
"Query": query,
"NoResultsMsg": noResultsMsg,
"Years": years,
"Machines": machines,
"BallSets": ballsets,
"YearFilter": yearFilter,
"MachineFilter": machineFilter,
"BallSetFilter": ballSetFilter,
})
if err != nil {
log.Println("❌ Template error:", err)
http.Error(w, "Error rendering results", http.StatusInternalServerError)
}
}
}

View File

@@ -0,0 +1,23 @@
package handlers
import (
"fmt"
"net/http"
"github.com/gorilla/sessions"
)
var (
SessionStore *sessions.CookieStore
Name string
)
func GetSession(w http.ResponseWriter, r *http.Request) (*sessions.Session, error) {
if SessionStore == nil {
return nil, fmt.Errorf("session store not initialized")
}
if Name == "" {
return nil, fmt.Errorf("session name not configured")
}
return SessionStore.Get(r, Name)
}

View File

@@ -0,0 +1,32 @@
package handlers
import (
"net/http"
"github.com/gorilla/securecookie"
)
var (
authKey []byte
encryptKey []byte
)
func SecureCookie(w http.ResponseWriter, name, value string, isProduction bool) error {
s := securecookie.New(authKey, encryptKey)
encoded, err := s.Encode(name, value)
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: name,
Value: encoded,
Path: "/",
HttpOnly: true,
Secure: isProduction,
SameSite: http.SameSiteStrictMode,
})
return nil
}

View File

@@ -0,0 +1,36 @@
package handlers
import (
"database/sql"
"log"
"net"
"net/http"
templateHandlers "synlotto-website/handlers/template"
templateHelpers "synlotto-website/helpers/template"
"synlotto-website/middleware"
)
func StatisticsThunderball(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
limiter := middleware.GetVisitorLimiter(ip)
if !limiter.Allow() {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
data := templateHandlers.BuildTemplateData(db, w, r)
context := templateHelpers.TemplateContext(w, r, data)
tmpl := templateHelpers.LoadTemplateFiles("statistics.html", "templates/statistics/thunderball.html")
err := tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
log.Println("❌ Template render error:", err)
http.Error(w, "Error rendering homepage", http.StatusInternalServerError)
}
}
}

View File

@@ -0,0 +1,46 @@
package handlers
import (
"database/sql"
"log"
"net/http"
httpHelper "synlotto-website/helpers/http"
"synlotto-website/models"
"synlotto-website/storage"
)
func BuildTemplateData(db *sql.DB, w http.ResponseWriter, r *http.Request) models.TemplateData {
session, err := httpHelper.GetSession(w, r)
if err != nil {
log.Printf("Session error: %v", err)
}
var user *models.User
var isAdmin bool
var notificationCount int
var notifications []models.Notification
var messageCount int
var messages []models.Message
if userId, ok := session.Values["user_id"].(int); ok {
user = storage.GetUserByID(db, userId)
if user != nil {
isAdmin = user.IsAdmin
notificationCount = storage.GetNotificationCount(db, user.Id)
notifications = storage.GetRecentNotifications(db, user.Id, 15)
messageCount, _ = storage.GetMessageCount(db, user.Id)
messages = storage.GetRecentMessages(db, user.Id, 15)
}
}
return models.TemplateData{
User: user,
IsAdmin: isAdmin,
NotificationCount: notificationCount,
Notifications: notifications,
MessageCount: messageCount,
Messages: messages,
}
}

View File

@@ -0,0 +1,50 @@
package helpers
import (
"database/sql"
"synlotto-website/models"
)
func BuildBallsSlice(t models.Ticket) []int {
balls := []int{t.Ball1, t.Ball2, t.Ball3, t.Ball4, t.Ball5}
if t.GameType == "Lotto" && t.Ball6 > 0 {
balls = append(balls, t.Ball6)
}
return balls
}
func BuildBonusSlice(t models.Ticket) []int {
var bonuses []int
if t.Bonus1 != nil {
bonuses = append(bonuses, *t.Bonus1)
}
if t.Bonus2 != nil {
bonuses = append(bonuses, *t.Bonus2)
}
return bonuses
}
// BuildBallsFromNulls builds main balls from sql.NullInt64 values
func BuildBallsFromNulls(vals ...sql.NullInt64) []int {
var result []int
for _, v := range vals {
if v.Valid {
result = append(result, int(v.Int64))
}
}
return result
}
// BuildBonusFromNulls builds bonus balls from two sql.NullInt64 values
func BuildBonusFromNulls(b1, b2 sql.NullInt64) []int {
var result []int
if b1.Valid {
result = append(result, int(b1.Int64))
}
if b2.Valid {
result = append(result, int(b2.Int64))
}
return result
}

View File

@@ -0,0 +1,21 @@
package helpers
import "database/sql"
func GetDistinctValues(db *sql.DB, column string) ([]string, error) {
query := "SELECT DISTINCT " + column + " FROM results_thunderball ORDER BY " + column
rows, err := db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var values []string
for rows.Next() {
var val string
if err := rows.Scan(&val); err == nil {
values = append(values, val)
}
}
return values, nil
}

View File

@@ -0,0 +1,51 @@
package helpers
import (
"net/http"
"time"
session "synlotto-website/handlers/session"
"synlotto-website/constants"
"github.com/gorilla/sessions"
)
func GetSession(w http.ResponseWriter, r *http.Request) (*sessions.Session, error) {
return session.GetSession(w, r)
}
func IsSessionExpired(session *sessions.Session) bool {
last, ok := session.Values["last_activity"].(time.Time)
if !ok {
return false
}
return time.Since(last) > constants.SessionDuration
}
func UpdateSessionActivity(session *sessions.Session, r *http.Request, w http.ResponseWriter) {
session.Values["last_activity"] = time.Now().UTC()
session.Save(r, w)
}
func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session, _ := GetSession(w, r)
if IsSessionExpired(session) {
session.Options.MaxAge = -1
session.Save(r, w)
newSession, _ := GetSession(w, r)
newSession.Values["flash"] = "Your session has timed out."
newSession.Save(r, w)
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
return
}
UpdateSessionActivity(session, r, w)
next(w, r)
}
}

View File

@@ -0,0 +1,13 @@
package helpers
import (
"database/sql"
)
func IntPtrIfValid(val sql.NullInt64) *int {
if val.Valid {
n := int(val.Int64)
return &n
}
return nil
}

16
internal/helpers/match.go Normal file
View File

@@ -0,0 +1,16 @@
package helpers
func CountMatches(a, b []int) int {
m := make(map[int]bool)
for _, n := range b {
m[n] = true
}
match := 0
for _, n := range a {
if m[n] {
match++
}
}
return match
}

View File

@@ -0,0 +1,8 @@
package helpers
func Nullable(val int) *int {
if val == 0 {
return nil
}
return &val
}

14
internal/helpers/parse.go Normal file
View File

@@ -0,0 +1,14 @@
package helpers
import "strconv"
func ParseIntSlice(input []string) []int {
var out []int
for _, s := range input {
n, err := strconv.Atoi(s)
if err == nil {
out = append(out, n)
}
}
return out
}

View File

@@ -0,0 +1,16 @@
package security
import (
"database/sql"
"log"
)
func IsAdmin(db *sql.DB, userID int) bool {
var isAdmin bool
err := db.QueryRow(`SELECT is_admin FROM users WHERE id = ?`, userID).Scan(&isAdmin)
if err != nil {
log.Printf("⚠️ Failed to check is_admin for user %d: %v", userID, err)
return false
}
return isAdmin
}

View File

@@ -0,0 +1,13 @@
package security
import "golang.org/x/crypto/bcrypt"
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
func CheckPasswordHash(hash, password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}

View File

@@ -0,0 +1,15 @@
package security
import (
"crypto/rand"
"encoding/hex"
)
func GenerateSecureToken() (string, error) {
b := make([]byte, 32)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

View File

@@ -0,0 +1,17 @@
package security
import (
"net/http"
httpHelpers "synlotto-website/helpers/http"
)
func GetCurrentUserID(r *http.Request) (int, bool) {
session, err := httpHelpers.GetSession(nil, r)
if err != nil {
return 0, false
}
id, ok := session.Values["user_id"].(int)
return id, ok
}

View File

@@ -0,0 +1,7 @@
package helpers
import "encoding/base64"
func EncodeKey(b []byte) string {
return base64.StdEncoding.EncodeToString(b)
}

View File

@@ -0,0 +1,20 @@
package helpers
import (
"bytes"
"os"
)
func LoadKeyFromFile(path string) ([]byte, error) {
key, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return bytes.TrimSpace(key), nil
}
func ZeroBytes(b []byte) {
for i := range b {
b[i] = 0
}
}

View File

@@ -0,0 +1,8 @@
package helpers
import "strconv"
func Atoi(s string) int {
n, _ := strconv.Atoi(s)
return n
}

View File

@@ -0,0 +1,151 @@
package helpers
import (
"html/template"
"log"
"net/http"
"strings"
"time"
"synlotto-website/config"
helpers "synlotto-website/helpers/http"
"synlotto-website/models"
"github.com/gorilla/csrf"
)
func TemplateContext(w http.ResponseWriter, r *http.Request, data models.TemplateData) map[string]interface{} {
cfg := config.Get()
if cfg == nil {
log.Println("⚠️ Config not initialized!")
}
session, _ := helpers.GetSession(w, r)
var flash string
if f, ok := session.Values["flash"].(string); ok {
flash = f
delete(session.Values, "flash")
session.Save(r, w)
}
return map[string]interface{}{
"CSRFField": csrf.TemplateField(r),
"Flash": flash,
"User": data.User,
"IsAdmin": data.IsAdmin,
"NotificationCount": data.NotificationCount,
"Notifications": data.Notifications,
"MessageCount": data.MessageCount,
"Messages": data.Messages,
"SiteName": cfg.Site.SiteName,
"CopyrightYearStart": cfg.Site.CopyrightYearStart,
}
}
func TemplateFuncs() template.FuncMap {
return template.FuncMap{
"plus1": func(i int) int { return i + 1 },
"minus1": func(i int) int {
if i > 1 {
return i - 1
}
return 0
},
"mul": func(a, b int) int { return a * b },
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"min": func(a, b int) int {
if a < b {
return a
} else {
return b
}
},
"intVal": func(p *int) int {
if p == nil {
return 0
}
return *p
},
"inSlice": InSlice,
"lower": lower,
"truncate": func(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
},
"PageRange": PageRange,
"now": time.Now,
"humanTime": func(v interface{}) string {
switch t := v.(type) {
case time.Time:
return t.Local().Format("02 Jan 2006 15:04")
case string:
parsed, err := time.Parse(time.RFC3339, t)
if err == nil {
return parsed.Local().Format("02 Jan 2006 15:04")
}
return t
default:
return ""
}
},
"rangeClass": rangeClass,
}
}
func LoadTemplateFiles(name string, files ...string) *template.Template {
shared := []string{
"templates/main/layout.html",
"templates/main/topbar.html",
"templates/main/footer.html",
}
all := append(shared, files...)
return template.Must(template.New(name).Funcs(TemplateFuncs()).ParseFiles(all...))
}
func SetFlash(w http.ResponseWriter, r *http.Request, message string) {
session, _ := helpers.GetSession(w, r)
session.Values["flash"] = message
session.Save(r, w)
}
func InSlice(n int, list []int) bool {
for _, v := range list {
if v == n {
return true
}
}
return false
}
func lower(input string) string {
return strings.ToLower(input)
}
func PageRange(current, total int) []int {
var pages []int
for i := 1; i <= total; i++ {
pages = append(pages, i)
}
return pages
}
func rangeClass(n int) string {
switch {
case n >= 1 && n <= 9:
return "01-09"
case n >= 10 && n <= 19:
return "10-19"
case n >= 20 && n <= 29:
return "20-29"
case n >= 30 && n <= 39:
return "30-39"
case n >= 40 && n <= 49:
return "40-49"
default:
return "50-plus"
}
}

View File

@@ -0,0 +1,39 @@
package helpers
import (
"fmt"
"log"
"net/http"
"os"
"synlotto-website/models"
)
func RenderError(w http.ResponseWriter, r *http.Request, statusCode int) {
log.Printf("⚙️ RenderError called with status: %d", statusCode)
context := TemplateContext(w, r, models.TemplateData{})
pagePath := fmt.Sprintf("templates/error/%d.html", statusCode)
log.Printf("📄 Checking for template file: %s", pagePath)
if _, err := os.Stat(pagePath); err != nil {
log.Printf("🚫 Template file missing: %s", err)
http.Error(w, http.StatusText(statusCode), statusCode)
return
}
log.Println("✅ Template file found, loading...")
tmpl := LoadTemplateFiles(fmt.Sprintf("%d.html", statusCode), pagePath)
w.WriteHeader(statusCode)
err := tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
log.Printf("❌ Failed to render error page layout: %v", err)
http.Error(w, http.StatusText(statusCode), statusCode)
return
}
log.Println("✅ Successfully rendered error page") // ToDo: log these to database
}

View File

@@ -0,0 +1,26 @@
package helpers
import (
"database/sql"
)
func GetTotalPages(db *sql.DB, tableName, whereClause string, args []interface{}, pageSize int) (totalPages, totalCount int) {
query := "SELECT COUNT(*) FROM " + tableName + " " + whereClause
row := db.QueryRow(query, args...)
if err := row.Scan(&totalCount); err != nil {
return 1, 0
}
totalPages = (totalCount + pageSize - 1) / pageSize
if totalPages < 1 {
totalPages = 1
}
return totalPages, totalCount
}
func MakePageRange(current, total int) []int {
var pages []int
for i := 1; i <= total; i++ {
pages = append(pages, i)
}
return pages
}

View File

@@ -0,0 +1,49 @@
package middleware
import (
"net/http"
"time"
httpHelpers "synlotto-website/helpers/http"
"synlotto-website/constants"
)
func Auth(required bool) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session, _ := httpHelpers.GetSession(w, r)
_, ok := session.Values["user_id"].(int)
if required && !ok {
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
return
}
if ok {
last, hasLast := session.Values["last_activity"].(time.Time)
if hasLast && time.Since(last) > constants.SessionDuration {
session.Options.MaxAge = -1
session.Save(r, w)
newSession, _ := httpHelpers.GetSession(w, r)
newSession.Values["flash"] = "Your session has timed out."
newSession.Save(r, w)
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
return
}
session.Values["last_activity"] = time.Now()
session.Save(r, w)
}
next(w, r)
}
}
}
func Protected(h http.HandlerFunc) http.HandlerFunc {
return Auth(true)(SessionTimeout(h))
}

View File

@@ -0,0 +1,23 @@
package middleware
import "net/http"
func EnforceHTTPS(next http.Handler, enabled bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if enabled && r.Header.Get("X-Forwarded-Proto") != "https" && r.TLS == nil {
http.Redirect(w, r, "https://"+r.Host+r.RequestURI, http.StatusMovedPermanently)
return
}
next.ServeHTTP(w, r)
})
}
func SecureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' https://cdn.jsdelivr.net; script-src 'self' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,35 @@
package middleware
import (
"net"
"net/http"
"sync"
"golang.org/x/time/rate"
)
var visitors = make(map[string]*rate.Limiter)
var mu sync.Mutex
func GetVisitorLimiter(ip string) *rate.Limiter {
mu.Lock()
defer mu.Unlock()
limiter, exists := visitors[ip]
if !exists {
limiter = rate.NewLimiter(3, 5)
visitors[ip] = limiter
}
return limiter
}
func RateLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
if !GetVisitorLimiter(ip).Allow() {
http.Error(w, "Too many requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,22 @@
package middleware
import (
"log"
"net/http"
"runtime/debug"
templateHelpers "synlotto-website/helpers/template"
)
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("🔥 Recovered from panic: %v\n%s", rec, debug.Stack())
templateHelpers.RenderError(w, r, http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,40 @@
package middleware
import (
"log"
"net/http"
"time"
session "synlotto-website/handlers/session"
"synlotto-website/constants"
)
func SessionTimeout(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess, err := session.GetSession(w, r)
if err != nil {
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
return
}
last, ok := sess.Values["last_activity"].(time.Time)
if !ok || time.Since(last) > constants.SessionDuration {
sess.Options.MaxAge = -1
_ = sess.Save(r, w)
newSession, _ := session.GetSession(w, r)
newSession.Values["flash"] = "Your session has timed out."
_ = newSession.Save(r, w)
log.Printf("Session timeout triggered")
http.Redirect(w, r, "/account/login", http.StatusSeeOther)
return
}
sess.Values["last_activity"] = time.Now().UTC()
_ = sess.Save(r, w)
next(w, r)
}
}

View File

@@ -0,0 +1,28 @@
package routes
import (
"database/sql"
"net/http"
accountHandlers "synlotto-website/handlers/account"
lotteryDrawHandlers "synlotto-website/handlers/lottery/tickets"
"synlotto-website/handlers"
"synlotto-website/middleware"
)
func SetupAccountRoutes(mux *http.ServeMux, db *sql.DB) {
mux.HandleFunc("/account/login", accountHandlers.Login(db))
mux.HandleFunc("/account/logout", middleware.Protected(accountHandlers.Logout))
mux.HandleFunc("/account/signup", accountHandlers.Signup)
mux.HandleFunc("/account/tickets/add_ticket", lotteryDrawHandlers.AddTicket(db))
mux.HandleFunc("/account/tickets/my_tickets", lotteryDrawHandlers.GetMyTickets(db))
mux.HandleFunc("/account/messages", middleware.Protected(handlers.MessagesInboxHandler(db)))
mux.HandleFunc("/account/messages/read", middleware.Protected(handlers.ReadMessageHandler(db)))
mux.HandleFunc("/account/messages/archive", middleware.Protected(handlers.ArchiveMessageHandler(db)))
mux.HandleFunc("/account/messages/archived", middleware.Protected(handlers.ArchivedMessagesHandler(db)))
mux.HandleFunc("/account/messages/restore", middleware.Protected(handlers.RestoreMessageHandler(db)))
mux.HandleFunc("/account/messages/send", middleware.Protected(handlers.SendMessageHandler(db)))
mux.HandleFunc("/account/notifications", middleware.Protected(handlers.NotificationsHandler(db)))
mux.HandleFunc("/account/notifications/read", middleware.Protected(handlers.MarkNotificationReadHandler(db)))
}

View File

@@ -0,0 +1,27 @@
package routes
import (
"database/sql"
"net/http"
admin "synlotto-website/handlers/admin"
"synlotto-website/middleware"
)
func SetupAdminRoutes(mux *http.ServeMux, db *sql.DB) {
mux.HandleFunc("/admin/access", middleware.Protected(admin.AdminAccessLogHandler(db)))
mux.HandleFunc("/admin/audit", middleware.Protected(admin.AuditLogHandler(db)))
mux.HandleFunc("/admin/dashboard", middleware.Protected(admin.AdminDashboardHandler(db)))
mux.HandleFunc("/admin/triggers", middleware.Protected(admin.AdminTriggersHandler(db)))
// Draw management
mux.HandleFunc("/admin/draws", middleware.Protected(admin.ListDrawsHandler(db)))
// mux.HandleFunc("/admin/draws/new", middleware.AdminOnly(db, admin.RenderNewDrawForm(db)))
// mux.HandleFunc("/admin/draws/submit", middleware.AdminOnly(db, admin.CreateDrawHandler(db)))
mux.HandleFunc("/admin/draws/modify", middleware.Protected(admin.ModifyDrawHandler(db)))
mux.HandleFunc("/admin/draws/delete", middleware.Protected(admin.DeleteDrawHandler(db)))
// Prize management
mux.HandleFunc("/admin/draws/prizes/add", middleware.Protected(admin.AddPrizesHandler(db)))
mux.HandleFunc("/admin/draws/prizes/modify", middleware.Protected(admin.ModifyPrizesHandler(db)))
}

View File

@@ -0,0 +1,12 @@
package routes
import (
"database/sql"
"net/http"
"synlotto-website/handlers"
)
func SetupResultRoutes(mux *http.ServeMux, db *sql.DB) {
mux.HandleFunc("/results/thunderball", handlers.ResultsThunderball(db))
}

View File

@@ -0,0 +1,13 @@
package routes
import (
"database/sql"
"net/http"
handlers "synlotto-website/handlers/statistics"
"synlotto-website/middleware"
)
func SetupStatisticsRoutes(mux *http.ServeMux, db *sql.DB) {
mux.HandleFunc("/statistics/thunderball", middleware.Auth(true)(handlers.StatisticsThunderball(db)))
}

View File

@@ -0,0 +1,25 @@
package routes
import (
"database/sql"
"net/http"
lotterySyndicateHandlers "synlotto-website/handlers/lottery/syndicate"
"synlotto-website/middleware"
)
func SetupSyndicateRoutes(mux *http.ServeMux, db *sql.DB) {
mux.HandleFunc("/syndicate", middleware.Auth(true)(lotterySyndicateHandlers.ListSyndicatesHandler(db)))
mux.HandleFunc("/syndicate/create", middleware.Auth(true)(lotterySyndicateHandlers.CreateSyndicateHandler(db)))
mux.HandleFunc("/syndicate/view", middleware.Auth(true)(lotterySyndicateHandlers.ViewSyndicateHandler(db)))
mux.HandleFunc("/syndicate/tickets", middleware.Auth(true)(lotterySyndicateHandlers.SyndicateTicketsHandler(db)))
mux.HandleFunc("/syndicate/tickets/new", middleware.Auth(true)(lotterySyndicateHandlers.SyndicateLogTicketHandler(db)))
mux.HandleFunc("/syndicate/invites", middleware.Auth(true)(lotterySyndicateHandlers.ViewInvitesHandler(db)))
mux.HandleFunc("/syndicate/invites/accept", middleware.Auth(true)(lotterySyndicateHandlers.AcceptInviteHandler(db)))
mux.HandleFunc("/syndicate/invites/decline", middleware.Auth(true)(lotterySyndicateHandlers.DeclineInviteHandler(db)))
mux.HandleFunc("/syndicate/invite/token", middleware.Auth(true)(lotterySyndicateHandlers.GenerateInviteLinkHandler(db)))
mux.HandleFunc("/syndicate/invite/tokens", middleware.Auth(true)(lotterySyndicateHandlers.ManageInviteTokensHandler(db)))
mux.HandleFunc("/syndicate/join", middleware.Auth(true)(lotterySyndicateHandlers.JoinSyndicateWithTokenHandler(db)))
}

View File

@@ -0,0 +1,24 @@
package logging
import (
"encoding/json"
"log"
"synlotto-website/models"
)
func LogConfig(config *models.Config) {
safeConfig := *config
safeConfig.CSRF.CSRFKey = "[REDACTED]"
safeConfig.Session.AuthKeyPath = "[REDACTED]"
safeConfig.Session.EncryptionKeyPath = "[REDACTED]"
cfg, err := json.MarshalIndent(safeConfig, "", " ")
if err != nil {
log.Println("Failed to log config:", err)
return
}
log.Println("App starting with config:")
log.Println(string(cfg))
}

View File

@@ -0,0 +1,13 @@
package logging
import (
"log"
)
func Info(msg string, args ...any) {
log.Printf("[INFO] "+msg, args...)
}
func Error(msg string, args ...any) {
log.Printf("[ERROR] "+msg, args...)
}

14
internal/models/audit.go Normal file
View File

@@ -0,0 +1,14 @@
package models
import "time"
type AuditEntry struct {
ID int
UserID int
Username string
Action string
Path string
IP string
UserAgent string
Timestamp time.Time
}

40
internal/models/config.go Normal file
View File

@@ -0,0 +1,40 @@
package models
type Config struct {
CSRF struct {
CSRFKey string `json:"csrfKey"`
} `json:"csrf"`
Database struct {
Server string `json:"server"`
Port int `json:"port"`
DatabaseNamed string `json:"databaseName"`
MaxOpenConnections int `json:"maxOpenConnections"`
MaxIdleConnections int `json:"maxIdleConnections"`
ConnectionMaxLifetime string `json:"connectionMaxLifetime"`
Username string `json:"username"`
Password string `json:"password"`
}
HttpServer struct {
Port int `json:"port"`
Address string `json:"address"`
ProductionMode bool `json:"productionMode"`
} `json:"httpServer"`
License struct {
APIURL string `json:"apiUrl"`
APIKey string `json:"apiKey"`
} `json:"license"`
Session struct {
AuthKeyPath string `json:"authKeyPath"`
EncryptionKeyPath string `json:"encryptionKeyPath"`
Name string `json:"name"`
} `json:"session"`
Site struct {
SiteName string `json:"siteName"`
CopyrightYearStart int `json:"copyrightYearStart"`
} `json:"site"`
}

25
internal/models/draw.go Normal file
View File

@@ -0,0 +1,25 @@
package models
type DrawSummary struct {
Id int
GameType string
DrawDate string
BallSet string
Machine string
PrizeSet bool
}
type ThunderballResult struct {
Id int
DrawDate string
Machine string
BallSet int
Ball1 int
Ball2 int
Ball3 int
Ball4 int
Ball5 int
Thunderball int
SortedBalls []int
}

View File

@@ -0,0 +1,9 @@
package models
type MachineUsage struct {
Machine string
DrawsUsed int
PctOfDraws float64
FirstUsed string
LastUsed string
}

49
internal/models/match.go Normal file
View File

@@ -0,0 +1,49 @@
package models
type MatchTicket struct {
ID int
GameType string
DrawDate string
Balls []int
BonusBalls []int
}
type DrawResult struct {
DrawID int
GameType string
DrawDate string
Balls []int
BonusBalls []int
}
type MatchResult struct {
MatchedMain int
MatchedBonus int
PrizeTier string
IsWinner bool
MatchedDrawID int
PrizeAmount float64
PrizeLabel string
}
type PrizeRule struct {
Game string
MainMatches int
BonusMatches int
Tier string
}
type MatchRunStats struct {
TicketsMatched int
WinnersFound int
}
type MatchLog struct {
ID int
TriggeredBy string
RunAt string
TicketsMatched int
WinnersFound int
Notes string
}

View File

@@ -0,0 +1,15 @@
package models
import (
"database/sql"
)
type NextMachineBallsetPrediction struct {
NextDrawDate string
CurrentMachine string
EstimatedNextMachine string
MachineTransitionPct float64
CurrentBallset sql.NullString
EstimatedNextBallset sql.NullString
BallsetTransitionPct sql.NullFloat64
}

View File

@@ -0,0 +1,18 @@
package models
type TopNum struct {
Number int
Frequency int
}
type Pair struct {
A int
B int
Frequency int
}
type ZScore struct {
Ball int
Recent int
Z float64
}

View File

@@ -0,0 +1,41 @@
package models
import (
"database/sql"
"time"
)
type Syndicate struct {
ID int
OwnerID int
Name string
Description string
CreatedBy int
CreatedAt time.Time
}
type SyndicateMember struct {
ID int
SyndicateID int
UserID int
Role string
JoinedAt time.Time
}
type SyndicateInvite struct {
ID int
SyndicateID int
InvitedUserID int
SentByUserID int
Status string
CreatedAt time.Time
}
type SyndicateInviteToken struct {
Token string
InvitedByUserID int
AcceptedByUserID sql.NullInt64
CreatedAt time.Time
ExpiresAt time.Time
AcceptedAt sql.NullTime
}

View File

@@ -0,0 +1,10 @@
package models
type TemplateData struct {
User *User
IsAdmin bool
NotificationCount int
Notifications []Notification
MessageCount int
Messages []Message
}

32
internal/models/ticket.go Normal file
View File

@@ -0,0 +1,32 @@
package models
type Ticket struct {
Id int
UserId int
SyndicateId *int
GameType string
DrawDate string
Ball1 int
Ball2 int
Ball3 int
Ball4 int
Ball5 int
Ball6 int
Bonus1 *int
Bonus2 *int
PurchaseMethod string
PurchaseDate string
ImagePath string
Duplicate bool
MatchedMain int
MatchedBonus int
PrizeTier string
IsWinner bool
// Used only for display these are not stored in the DB, they mirror MatchTicket structure but are populated on read.
Balls []int
BonusBalls []int
MatchedDraw DrawResult
PrizeAmount float64 `db:"prize_amount"`
PrizeLabel string `db:"prize_label"`
}

34
internal/models/user.go Normal file
View File

@@ -0,0 +1,34 @@
package models
import (
"time"
)
type User struct {
Id int
Username string
PasswordHash string
IsAdmin bool
}
// ToDo: should be in a notification model?
type Notification struct {
ID int
UserId int
Subject string
Body string
IsRead bool
CreatedAt time.Time
}
// ToDo: should be in a message model?
type Message struct {
ID int
SenderId int
RecipientId int
Subject string
Message string
IsRead bool
CreatedAt time.Time
ArchivedAt *time.Time
}

View File

@@ -0,0 +1,26 @@
package bootstrap
import (
"fmt"
"net/http"
"github.com/gorilla/csrf"
)
var CSRFMiddleware func(http.Handler) http.Handler
func InitCSRFProtection(csrfKey []byte, isProduction bool) error {
if len(csrfKey) != 32 {
return fmt.Errorf("csrf key must be 32 bytes, got %d", len(csrfKey))
}
CSRFMiddleware = csrf.Protect(
csrfKey,
csrf.Secure(isProduction),
csrf.SameSite(csrf.SameSiteStrictMode),
csrf.Path("/"),
csrf.HttpOnly(true),
)
return nil
}

View File

@@ -0,0 +1,32 @@
package bootstrap
import (
"log"
"time"
internal "synlotto-website/internal/licensecheck"
"synlotto-website/models"
)
var globalChecker *internal.LicenseChecker
func InitLicenseChecker(config *models.Config) error {
checker := &internal.LicenseChecker{
LicenseAPIURL: config.License.APIURL,
APIKey: config.License.APIKey,
PollInterval: 10 * time.Minute,
}
if err := checker.Validate(); err != nil {
return err
}
checker.StartBackgroundCheck()
globalChecker = checker
log.Println("✅ License validation started.")
return nil
}
func GetLicenseChecker() *internal.LicenseChecker {
return globalChecker
}

View File

@@ -0,0 +1,30 @@
package bootstrap
import (
"encoding/json"
"fmt"
"os"
"synlotto-website/models"
)
type AppState struct {
Config *models.Config
}
func LoadAppState(configPath string) (*AppState, error) {
file, err := os.Open(configPath)
if err != nil {
return nil, fmt.Errorf("open config: %w", err)
}
defer file.Close()
var config models.Config
if err := json.NewDecoder(file).Decode(&config); err != nil {
return nil, fmt.Errorf("decode config: %w", err)
}
return &AppState{
Config: &config,
}, nil
}

View File

@@ -0,0 +1,115 @@
package bootstrap
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/gob"
"fmt"
"net/http"
"os"
"time"
sessionHandlers "synlotto-website/handlers/session"
sessionHelpers "synlotto-website/helpers/session"
"synlotto-website/logging"
"synlotto-website/models"
"github.com/gorilla/sessions"
)
var (
sessionStore *sessions.CookieStore
Name string
authKey []byte
encryptKey []byte
)
func InitSession(cfg *models.Config) error {
gob.Register(time.Time{})
authPath := cfg.Session.AuthKeyPath
encPath := cfg.Session.EncryptionKeyPath
if _, err := os.Stat(authPath); os.IsNotExist(err) {
logging.Info("⚠️ Auth key not found, creating: %s", authPath)
key, err := generateRandomBytes(32)
if err != nil {
return err
}
encoded := sessionHelpers.EncodeKey(key)
err = os.WriteFile(authPath, []byte(encoded), 0600)
if err != nil {
return err
}
}
if _, err := os.Stat(encPath); os.IsNotExist(err) {
logging.Info("⚠️ Encryption key not found, creating: %s", encPath)
key, err := generateRandomBytes(32)
if err != nil {
return err
}
encoded := sessionHelpers.EncodeKey(key)
err = os.WriteFile(encPath, []byte(encoded), 0600)
if err != nil {
return err
}
}
return loadSessionKeys(
authPath,
encPath,
cfg.Session.Name,
cfg.HttpServer.ProductionMode,
)
}
func generateRandomBytes(length int) ([]byte, error) {
b := make([]byte, length)
_, err := rand.Read(b)
if err != nil {
logging.Error("failed to generate random bytes: %w", err)
return nil, err
}
return b, nil
}
func loadSessionKeys(authPath, encryptionPath, name string, isProduction bool) error {
var err error
rawAuth, err := os.ReadFile(authPath)
if err != nil {
return fmt.Errorf("error reading auth key: %w", err)
}
authKey, err = base64.StdEncoding.DecodeString(string(bytes.TrimSpace(rawAuth)))
if err != nil {
return fmt.Errorf("error decoding auth key: %w", err)
}
rawEnc, err := os.ReadFile(encryptionPath)
if err != nil {
return fmt.Errorf("error reading encryption key: %w", err)
}
encryptKey, err = base64.StdEncoding.DecodeString(string(bytes.TrimSpace(rawEnc)))
if err != nil {
return fmt.Errorf("error decoding encryption key: %w", err)
}
if len(authKey) != 32 || len(encryptKey) != 32 {
return fmt.Errorf("auth and encryption keys must be 32 bytes each (got auth=%d, enc=%d)", len(authKey), len(encryptKey))
}
sessionHandlers.SessionStore = sessions.NewCookieStore(authKey, encryptKey)
sessionHandlers.SessionStore.Options = &sessions.Options{
Path: "/",
MaxAge: 86400,
HttpOnly: true,
Secure: isProduction,
SameSite: http.SameSiteLaxMode,
}
sessionHandlers.Name = name
return nil
}

View File

@@ -0,0 +1,22 @@
package config
import (
"sync"
"synlotto-website/models"
)
var (
appConfig *models.Config
once sync.Once
)
func Init(config *models.Config) {
once.Do(func() {
appConfig = config
})
}
func Get() *models.Config {
return appConfig
}

View File

@@ -0,0 +1,5 @@
package constants
import "time"
const SessionDuration = 30 * time.Minute

View File

@@ -0,0 +1,43 @@
package rules
import (
"synlotto-website/internal/models"
"synlotto-website/internal/rules"
)
var ThunderballPrizeRules = []models.PrizeRule{
{Game: rules.GameThunderball, MainMatches: 0, BonusMatches: 1, Tier: "Tier 1"},
{Game: rules.GameThunderball, MainMatches: 1, BonusMatches: 1, Tier: "Tier 2"},
{Game: rules.GameThunderball, MainMatches: 2, BonusMatches: 1, Tier: "Tier 3"},
{Game: rules.GameThunderball, MainMatches: 3, BonusMatches: 0, Tier: "Tier 4"},
{Game: rules.GameThunderball, MainMatches: 3, BonusMatches: 1, Tier: "Tier 5"},
{Game: rules.GameThunderball, MainMatches: 4, BonusMatches: 0, Tier: "Tier 6"},
{Game: rules.GameThunderball, MainMatches: 4, BonusMatches: 1, Tier: "Tier 7"},
{Game: rules.GameThunderball, MainMatches: 5, BonusMatches: 0, Tier: "Second"},
{Game: rules.GameThunderball, MainMatches: 5, BonusMatches: 1, Tier: "Jackpot"},
}
func GetThunderballPrizeIndex(main, bonus int) (int, bool) {
switch {
case main == 0 && bonus == 1:
return 9, true
case main == 1 && bonus == 1:
return 8, true
case main == 2 && bonus == 1:
return 7, true
case main == 3 && bonus == 0:
return 6, true
case main == 3 && bonus == 1:
return 5, true
case main == 4 && bonus == 0:
return 4, true
case main == 4 && bonus == 1:
return 3, true
case main == 5 && bonus == 0:
return 2, true
case main == 5 && bonus == 1:
return 1, true
default:
return 0, false
}
}

23
internal/rules/types.go Normal file
View File

@@ -0,0 +1,23 @@
package rules
// PrizeRule defines how many matches correspond to which prize tier.
type PrizeRule struct {
Game string
MainMatches int
BonusMatches int
Tier string
}
// ToDo: should this struct not be in a model~?
// PrizeInfo describes the tier and payout details.
type PrizeInfo struct {
Tier string
Amount float64
Label string
}
// Game names (use constants to avoid typos). ToDo: should this not be in my constants folder or avoid constands folder as it may end up as a junk draw
const (
GameThunderball = "Thunderball"
GameEuroJackpot = "EuroJackpot"
)

View File

@@ -0,0 +1,38 @@
package services
import (
"database/sql"
"log"
"synlotto-website/models"
)
func GetDrawResultForTicket(db *sql.DB, game string, drawDate string) models.DrawResult {
var result models.DrawResult
if game != "Thunderball" {
log.Printf("Draw lookup for unsupported game type: %s", game)
return result
}
query := `
SELECT id, ball1, ball2, ball3, ball4, ball5, thunderball
FROM results_thunderball
WHERE draw_date = ?
`
var b1, b2, b3, b4, b5, tb sql.NullInt64
err := db.QueryRow(query, drawDate).Scan(&result.DrawID, &b1, &b2, &b3, &b4, &b5, &tb)
if err != nil {
log.Printf("No draw found for %s %s: %v", game, drawDate, err)
return result
}
result.GameType = game
result.DrawDate = drawDate
result.Balls = []int{int(b1.Int64), int(b2.Int64), int(b3.Int64), int(b4.Int64), int(b5.Int64)}
if tb.Valid {
result.BonusBalls = []int{int(tb.Int64)}
}
return result
}

View File

@@ -0,0 +1,54 @@
package matcher
import (
"database/sql"
"fmt"
"synlotto-website/helpers"
"synlotto-website/models"
thunderballRules "synlotto-website/rules"
)
func MatchTicketToDraw(ticket models.MatchTicket, draw models.DrawResult, rules []models.PrizeRule, db *sql.DB) models.MatchResult {
mainMatches := helpers.CountMatches(ticket.Balls, draw.Balls)
bonusMatches := helpers.CountMatches(ticket.BonusBalls, draw.BonusBalls)
prizeTier := GetPrizeTier(ticket.GameType, mainMatches, bonusMatches, rules)
isWinner := prizeTier != ""
result := models.MatchResult{
MatchedDrawID: draw.DrawID,
MatchedMain: mainMatches,
MatchedBonus: bonusMatches,
PrizeTier: prizeTier,
IsWinner: isWinner,
}
if ticket.GameType == "Thunderball" && isWinner {
if idx, ok := thunderballRules.GetThunderballPrizeIndex(mainMatches, bonusMatches); ok {
query := fmt.Sprintf(`SELECT prize%d_per_winner FROM prizes_thunderball WHERE draw_date = ?`, idx)
var amount int
err := db.QueryRow(query, draw.DrawDate).Scan(&amount)
if err == nil {
result.PrizeAmount = float64(amount)
if amount == 0 {
result.PrizeLabel = "Free Ticket"
} else {
result.PrizeLabel = fmt.Sprintf("£%.2f", float64(amount))
}
}
}
}
return result
}
func GetPrizeTier(game string, main, bonus int, rules []models.PrizeRule) string {
for _, rule := range rules {
if rule.Game == game && rule.MainMatches == main && rule.BonusMatches == bonus {
return rule.Tier
}
}
return ""
}

View File

@@ -0,0 +1,258 @@
package services
import (
"database/sql"
"fmt"
"log"
lotteryTicketHandlers "synlotto-website/handlers/lottery/tickets"
thunderballrules "synlotto-website/rules"
services "synlotto-website/services/draws"
"synlotto-website/helpers"
"synlotto-website/matcher"
"synlotto-website/models"
)
func RunTicketMatching(db *sql.DB, triggeredBy string) (models.MatchRunStats, error) {
stats := models.MatchRunStats{}
rows, err := db.Query(`
SELECT id, game_type, draw_date,
ball1, ball2, ball3, ball4, ball5, ball6,
bonus1, bonus2
FROM my_tickets
WHERE matched_main IS NULL
`)
if err != nil {
return stats, err
}
defer rows.Close()
var pending []models.Ticket
for rows.Next() {
var t models.Ticket
var b1, b2, b3, b4, b5, b6, bo1, bo2 sql.NullInt64
if err := rows.Scan(
&t.Id, &t.GameType, &t.DrawDate,
&b1, &b2, &b3, &b4, &b5, &b6,
&bo1, &bo2,
); err != nil {
continue
}
t.Ball1 = int(b1.Int64)
t.Ball2 = int(b2.Int64)
t.Ball3 = int(b3.Int64)
t.Ball4 = int(b4.Int64)
t.Ball5 = int(b5.Int64)
t.Ball6 = int(b6.Int64)
t.Bonus1 = helpers.IntPtrIfValid(bo1)
t.Bonus2 = helpers.IntPtrIfValid(bo2)
pending = append(pending, t)
}
for _, t := range pending {
matchTicket := models.MatchTicket{
ID: t.Id,
GameType: t.GameType,
DrawDate: t.DrawDate,
Balls: helpers.BuildBallsSlice(t),
BonusBalls: helpers.BuildBonusSlice(t),
}
draw := services.GetDrawResultForTicket(db, t.GameType, t.DrawDate)
result := lotteryTicketHandlers.MatchTicketToDraw(matchTicket, draw, thunderballrules.ThunderballPrizeRules)
if result.MatchedDrawID == 0 {
continue
}
_, err := db.Exec(`
UPDATE my_tickets
SET matched_main = ?, matched_bonus = ?, prize_tier = ?, is_winner = ?
WHERE id = ?
`, result.MatchedMain, result.MatchedBonus, result.PrizeTier, result.IsWinner, t.Id)
if err != nil {
log.Println("⚠️ Failed to update ticket match:", err)
continue
}
stats.TicketsMatched++
if result.IsWinner {
stats.WinnersFound++
}
}
_, _ = db.Exec(`
INSERT INTO log_ticket_matching (triggered_by, tickets_matched, winners_found)
VALUES (?, ?, ?)
`, triggeredBy, stats.TicketsMatched, stats.WinnersFound)
return stats, nil
}
func UpdateMissingPrizes(db *sql.DB) error {
type TicketInfo struct {
ID int
GameType string
DrawDate string
Main int
Bonus int
}
var tickets []TicketInfo
rows, err := db.Query(`
SELECT id, game_type, draw_date, matched_main, matched_bonus
FROM my_tickets
WHERE is_winner = 1 AND (prize_label IS NULL OR prize_label = '')
`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var t TicketInfo
if err := rows.Scan(&t.ID, &t.GameType, &t.DrawDate, &t.Main, &t.Bonus); err != nil {
log.Println("⚠️ Failed to scan row:", err)
continue
}
tickets = append(tickets, t)
}
for _, t := range tickets {
if t.GameType != "Thunderball" {
continue
}
idx, ok := thunderballrules.GetThunderballPrizeIndex(t.Main, t.Bonus)
if !ok {
log.Printf("❌ No index for %d main, %d bonus", t.Main, t.Bonus)
continue
}
query := fmt.Sprintf(`SELECT prize%d_per_winner FROM prizes_thunderball WHERE draw_date = ?`, idx)
var amount int
err := db.QueryRow(query, t.DrawDate).Scan(&amount)
if err != nil {
log.Printf("❌ Prize lookup failed for ticket %d: %v", t.ID, err)
continue
}
label := "Free Ticket"
if amount > 0 {
label = fmt.Sprintf("£%.2f", float64(amount))
}
_, err = db.Exec(`
UPDATE my_tickets SET prize_amount = ?, prize_label = ? WHERE id = ?
`, float64(amount), label, t.ID)
if err != nil {
log.Printf("❌ Failed to update ticket %d: %v", t.ID, err)
} else {
log.Printf("✅ Updated ticket %d → %s", t.ID, label)
}
}
return nil
}
func RefreshTicketPrizes(db *sql.DB) error {
type TicketRow struct {
ID int
GameType string
DrawDate string
B1, B2, B3, B4, B5, B6 sql.NullInt64
Bonus1, Bonus2 sql.NullInt64
}
var tickets []TicketRow
rows, err := db.Query(`
SELECT id, game_type, draw_date,
ball1, ball2, ball3, ball4, ball5, ball6,
bonus1, bonus2
FROM my_tickets
`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var t TicketRow
if err := rows.Scan(&t.ID, &t.GameType, &t.DrawDate,
&t.B1, &t.B2, &t.B3, &t.B4, &t.B5, &t.B6, &t.Bonus1, &t.Bonus2); err != nil {
log.Println("⚠️ Failed to scan ticket:", err)
continue
}
tickets = append(tickets, t)
}
rows.Close()
for _, row := range tickets {
matchTicket := models.MatchTicket{
GameType: row.GameType,
DrawDate: row.DrawDate,
Balls: helpers.BuildBallsFromNulls(row.B1, row.B2, row.B3, row.B4, row.B5, row.B6),
BonusBalls: helpers.BuildBonusFromNulls(row.Bonus1, row.Bonus2),
}
draw := services.GetDrawResultForTicket(db, row.GameType, row.DrawDate)
if draw.DrawID == 0 {
log.Printf("❌ No draw result for %s (%s)", row.DrawDate, row.GameType)
continue
}
mainMatches := helpers.CountMatches(matchTicket.Balls, draw.Balls)
bonusMatches := helpers.CountMatches(matchTicket.BonusBalls, draw.BonusBalls)
prizeTier := matcher.GetPrizeTier(row.GameType, mainMatches, bonusMatches, thunderballrules.ThunderballPrizeRules)
isWinner := prizeTier != ""
var label string
var amount float64
if row.GameType == "Thunderball" {
idx, ok := thunderballrules.GetThunderballPrizeIndex(mainMatches, bonusMatches)
if ok {
query := fmt.Sprintf(`SELECT prize%d_per_winner FROM prizes_thunderball WHERE draw_date = ?`, idx)
var val int
err := db.QueryRow(query, row.DrawDate).Scan(&val)
if err == nil {
amount = float64(val)
if val > 0 {
label = fmt.Sprintf("£%.2f", amount)
} else {
label = "Free Ticket"
}
}
}
}
log.Printf("🧪 Ticket %d → Matches: %d+%d, Tier: %s, Winner: %v, Label: %s, Amount: %.2f",
row.ID, mainMatches, bonusMatches, prizeTier, isWinner, label, amount)
res, err := db.Exec(`
UPDATE my_tickets
SET matched_main = ?, matched_bonus = ?, prize_tier = ?, is_winner = ?, prize_amount = ?, prize_label = ?
WHERE id = ?
`, mainMatches, bonusMatches, prizeTier, isWinner, amount, label, row.ID)
if err != nil {
log.Printf("❌ Failed to update ticket %d: %v", row.ID, err)
continue
}
rowsAffected, _ := res.RowsAffected()
log.Printf("✅ Ticket %d updated — rows affected: %d | Tier: %s | Label: %s | Matches: %d+%d",
row.ID, rowsAffected, prizeTier, label, mainMatches, bonusMatches)
}
return nil
}

View File

@@ -0,0 +1,56 @@
package storage
import (
"database/sql"
"log"
"net/http"
"time"
securityHelpers "synlotto-website/helpers/security"
templateHelpers "synlotto-website/helpers/template"
"synlotto-website/internal/logging"
"synlotto-website/middleware"
)
func AdminOnly(db *sql.DB, next http.HandlerFunc) http.HandlerFunc {
return middleware.Auth(true)(func(w http.ResponseWriter, r *http.Request) {
userID, ok := securityHelpers.GetCurrentUserID(r)
if !ok || !securityHelpers.IsAdmin(db, userID) {
log.Printf("⛔️ Unauthorized admin attempt: user_id=%v, IP=%s, Path=%s", userID, r.RemoteAddr, r.URL.Path)
templateHelpers.RenderError(w, r, http.StatusForbidden)
return
}
ip := r.RemoteAddr
ua := r.UserAgent()
path := r.URL.Path
_, err := db.Exec(`
INSERT INTO admin_access_log (user_id, path, ip, user_agent)
VALUES (?, ?, ?, ?)`,
userID, path, ip, ua,
)
if err != nil {
log.Printf("⚠️ Failed to log admin access: %v", err)
}
log.Printf("🛡️ Admin access: user_id=%d IP=%s Path=%s", userID, ip, path)
next(w, r)
})
}
func LogLoginAttempt(r *http.Request, username string, success bool) {
ip := r.RemoteAddr
userAgent := r.UserAgent()
_, err := db.Exec(
`INSERT INTO audit_login (username, success, ip, user_agent, timestamp)
VALUES (?, ?, ?, ?, ?)`,
username, success, ip, userAgent, time.Now().UTC(),
)
if err != nil {
logging.Info("❌ Failed to log login:", err)
}
}

View File

@@ -0,0 +1,78 @@
package storage
import (
"database/sql"
"embed"
"fmt"
"log"
"os"
_ "github.com/go-sql-driver/mysql"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/mysql"
iofs "github.com/golang-migrate/migrate/v4/source/iofs"
)
//go:embed migrations/*.sql
var migrationFiles embed.FS
var DB *sql.DB
// InitDB connects to MySQL, runs migrations, and returns the DB handle.
func InitDB() *sql.DB {
cfg := getDSNFromEnv()
db, err := sql.Open("mysql", cfg)
if err != nil {
log.Fatalf("❌ Failed to connect to MySQL: %v", err)
}
if err := db.Ping(); err != nil {
log.Fatalf("❌ MySQL not reachable: %v", err)
}
if err := runMigrations(db); err != nil {
log.Fatalf("❌ Migration failed: %v", err)
}
DB = db
return db
}
// runMigrations applies any pending .sql files in migrations/
func runMigrations(db *sql.DB) error {
driver, err := mysql.WithInstance(db, &mysql.Config{})
if err != nil {
return err
}
src, err := iofs.New(migrationFiles, "migrations")
if err != nil {
return err
}
m, err := migrate.NewWithInstance("iofs", src, "mysql", driver)
if err != nil {
return err
}
err = m.Up()
if err == migrate.ErrNoChange {
log.Println("✅ Database schema up to date.")
return nil
}
return err
}
func getDSNFromEnv() string {
user := os.Getenv("DB_USER")
pass := os.Getenv("DB_PASS")
host := os.Getenv("DB_HOST") // e.g. localhost or 127.0.0.1
port := os.Getenv("DB_PORT") // e.g. 3306
name := os.Getenv("DB_NAME") // e.g. synlotto
params := "parseTime=true&multiStatements=true"
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?%s",
user, pass, host, port, name, params)
return dsn
}

View File

@@ -0,0 +1,13 @@
package storage
import (
"database/sql"
)
func SendMessage(db *sql.DB, senderID, recipientID int, subject, message string) error {
_, err := db.Exec(`
INSERT INTO users_messages (senderId, recipientId, subject, message)
VALUES (?, ?, ?, ?)
`, senderID, recipientID, subject, message)
return err
}

View File

@@ -0,0 +1,2 @@
//Currently no delete functions, only archiving to remove from user view but they can pull them back. Consider a soft delete which hides them from being unarchived for 5 years?
// Then systematically delete after 5 years? or delete sooner but retain backups

View File

@@ -0,0 +1,132 @@
package storage
import (
"database/sql"
"synlotto-website/internal/models"
)
func GetMessageCount(db *sql.DB, userID int) (int, error) {
var count int
err := db.QueryRow(`
SELECT COUNT(*) FROM users_messages
WHERE recipientId = ? AND is_read = FALSE AND is_archived = FALSE
`, userID).Scan(&count)
return count, err
}
func GetRecentMessages(db *sql.DB, userID int, limit int) []models.Message {
rows, err := db.Query(`
SELECT id, senderId, recipientId, subject, message, is_read, created_at
FROM users_messages
WHERE recipientId = ? AND is_archived = FALSE
ORDER BY created_at DESC
LIMIT ?
`, userID, limit)
if err != nil {
return nil
}
defer rows.Close()
var messages []models.Message
for rows.Next() {
var m models.Message
err := rows.Scan(
&m.ID,
&m.SenderId,
&m.RecipientId,
&m.Subject,
&m.Message,
&m.IsRead,
&m.CreatedAt,
)
if err == nil {
messages = append(messages, m)
}
}
return messages
}
func GetMessageByID(db *sql.DB, userID, messageID int) (*models.Message, error) {
row := db.QueryRow(`
SELECT id, senderId, recipientId, subject, message, is_read, created_at
FROM users_messages
WHERE id = ? AND recipientId = ?
`, messageID, userID)
var m models.Message
err := row.Scan(&m.ID, &m.SenderId, &m.RecipientId, &m.Subject, &m.Message, &m.IsRead, &m.CreatedAt)
if err != nil {
return nil, err
}
return &m, nil
}
func GetArchivedMessages(db *sql.DB, userID int, page, perPage int) []models.Message {
offset := (page - 1) * perPage
rows, err := db.Query(`
SELECT id, senderId, recipientId, subject, message, is_read, created_at, archived_at
FROM users_messages
WHERE recipientId = ? AND is_archived = TRUE
ORDER BY archived_at DESC
LIMIT ? OFFSET ?
`, userID, perPage, offset)
if err != nil {
return nil
}
defer rows.Close()
var messages []models.Message
for rows.Next() {
var m models.Message
err := rows.Scan(
&m.ID, &m.SenderId, &m.RecipientId,
&m.Subject, &m.Message, &m.IsRead,
&m.CreatedAt, &m.ArchivedAt,
)
if err == nil {
messages = append(messages, m)
}
}
return messages
}
func GetInboxMessages(db *sql.DB, userID int, page, perPage int) []models.Message {
offset := (page - 1) * perPage
rows, err := db.Query(`
SELECT id, senderId, recipientId, subject, message, is_read, created_at
FROM users_messages
WHERE recipientId = ? AND is_archived = FALSE
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`, userID, perPage, offset)
if err != nil {
return nil
}
defer rows.Close()
var messages []models.Message
for rows.Next() {
var m models.Message
err := rows.Scan(
&m.ID, &m.SenderId, &m.RecipientId,
&m.Subject, &m.Message, &m.IsRead, &m.CreatedAt,
)
if err == nil {
messages = append(messages, m)
}
}
return messages
}
func GetInboxMessageCount(db *sql.DB, userID int) int {
var count int
err := db.QueryRow(`
SELECT COUNT(*) FROM users_messages
WHERE recipientId = ? AND is_archived = FALSE
`, userID).Scan(&count)
if err != nil {
return 0
}
return count
}

View File

@@ -0,0 +1,44 @@
package storage
import (
"database/sql"
"fmt"
)
func ArchiveMessage(db *sql.DB, userID, messageID int) error {
_, err := db.Exec(`
UPDATE users_messages
SET is_archived = TRUE, archived_at = CURRENT_TIMESTAMP
WHERE id = ? AND recipientId = ?
`, messageID, userID)
return err
}
func MarkMessageAsRead(db *sql.DB, messageID, userID int) error {
result, err := db.Exec(`
UPDATE users_messages
SET is_read = TRUE
WHERE id = ? AND recipientId = ?
`, messageID, userID)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return fmt.Errorf("no matching message found for user_id=%d and message_id=%d", userID, messageID)
}
return nil
}
func RestoreMessage(db *sql.DB, userID, messageID int) error {
_, err := db.Exec(`
UPDATE users_messages
SET is_archived = FALSE, archived_at = NULL
WHERE id = ? AND recipientId = ?
`, messageID, userID)
return err
}

View File

@@ -0,0 +1,281 @@
-- 0001_initial.up.sql
-- Engine/charset notes:
-- - InnoDB for FK support
-- - utf8mb4 for full Unicode
-- Booleans are TINYINT(1). Dates use DATE/DATETIME/TIMESTAMP as appropriate.
-- USERS
CREATE TABLE IF NOT EXISTS users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(191) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
is_admin TINYINT(1) NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- THUNDERBALL RESULTS
CREATE TABLE IF NOT EXISTS results_thunderball (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
draw_date DATE NOT NULL UNIQUE,
draw_id BIGINT UNSIGNED NOT NULL UNIQUE,
machine VARCHAR(50),
ballset VARCHAR(50),
ball1 TINYINT UNSIGNED,
ball2 TINYINT UNSIGNED,
ball3 TINYINT UNSIGNED,
ball4 TINYINT UNSIGNED,
ball5 TINYINT UNSIGNED,
thunderball TINYINT UNSIGNED
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- THUNDERBALL PRIZES
CREATE TABLE IF NOT EXISTS prizes_thunderball (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
draw_id BIGINT UNSIGNED NOT NULL,
draw_date DATE,
prize1 VARCHAR(50),
prize1_winners INT UNSIGNED,
prize1_per_winner INT UNSIGNED,
prize1_fund BIGINT UNSIGNED,
prize2 VARCHAR(50),
prize2_winners INT UNSIGNED,
prize2_per_winner INT UNSIGNED,
prize2_fund BIGINT UNSIGNED,
prize3 VARCHAR(50),
prize3_winners INT UNSIGNED,
prize3_per_winner INT UNSIGNED,
prize3_fund BIGINT UNSIGNED,
prize4 VARCHAR(50),
prize4_winners INT UNSIGNED,
prize4_per_winner INT UNSIGNED,
prize4_fund BIGINT UNSIGNED,
prize5 VARCHAR(50),
prize5_winners INT UNSIGNED,
prize5_per_winner INT UNSIGNED,
prize5_fund BIGINT UNSIGNED,
prize6 VARCHAR(50),
prize6_winners INT UNSIGNED,
prize6_per_winner INT UNSIGNED,
prize6_fund BIGINT UNSIGNED,
prize7 VARCHAR(50),
prize7_winners INT UNSIGNED,
prize7_per_winner INT UNSIGNED,
prize7_fund BIGINT UNSIGNED,
prize8 VARCHAR(50),
prize8_winners INT UNSIGNED,
prize8_per_winner INT UNSIGNED,
prize8_fund BIGINT UNSIGNED,
prize9 VARCHAR(50),
prize9_winners INT UNSIGNED,
prize9_per_winner INT UNSIGNED,
prize9_fund BIGINT UNSIGNED,
total_winners INT UNSIGNED,
total_prize_fund BIGINT UNSIGNED,
CONSTRAINT fk_prizes_tb_drawdate
FOREIGN KEY (draw_date) REFERENCES results_thunderball(draw_date)
ON UPDATE CASCADE ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- LOTTO RESULTS
CREATE TABLE IF NOT EXISTS results_lotto (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
draw_date DATE NOT NULL UNIQUE,
draw_id BIGINT UNSIGNED NOT NULL UNIQUE,
machine VARCHAR(50),
ballset VARCHAR(50),
ball1 TINYINT UNSIGNED,
ball2 TINYINT UNSIGNED,
ball3 TINYINT UNSIGNED,
ball4 TINYINT UNSIGNED,
ball5 TINYINT UNSIGNED,
ball6 TINYINT UNSIGNED,
bonusball TINYINT UNSIGNED
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- MY TICKETS
CREATE TABLE IF NOT EXISTS my_tickets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
userId BIGINT UNSIGNED NOT NULL,
game_type VARCHAR(32) NOT NULL,
draw_date DATE NOT NULL,
ball1 TINYINT UNSIGNED,
ball2 TINYINT UNSIGNED,
ball3 TINYINT UNSIGNED,
ball4 TINYINT UNSIGNED,
ball5 TINYINT UNSIGNED,
ball6 TINYINT UNSIGNED,
bonus1 TINYINT UNSIGNED,
bonus2 TINYINT UNSIGNED,
duplicate TINYINT(1) NOT NULL DEFAULT 0,
purchase_date DATETIME,
purchase_method VARCHAR(50),
image_path VARCHAR(255),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
matched_main TINYINT UNSIGNED,
matched_bonus TINYINT UNSIGNED,
prize_tier VARCHAR(50),
is_winner TINYINT(1) NOT NULL DEFAULT 0,
prize_amount BIGINT,
prize_label VARCHAR(100),
syndicate_id BIGINT UNSIGNED,
CONSTRAINT fk_my_tickets_user
FOREIGN KEY (userId) REFERENCES users(id)
ON UPDATE CASCADE ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- USERS MESSAGES
CREATE TABLE IF NOT EXISTS users_messages (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
senderId BIGINT UNSIGNED NOT NULL,
recipientId BIGINT UNSIGNED NOT NULL,
subject VARCHAR(255) NOT NULL,
message MEDIUMTEXT,
is_read TINYINT(1) NOT NULL DEFAULT 0,
is_archived TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
archived_at DATETIME NULL,
CONSTRAINT fk_users_messages_sender
FOREIGN KEY (senderId) REFERENCES users(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_users_messages_recipient
FOREIGN KEY (recipientId) REFERENCES users(id)
ON UPDATE CASCADE ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- USERS NOTIFICATIONS
CREATE TABLE IF NOT EXISTS users_notification (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
subject VARCHAR(255),
body MEDIUMTEXT,
is_read TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_users_notification_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON UPDATE CASCADE ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- AUDITLOG
CREATE TABLE IF NOT EXISTS auditlog (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(191),
success TINYINT(1),
timestamp DATETIME
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- LOG: TICKET MATCHING
CREATE TABLE IF NOT EXISTS log_ticket_matching (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
triggered_by VARCHAR(191),
run_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
tickets_matched INT,
winners_found INT,
notes TEXT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ADMIN ACCESS LOG
CREATE TABLE IF NOT EXISTS admin_access_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED,
accessed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
path VARCHAR(255),
ip VARCHAR(64),
user_agent VARCHAR(255),
CONSTRAINT fk_admin_access_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON UPDATE CASCADE ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- AUDIT LOG (new)
CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED,
username VARCHAR(191),
action VARCHAR(191),
path VARCHAR(255),
ip VARCHAR(64),
user_agent VARCHAR(255),
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_audit_log_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON UPDATE CASCADE ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- AUDIT LOGIN (new)
CREATE TABLE IF NOT EXISTS audit_login (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(191),
success TINYINT(1),
ip VARCHAR(64),
user_agent VARCHAR(255),
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- SYNDICATES
CREATE TABLE IF NOT EXISTS syndicates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(191) NOT NULL,
description TEXT,
owner_id BIGINT UNSIGNED NOT NULL,
join_code VARCHAR(191) UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_syndicates_owner
FOREIGN KEY (owner_id) REFERENCES users(id)
ON UPDATE CASCADE ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- SYNDICATE MEMBERS
CREATE TABLE IF NOT EXISTS syndicate_members (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
syndicate_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
role VARCHAR(32) NOT NULL DEFAULT 'member',
status VARCHAR(32) NOT NULL DEFAULT 'active',
joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_synmem_syn
FOREIGN KEY (syndicate_id) REFERENCES syndicates(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_synmem_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT uq_synmem UNIQUE (syndicate_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- SYNDICATE INVITES
CREATE TABLE IF NOT EXISTS syndicate_invites (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
syndicate_id BIGINT UNSIGNED NOT NULL,
invited_user_id BIGINT UNSIGNED NOT NULL,
sent_by_user_id BIGINT UNSIGNED NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_syninv_syn
FOREIGN KEY (syndicate_id) REFERENCES syndicates(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_syninv_invited
FOREIGN KEY (invited_user_id) REFERENCES users(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_syninv_sender
FOREIGN KEY (sent_by_user_id) REFERENCES users(id)
ON UPDATE CASCADE ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- SYNDICATE INVITE TOKENS
CREATE TABLE IF NOT EXISTS syndicate_invite_tokens (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
syndicate_id BIGINT UNSIGNED NOT NULL,
token VARCHAR(191) NOT NULL UNIQUE,
invited_by_user_id BIGINT UNSIGNED NOT NULL,
accepted_by_user_id BIGINT UNSIGNED,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
accepted_at DATETIME,
expires_at DATETIME,
CONSTRAINT fk_syninvtoken_syn
FOREIGN KEY (syndicate_id) REFERENCES syndicates(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_syninvtoken_invitedby
FOREIGN KEY (invited_by_user_id) REFERENCES users(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_syninvtoken_acceptedby
FOREIGN KEY (accepted_by_user_id) REFERENCES users(id)
ON UPDATE CASCADE ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -0,0 +1,3 @@
package storage
// ToDo: somethign must create notifications?

View File

@@ -0,0 +1,3 @@
package storage
// ToDo: not used, check messages and do something similar maybe dont store them?

View File

@@ -0,0 +1,63 @@
package storage
//ToDo: should be using my own logging wrapper?
import (
"database/sql"
"log"
"synlotto-website/internal/models"
)
func GetNotificationByID(db *sql.DB, userID, notificationID int) (*models.Notification, error) {
row := db.QueryRow(`
SELECT id, user_id, subject, body, is_read
FROM users_notification
WHERE id = ? AND user_id = ?
`, notificationID, userID)
var n models.Notification
err := row.Scan(&n.ID, &n.UserId, &n.Subject, &n.Body, &n.IsRead)
if err != nil {
return nil, err
}
return &n, nil
}
func GetNotificationCount(db *sql.DB, userID int) int {
var count int
err := db.QueryRow(`
SELECT COUNT(*) FROM users_notification
WHERE user_id = ? AND is_read = FALSE`, userID).Scan(&count)
if err != nil {
log.Println("⚠️ Failed to count notifications:", err)
return 0
}
return count
}
func GetRecentNotifications(db *sql.DB, userID int, limit int) []models.Notification {
rows, err := db.Query(`
SELECT id, subject, body, is_read, created_at
FROM users_notification
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT ?`, userID, limit)
if err != nil {
log.Println("⚠️ Failed to get notifications:", err)
return nil
}
defer rows.Close()
var notifications []models.Notification
for rows.Next() {
var n models.Notification
if err := rows.Scan(&n.ID, &n.Subject, &n.Body, &n.IsRead, &n.CreatedAt); err == nil {
notifications = append(notifications, n)
}
}
return notifications
}

View File

@@ -0,0 +1,29 @@
package storage
// ToDo: Should be logging fmt to my loggign wrapper
import (
"database/sql"
"fmt"
)
func MarkNotificationAsRead(db *sql.DB, userID int, notificationID int) error {
result, err := db.Exec(`
UPDATE users_notification
SET is_read = TRUE
WHERE id = ? AND user_id = ?
`, notificationID, userID)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return fmt.Errorf("no matching notification for user_id=%d and id=%d", userID, notificationID)
}
return nil
}

View File

@@ -0,0 +1,30 @@
package storage
import (
"database/sql"
"log"
"strings"
"synlotto-website/internal/models"
)
func InsertThunderballResult(db *sql.DB, res models.ThunderballResult) error {
stmt := `
INSERT INTO results_thunderball (
draw_date, machine, ballset,
ball1, ball2, ball3, ball4, ball5, thunderball
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`
_, err := db.Exec(stmt,
res.DrawDate, res.Machine, res.BallSet,
res.Ball1, res.Ball2, res.Ball3, res.Ball4, res.Ball5, res.Thunderball,
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
log.Printf("⚠️ Draw for %s already exists. Skipping insert.\n", res.DrawDate)
return nil
}
log.Println("❌ InsertThunderballResult error:", err)
}
return err
}

View File

@@ -0,0 +1 @@
package storage

View File

@@ -0,0 +1,31 @@
package storage
import (
"database/sql"
"os"
)
// this is an example currenlty not true data
func Seed(db *sql.DB) error {
if _, err := db.Exec(`
INSERT INTO settings (k, v)
SELECT 'site_name', 'SynLotto'
WHERE NOT EXISTS (SELECT 1 FROM settings WHERE k='site_name');
`); err != nil {
// settings table is optional; remove if you don't use it
}
// 2) admin user (idempotent + secret from env)
adminUser := "admin"
adminHash := os.Getenv("ADMIN_BCRYPT_HASH") // or build from ADMIN_PASSWORD
if adminHash == "" {
// skip silently if you dont want to hard-require it
return nil
}
_, err := db.Exec(`
INSERT INTO users (username, password_hash, is_admin)
VALUES (?, ?, 1)
ON DUPLICATE KEY UPDATE is_admin=VALUES(is_admin)
`, adminUser, adminHash)
return err
}

View File

@@ -0,0 +1,21 @@
package storage
import (
"database/sql"
)
func AddMemberToSyndicate(db *sql.DB, syndicateID, userID int) error {
_, err := db.Exec(`
INSERT INTO syndicate_members (syndicate_id, user_id, joined_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
`, syndicateID, userID)
return err
}
func InviteUserToSyndicate(db *sql.DB, syndicateID, invitedUserID, senderID int) error {
_, err := db.Exec(`
INSERT INTO syndicate_invites (syndicate_id, invited_user_id, sent_by_user_id)
VALUES (?, ?, ?)
`, syndicateID, invitedUserID, senderID)
return err
}

View File

@@ -0,0 +1,146 @@
package storage
import (
"database/sql"
"synlotto-website/internal/models"
)
func GetInviteTokensForSyndicate(db *sql.DB, syndicateID int) []models.SyndicateInviteToken {
rows, err := db.Query(`
SELECT token, invited_by_user_id, accepted_by_user_id, created_at, expires_at, accepted_at
FROM syndicate_invite_tokens
WHERE syndicate_id = ?
ORDER BY created_at DESC
`, syndicateID)
if err != nil {
return nil
}
defer rows.Close()
var tokens []models.SyndicateInviteToken
for rows.Next() {
var t models.SyndicateInviteToken
_ = rows.Scan(
&t.Token,
&t.InvitedByUserID,
&t.AcceptedByUserID,
&t.CreatedAt,
&t.ExpiresAt,
&t.AcceptedAt,
)
tokens = append(tokens, t)
}
return tokens
}
func GetPendingSyndicateInvites(db *sql.DB, userID int) []models.SyndicateInvite {
rows, err := db.Query(`
SELECT id, syndicate_id, invited_user_id, sent_by_user_id, status, created_at
FROM syndicate_invites
WHERE invited_user_id = ? AND status = 'pending'
`, userID)
if err != nil {
return nil
}
defer rows.Close()
var invites []models.SyndicateInvite
for rows.Next() {
var i models.SyndicateInvite
rows.Scan(&i.ID, &i.SyndicateID, &i.InvitedUserID, &i.SentByUserID, &i.Status, &i.CreatedAt)
invites = append(invites, i)
}
return invites
}
func GetSyndicateByID(db *sql.DB, id int) (*models.Syndicate, error) {
row := db.QueryRow(`SELECT id, name, description, owner_id, created_at FROM syndicates WHERE id = ?`, id)
var s models.Syndicate
err := row.Scan(&s.ID, &s.Name, &s.Description, &s.OwnerID, &s.CreatedAt)
if err != nil {
return nil, err
}
return &s, nil
}
func GetSyndicatesByMember(db *sql.DB, userID int) []models.Syndicate {
rows, err := db.Query(`
SELECT s.id, s.name, s.description, s.created_at, s.owner_id
FROM syndicates s
JOIN syndicate_members m ON s.id = m.syndicate_id
WHERE m.user_id = ?`, userID)
if err != nil {
return nil
}
defer rows.Close()
var syndicates []models.Syndicate
for rows.Next() {
var s models.Syndicate
err := rows.Scan(&s.ID, &s.Name, &s.Description, &s.CreatedAt, &s.OwnerID)
if err == nil {
syndicates = append(syndicates, s)
}
}
return syndicates
}
func GetSyndicatesByOwner(db *sql.DB, ownerID int) []models.Syndicate {
rows, err := db.Query(`
SELECT id, name, description, created_at, owner_id
FROM syndicates
WHERE owner_id = ?`, ownerID)
if err != nil {
return nil
}
defer rows.Close()
var syndicates []models.Syndicate
for rows.Next() {
var s models.Syndicate
err := rows.Scan(&s.ID, &s.Name, &s.Description, &s.CreatedAt, &s.OwnerID)
if err == nil {
syndicates = append(syndicates, s)
}
}
return syndicates
}
func GetSyndicateMembers(db *sql.DB, syndicateID int) []models.SyndicateMember {
rows, err := db.Query(`
SELECT m.user_id, u.username, m.joined_at
FROM syndicate_members m
JOIN users u ON u.id = m.user_id
WHERE m.syndicate_id = ?
`, syndicateID)
if err != nil {
return nil
}
defer rows.Close()
var members []models.SyndicateMember
for rows.Next() {
var m models.SyndicateMember
err := rows.Scan(&m.UserID, &m.UserID, &m.JoinedAt)
if err == nil {
members = append(members, m)
}
}
return members
}
func IsSyndicateManager(db *sql.DB, syndicateID, userID int) bool {
var count int
err := db.QueryRow(`
SELECT COUNT(*) FROM syndicates
WHERE id = ? AND owner_id = ?
`, syndicateID, userID).Scan(&count)
return err == nil && count > 0
}
func IsSyndicateMember(db *sql.DB, syndicateID, userID int) bool {
var count int
err := db.QueryRow(`SELECT COUNT(*) FROM syndicate_members WHERE syndicate_id = ? AND user_id = ?`, syndicateID, userID).Scan(&count)
return err == nil && count > 0
}

View File

@@ -0,0 +1,14 @@
package storage
import (
"database/sql"
)
func UpdateInviteStatus(db *sql.DB, inviteID int, status string) error {
_, err := db.Exec(`
UPDATE syndicate_invites
SET status = ?
WHERE id = ?
`, status, inviteID)
return err
}

View File

@@ -0,0 +1,70 @@
package storage
import (
"database/sql"
"log"
"synlotto-website/internal/helpers"
"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 {
var bonus1Val interface{}
var bonus2Val interface{}
if ticket.Bonus1 != nil {
bonus1Val = helpers.Nullable(*ticket.Bonus1)
} else {
bonus1Val = nil
}
if ticket.Bonus2 != nil {
bonus2Val = helpers.Nullable(*ticket.Bonus2)
} else {
bonus2Val = nil
}
query := `
SELECT COUNT(*) FROM my_tickets
WHERE game_type = ? AND draw_date = ?
AND ball1 = ? AND ball2 = ? AND ball3 = ?
AND ball4 = ? AND ball5 = ? AND bonus1 IS ? AND bonus2 IS ?;`
var count int
err := db.QueryRow(query,
ticket.GameType,
ticket.DrawDate,
ticket.Ball1,
ticket.Ball2,
ticket.Ball3,
ticket.Ball4,
ticket.Ball5,
bonus1Val,
bonus2Val,
).Scan(&count)
isDuplicate := count > 0
insert := `
INSERT INTO my_tickets (
game_type, draw_date,
ball1, ball2, ball3, ball4, ball5,
bonus1, bonus2, duplicate
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`
_, err = db.Exec(insert,
ticket.GameType, ticket.DrawDate,
ticket.Ball1, ticket.Ball2, ticket.Ball3,
ticket.Ball4, ticket.Ball5,
bonus1Val, bonus2Val,
isDuplicate,
)
if err != nil {
log.Println("❌ Failed to insert ticket:", err)
} else if isDuplicate {
log.Println("⚠️ Duplicate ticket detected and flagged.")
}
return err
}

View File

@@ -0,0 +1,22 @@
package storage
// ToDo.. "errors" should this not be using my custom log wrapper
import (
"context"
"database/sql"
"errors"
"synlotto-website/models"
)
type UsersRepo struct{ db *sql.DB}
func NewUsersRepo(db *.sql.DB) *UsersRepo { return &UsersRepo{db: db} }
func (r *UsersRepo) Create(ctx context.Context, username, passwordHash string, isAdmin bool) error {
_, err := r.db.ExecContext(ctx,
`INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)`,
username, passwordHash, isAdmin,
)
return err
}

View File

@@ -0,0 +1,34 @@
package storage
import (
"database/sql"
"synlotto-website/internal/logging"
"synlotto-website/internal/models"
)
func GetUserByID(db *sql.DB, id int) *models.User {
row := db.QueryRow("SELECT id, username, password_hash, is_admin FROM users WHERE id = ?", id)
var user models.User
err := row.Scan(&user.Id, &user.Username, &user.PasswordHash, &user.IsAdmin)
if err != nil {
if err != sql.ErrNoRows {
logging.Error("DB error:", err)
}
return nil
}
return &user
}
func GetUserByUsername(db *sql.DB, username string) *models.User {
row := db.QueryRow(`SELECT id, username, password_hash, is_admin FROM users WHERE username = ?`, username)
var u models.User
err := row.Scan(&u.Id, &u.Username, &u.PasswordHash, &u.IsAdmin)
if err != nil {
return nil
}
return &u
}

View File

@@ -0,0 +1,53 @@
package storage
import (
"database/sql"
"log"
"synlotto-website/config"
"synlotto-website/logging"
_ "modernc.org/sqlite"
)
var db *sql.DB
func InitDB(filepath string) *sql.DB {
var err error
cfg := config.Get()
db, err = sql.Open("sqlite", filepath)
if err != nil {
log.Fatal("❌ Failed to open DB:", err)
}
schemas := []string{
SchemaUsers,
SchemaThunderballResults,
SchemaThunderballPrizes,
SchemaLottoResults,
SchemaMyTickets,
SchemaUsersMessages,
SchemaUsersNotifications,
SchemaAuditLog,
SchemaAuditLogin,
SchemaLogTicketMatching,
SchemaAdminAccessLog,
SchemaNewAuditLog,
SchemaSyndicates,
SchemaSyndicateMembers,
SchemaSyndicateInvites,
SchemaSyndicateInviteTokens,
}
if cfg == nil {
logging.Error("❌ config is nil — did config.Init() run before InitDB?")
panic("config not ready")
}
for _, stmt := range schemas {
if _, err := db.Exec(stmt); err != nil {
log.Fatalf("❌ Failed to apply schema: %v", err)
}
}
return db
}

View File

@@ -0,0 +1,238 @@
package storage
const SchemaUsers = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
is_admin BOOLEAN
);`
const SchemaThunderballResults = `
CREATE TABLE IF NOT EXISTS results_thunderball (
id INTEGER PRIMARY KEY AUTOINCREMENT,
draw_date TEXT NOT NULL UNIQUE,
draw_id INTEGER NOT NULL UNIQUE,
machine TEXT,
ballset TEXT,
ball1 INTEGER,
ball2 INTEGER,
ball3 INTEGER,
ball4 INTEGER,
ball5 INTEGER,
thunderball INTEGER
);`
const SchemaThunderballPrizes = `
CREATE TABLE IF NOT EXISTS prizes_thunderball (
id INTEGER PRIMARY KEY AUTOINCREMENT,
draw_id INTEGER NOT NULL,
draw_date TEXT,
prize1 TEXT,
prize1_winners INTEGER,
prize1_per_winner INTEGER,
prize1_fund INTEGER,
prize2 TEXT,
prize2_winners INTEGER,
prize2_per_winner INTEGER,
prize2_fund INTEGER,
prize3 TEXT,
prize3_winners INTEGER,
prize3_per_winner INTEGER,
prize3_fund INTEGER,
prize4 TEXT,
prize4_winners INTEGER,
prize4_per_winner INTEGER,
prize4_fund INTEGER,
prize5 TEXT,
prize5_winners INTEGER,
prize5_per_winner INTEGER,
prize5_fund INTEGER,
prize6 TEXT,
prize6_winners INTEGER,
prize6_per_winner INTEGER,
prize6_fund INTEGER,
prize7 TEXT,
prize7_winners INTEGER,
prize7_per_winner INTEGER,
prize7_fund INTEGER,
prize8 TEXT,
prize8_winners INTEGER,
prize8_per_winner INTEGER,
prize8_fund INTEGER,
prize9 TEXT,
prize9_winners INTEGER,
prize9_per_winner INTEGER,
prize9_fund INTEGER,
total_winners INTEGER,
total_prize_fund INTEGER,
FOREIGN KEY (draw_date) REFERENCES results_thunderball(draw_date)
);`
const SchemaLottoResults = `
CREATE TABLE IF NOT EXISTS results_lotto (
id INTEGER PRIMARY KEY AUTOINCREMENT,
draw_date TEXT NOT NULL UNIQUE,
draw_id INTEGER NOT NULL UNIQUE,
machine TEXT,
ballset TEXT,
ball1 INTEGER,
ball2 INTEGER,
ball3 INTEGER,
ball4 INTEGER,
ball5 INTEGER,
ball6 INTEGER,
bonusball INTEGER
);`
const SchemaMyTickets = `
CREATE TABLE IF NOT EXISTS my_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
userId INTEGER NOT NULL,
game_type TEXT NOT NULL,
draw_date TEXT NOT NULL,
ball1 INTEGER,
ball2 INTEGER,
ball3 INTEGER,
ball4 INTEGER,
ball5 INTEGER,
ball6 INTEGER,
bonus1 INTEGER,
bonus2 INTEGER,
duplicate BOOLEAN DEFAULT 0,
purchase_date TEXT,
purchase_method TEXT,
image_path TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
matched_main INTEGER,
matched_bonus INTEGER,
prize_tier TEXT,
is_winner BOOLEAN,
prize_amount INTEGER,
prize_label TEXT,
syndicate_id INTEGER,
FOREIGN KEY (userId) REFERENCES users(id)
);`
const SchemaUsersMessages = `
CREATE TABLE IF NOT EXISTS users_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
senderId INTEGER NOT NULL REFERENCES users(id),
recipientId INTEGER NOT NULL REFERENCES users(id),
subject TEXT NOT NULL,
message TEXT,
is_read BOOLEAN DEFAULT FALSE,
is_archived BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
archived_at TIMESTAMP
);`
const SchemaUsersNotifications = `
CREATE TABLE IF NOT EXISTS users_notification (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
subject TEXT,
body TEXT,
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`
const SchemaAuditLog = `
CREATE TABLE IF NOT EXISTS auditlog (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT,
success INTEGER,
timestamp TEXT
);`
const SchemaLogTicketMatching = `
CREATE TABLE IF NOT EXISTS log_ticket_matching (
id INTEGER PRIMARY KEY AUTOINCREMENT,
triggered_by TEXT,
run_at DATETIME DEFAULT CURRENT_TIMESTAMP,
tickets_matched INTEGER,
winners_found INTEGER,
notes TEXT
);`
const SchemaAdminAccessLog = `
CREATE TABLE IF NOT EXISTS admin_access_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
accessed_at DATETIME DEFAULT CURRENT_TIMESTAMP,
path TEXT,
ip TEXT,
user_agent TEXT
);`
const SchemaNewAuditLog = `
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
username TEXT,
action TEXT,
path TEXT,
ip TEXT,
user_agent TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
);`
const SchemaAuditLogin = `
CREATE TABLE IF NOT EXISTS audit_login (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT,
success BOOLEAN,
ip TEXT,
user_agent TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
);`
const SchemaSyndicates = `
CREATE TABLE IF NOT EXISTS syndicates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
owner_id INTEGER NOT NULL,
join_code TEXT UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (owner_id) REFERENCES users(id)
);`
const SchemaSyndicateMembers = `
CREATE TABLE IF NOT EXISTS syndicate_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
syndicate_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
role TEXT DEFAULT 'member', -- owner, manager, member
status TEXT DEFAULT 'active',
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (syndicate_id) REFERENCES syndicates(id),
FOREIGN KEY (user_id) REFERENCES users(id)
);`
const SchemaSyndicateInvites = `
CREATE TABLE IF NOT EXISTS syndicate_invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
syndicate_id INTEGER NOT NULL,
invited_user_id INTEGER NOT NULL,
sent_by_user_id INTEGER NOT NULL,
status TEXT DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(syndicate_id) REFERENCES syndicates(id),
FOREIGN KEY(invited_user_id) REFERENCES users(id)
);`
const SchemaSyndicateInviteTokens = `
CREATE TABLE IF NOT EXISTS syndicate_invite_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
syndicate_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
invited_by_user_id INTEGER NOT NULL,
accepted_by_user_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
accepted_at TIMESTAMP,
expires_at TIMESTAMP,
FOREIGN KEY (syndicate_id) REFERENCES syndicates(id),
FOREIGN KEY (invited_by_user_id) REFERENCES users(id),
FOREIGN KEY (accepted_by_user_id) REFERENCES users(id)
);`

View File

@@ -0,0 +1,125 @@
package storage
import (
"database/sql"
"fmt"
"synlotto-website/models"
"time"
)
// todo should be a ticket function?
func GetSyndicateTickets(db *sql.DB, syndicateID int) []models.Ticket {
rows, err := db.Query(`
SELECT id, userId, syndicateId, game_type, draw_date, ball1, ball2, ball3, ball4, ball5, ball6,
bonus1, bonus2, matched_main, matched_bonus, prize_tier, prize_amount, prize_label, is_winner
FROM my_tickets
WHERE syndicateId = ?
ORDER BY draw_date DESC
`, syndicateID)
if err != nil {
return nil
}
defer rows.Close()
var tickets []models.Ticket
for rows.Next() {
var t models.Ticket
err := rows.Scan(
&t.Id, &t.UserId, &t.SyndicateId, &t.GameType, &t.DrawDate,
&t.Ball1, &t.Ball2, &t.Ball3, &t.Ball4, &t.Ball5, &t.Ball6,
&t.Bonus1, &t.Bonus2, &t.MatchedMain, &t.MatchedBonus,
&t.PrizeTier, &t.PrizeAmount, &t.PrizeLabel, &t.IsWinner,
)
if err == nil {
tickets = append(tickets, t)
}
}
return tickets
}
// both a read and inset break up
func AcceptInvite(db *sql.DB, inviteID, userID int) error {
var syndicateID int
err := db.QueryRow(`
SELECT syndicate_id FROM syndicate_invites
WHERE id = ? AND invited_user_id = ? AND status = 'pending'
`, inviteID, userID).Scan(&syndicateID)
if err != nil {
return err
}
if err := UpdateInviteStatus(db, inviteID, "accepted"); err != nil {
return err
}
_, err = db.Exec(`
INSERT INTO syndicate_members (syndicate_id, user_id, joined_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
`, syndicateID, userID)
return err
}
func CreateSyndicate(db *sql.DB, ownerID int, name, description string) (int64, error) {
tx, err := db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
result, err := tx.Exec(`
INSERT INTO syndicates (name, description, owner_id, created_at)
VALUES (?, ?, ?, ?)
`, name, description, ownerID, time.Now())
if err != nil {
return 0, fmt.Errorf("failed to create syndicate: %w", err)
}
syndicateID, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("failed to get syndicate ID: %w", err)
}
_, err = tx.Exec(`
INSERT INTO syndicate_members (syndicate_id, user_id, role, joined_at)
VALUES (?, ?, 'manager', CURRENT_TIMESTAMP)
`, syndicateID, ownerID)
if err != nil {
return 0, fmt.Errorf("failed to add owner as member: %w", err)
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit failed: %w", err)
}
return syndicateID, nil
}
func InviteToSyndicate(db *sql.DB, inviterID, syndicateID int, username string) error {
var inviteeID int
err := db.QueryRow(`
SELECT id FROM users WHERE username = ?
`, username).Scan(&inviteeID)
if err == sql.ErrNoRows {
return fmt.Errorf("user not found")
} else if err != nil {
return err
}
var count int
err = db.QueryRow(`
SELECT COUNT(*) FROM syndicate_members
WHERE syndicate_id = ? AND user_id = ?
`, syndicateID, inviteeID).Scan(&count)
if err != nil {
return err
}
if count > 0 {
return fmt.Errorf("user already a member or invited")
}
_, err = db.Exec(`
INSERT INTO syndicate_members (syndicate_id, user_id, is_manager, status)
VALUES (?, ?, 0, 'invited')
`, syndicateID, inviteeID)
return err
}

View File

@@ -0,0 +1,266 @@
package storage
// ToDo: The last seen statistic is done in days, maybe change or add in how many draws x days ways for ease.
// Top 5 main numbers since inception of the game.
const top5AllTime = `
SELECT ball AS Number, COUNT(*) AS Frequency
FROM (
SELECT ball1 AS ball FROM results_thunderball
UNION ALL SELECT ball2 FROM results_thunderball
UNION ALL SELECT ball3 FROM results_thunderball
UNION ALL SELECT ball4 FROM results_thunderball
UNION ALL SELECT ball5 FROM results_thunderball
)
GROUP BY ball
ORDER BY Frequency DESC, Number
LIMIT 5;`
// Top 5 main numbers since the ball count change on May 9th 2010.
const top5Since = `
SELECT ball AS Number, COUNT(*) AS Frequency
FROM (
SELECT ball1 AS ball FROM results_thunderball WHERE date(draw_date) >= '2010-05-09'
UNION ALL SELECT ball2 FROM results_thunderball WHERE date(draw_date) >= '2010-05-09'
UNION ALL SELECT ball3 FROM results_thunderball WHERE date(draw_date) >= '2010-05-09'
UNION ALL SELECT ball4 FROM results_thunderball WHERE date(draw_date) >= '2010-05-09'
UNION ALL SELECT ball5 FROM results_thunderball WHERE date(draw_date) >= '2010-05-09'
)
GROUP BY ball
ORDER BY Frequency DESC, Number
LIMIT 5;`
// Top 5 main numbers in the last 180 draws.
const top5Last180draws = `
SELECT ball AS Number, COUNT(*) AS Frequency
FROM (
SELECT ball1 AS ball FROM (
SELECT * FROM results_thunderball ORDER BY date(draw_date) DESC LIMIT 180
)
UNION ALL
SELECT ball2 FROM (
SELECT * FROM results_thunderball ORDER BY date(draw_date) DESC LIMIT 180
)
UNION ALL
SELECT ball3 FROM (
SELECT * FROM results_thunderball ORDER BY date(draw_date) DESC LIMIT 180
)
UNION ALL
SELECT ball4 FROM (
SELECT * FROM results_thunderball ORDER BY date(draw_date) DESC LIMIT 180
)
UNION ALL
SELECT ball5 FROM (
SELECT * FROM results_thunderball ORDER BY date(draw_date) DESC LIMIT 180
)
)
GROUP BY ball
ORDER BY Frequency DESC
LIMIT 5;`
// The top 5 thunderballs drawn since the inception of the game.
const top5ThunderballAllTime = `
SELECT thunderball AS Number, COUNT(*) AS Frequency
FROM (
SELECT thunderball AS thunderball FROM results_thunderball
)
GROUP BY thunderball
ORDER BY Frequency DESC, Number
LIMIT 5;`
// The top 5 thunderballs drawn since the ball count change on May 9th 2010.
const top5ThunderballSince = `
SELECT thunderball AS Number, COUNT(*) AS Frequency
FROM (
SELECT thunderball AS thunderball FROM results_thunderball WHERE date(draw_date) >= '2010-05-09'
)
GROUP BY thunderball
ORDER BY Frequency DESC, Number
LIMIT 5;`
const top5TunderballLast180draws = `
SELECT thunderball AS Number, COUNT(*) AS Frequency
FROM (
SELECT thunderball AS thunderball FROM (
SELECT * FROM results_thunderball ORDER BY date(draw_date) DESC LIMIT 180
)
)
GROUP BY thunderball
ORDER BY Frequency DESC
LIMIT 5;`
const thunderballMainLastSeen = `
SELECT
n.ball AS Number,
julianday('now') - julianday(MAX(r.draw_date)) AS DaysSinceLastDrawn,
MAX(r.draw_date) AS LastDrawDate
FROM (
SELECT ball1 AS ball, draw_date FROM results_thunderball
UNION ALL
SELECT ball2, draw_date FROM results_thunderball
UNION ALL
SELECT ball3, draw_date FROM results_thunderball
UNION ALL
SELECT ball4, draw_date FROM results_thunderball
UNION ALL
SELECT ball5, draw_date FROM results_thunderball
) AS r
JOIN (
-- This generates a list of all possible ball numbers (139)
SELECT 1 AS ball UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL
SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 UNION ALL
SELECT 11 UNION ALL SELECT 12 UNION ALL SELECT 13 UNION ALL SELECT 14 UNION ALL SELECT 15 UNION ALL
SELECT 16 UNION ALL SELECT 17 UNION ALL SELECT 18 UNION ALL SELECT 19 UNION ALL SELECT 20 UNION ALL
SELECT 21 UNION ALL SELECT 22 UNION ALL SELECT 23 UNION ALL SELECT 24 UNION ALL SELECT 25 UNION ALL
SELECT 26 UNION ALL SELECT 27 UNION ALL SELECT 28 UNION ALL SELECT 29 UNION ALL SELECT 30 UNION ALL
SELECT 31 UNION ALL SELECT 32 UNION ALL SELECT 33 UNION ALL SELECT 34 UNION ALL SELECT 35 UNION ALL
SELECT 36 UNION ALL SELECT 37 UNION ALL SELECT 38 UNION ALL SELECT 39
) AS n ON n.ball = r.ball
GROUP BY n.ball
ORDER BY DaysSinceLastDrawn DESC;`
const thunderballLastSeen = `
SELECT
n.thunderball AS Number,
julianday('now') - julianday(MAX(r.draw_date)) AS DaysSinceLastDrawn,
MAX(r.draw_date) AS LastDrawDate
FROM (
SELECT thunderball AS thunderball, draw_date FROM results_thunderball
) AS r
JOIN (
SELECT 1 AS thunderball UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL
SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 UNION ALL
SELECT 11 UNION ALL SELECT 12 UNION ALL SELECT 13 UNION ALL SELECT 14
) AS n ON n.thunderball = r.thunderball
GROUP BY n.thunderball
ORDER BY DaysSinceLastDrawn DESC;`
const thunderballCommonPairsSince = `
WITH unpivot AS (
SELECT draw_date, ball1 AS ball FROM results_thunderball WHERE date(draw_date) >= '2010-05-09'
UNION ALL SELECT draw_date, ball2 FROM results_thunderball WHERE date(draw_date) >= '2010-05-09'
UNION ALL SELECT draw_date, ball3 FROM results_thunderball WHERE date(draw_date) >= '2010-05-09'
UNION ALL SELECT draw_date, ball4 FROM results_thunderball WHERE date(draw_date) >= '2010-05-09'
UNION ALL SELECT draw_date, ball5 FROM results_thunderball WHERE date(draw_date) >= '2010-05-09'
),
pairs AS (
SELECT a.draw_date,
MIN(a.ball, b.ball) AS ball_a,
MAX(a.ball, b.ball) AS ball_b
FROM unpivot a
JOIN unpivot b
ON a.draw_date = b.draw_date
AND a.ball < b.ball
)
SELECT ball_a, ball_b, COUNT(*) AS frequency
FROM pairs
GROUP BY ball_a, ball_b
ORDER BY frequency DESC, ball_a, ball_b
LIMIT 25;`
const thunderballCommonPairsLast180 = `
WITH recent AS (
SELECT * FROM results_thunderball
ORDER BY date(draw_date) DESC
LIMIT 180
),
unpivot AS (
SELECT draw_date, ball1 AS ball FROM recent
UNION ALL SELECT draw_date, ball2 FROM recent
UNION ALL SELECT draw_date, ball3 FROM recent
UNION ALL SELECT draw_date, ball4 FROM recent
UNION ALL SELECT draw_date, ball5 FROM recent
),
pairs AS (
SELECT a.draw_date,
MIN(a.ball, b.ball) AS ball_a,
MAX(a.ball, b.ball) AS ball_b
FROM unpivot a
JOIN unpivot b
ON a.draw_date = b.draw_date
AND a.ball < b.ball
)
SELECT ball_a, ball_b, COUNT(*) AS frequency
FROM pairs
GROUP BY ball_a, ball_b
ORDER BY frequency DESC, ball_a, ball_b
LIMIT 25;`
// Best pair balls if you choose x try picking these numbers that are frequencly seen with it (ToDo: Update this description)
// ToDo No All Time for this, go back and ensure everything has an all time for completeness.
const thunderballSepecificCommonPairsSince = `
WITH unpivot AS (
SELECT draw_date, ball1 AS ball FROM results_thunderball WHERE date(draw_date) >= '2010-05-09'
UNION ALL SELECT draw_date, ball2 FROM results_thunderball WHERE date(draw_date) >= '2010-05-09'
UNION ALL SELECT draw_date, ball3 FROM results_thunderball WHERE date(draw_date) >= '2010-05-09'
UNION ALL SELECT draw_date, ball4 FROM results_thunderball WHERE date(draw_date) >= '2010-05-09'
UNION ALL SELECT draw_date, ball5 FROM results_thunderball WHERE date(draw_date) >= '2010-05-09'
),
pairs AS (
SELECT a.draw_date,
MIN(a.ball, b.ball) AS ball_a,
MAX(a.ball, b.ball) AS ball_b
FROM unpivot a
JOIN unpivot b
ON a.draw_date = b.draw_date
AND a.ball < b.ball
)
SELECT
CASE WHEN ball_a = 26 THEN ball_b ELSE ball_a END AS partner,
COUNT(*) AS frequency
FROM pairs
WHERE ball_a = 26 OR ball_b = 26
GROUP BY partner
ORDER BY frequency DESC, partner
LIMIT 20;`
const thunderballCommonConsecutiveNumbersAllTime = `
WITH unpivot AS (
SELECT draw_date, ball1 AS ball FROM results_thunderball
UNION ALL SELECT draw_date, ball2 FROM results_thunderball
UNION ALL SELECT draw_date, ball3 FROM results_thunderball
UNION ALL SELECT draw_date, ball4 FROM results_thunderball
UNION ALL SELECT draw_date, ball5 FROM results_thunderball
),
pairs AS (
SELECT a.draw_date,
MIN(a.ball, b.ball) AS a_ball,
MAX(a.ball, b.ball) AS b_ball
FROM unpivot a
JOIN unpivot b
ON a.draw_date = b.draw_date
AND a.ball < b.ball
AND ABS(a.ball - b.ball) = 1 -- consecutive only
)
SELECT a_ball AS num1, b_ball AS num2, COUNT(*) AS frequency
FROM pairs
GROUP BY a_ball, b_ball
ORDER BY frequency DESC, num1, num2
LIMIT 25;
`
const thunderballCommonConsecutiveNumbersSince = `
WITH unpivot AS (
SELECT draw_date, ball1 AS ball FROM results_thunderball WHERE date(draw_date) >= '2010-05-09'
UNION ALL SELECT draw_date, ball2 FROM results_thunderball WHERE date(draw_date) >= '2010-05-09'
UNION ALL SELECT draw_date, ball3 FROM results_thunderball WHERE date(draw_date) >= '2010-05-09'
UNION ALL SELECT draw_date, ball4 FROM results_thunderball WHERE date(draw_date) >= '2010-05-09'
UNION ALL SELECT draw_date, ball5 FROM results_thunderball WHERE date(draw_date) >= '2010-05-09'
),
pairs AS (
SELECT a.draw_date,
MIN(a.ball, b.ball) AS a_ball,
MAX(a.ball, b.ball) AS b_ball
FROM unpivot a
JOIN unpivot b
ON a.draw_date = b.draw_date
AND a.ball < b.ball
AND ABS(a.ball - b.ball) = 1 -- consecutive only
)
SELECT a_ball AS num1, b_ball AS num2, COUNT(*) AS frequency
FROM pairs
GROUP BY a_ball, b_ball
ORDER BY frequency DESC, num1, num2
LIMIT 25;
`
// Wait, double check common number queries, consecutive and consecutive numbers make sure ive not mixed them up