diff --git a/handlers/account/authentication.go b/internal/handlers/account/authentication.go similarity index 100% rename from handlers/account/authentication.go rename to internal/handlers/account/authentication.go diff --git a/handlers/admin/audit.go b/internal/handlers/admin/audit.go similarity index 100% rename from handlers/admin/audit.go rename to internal/handlers/admin/audit.go diff --git a/handlers/admin/dashboard.go b/internal/handlers/admin/dashboard.go similarity index 100% rename from handlers/admin/dashboard.go rename to internal/handlers/admin/dashboard.go diff --git a/handlers/admin/draws.go b/internal/handlers/admin/draws.go similarity index 100% rename from handlers/admin/draws.go rename to internal/handlers/admin/draws.go diff --git a/handlers/admin/manualtriggers.go b/internal/handlers/admin/manualtriggers.go similarity index 100% rename from handlers/admin/manualtriggers.go rename to internal/handlers/admin/manualtriggers.go diff --git a/handlers/admin/prizes.go b/internal/handlers/admin/prizes.go similarity index 100% rename from handlers/admin/prizes.go rename to internal/handlers/admin/prizes.go diff --git a/handlers/common.go b/internal/handlers/common.go similarity index 100% rename from handlers/common.go rename to internal/handlers/common.go diff --git a/handlers/home.go b/internal/handlers/home.go similarity index 100% rename from handlers/home.go rename to internal/handlers/home.go diff --git a/handlers/lottery/draws/draw_handler.go b/internal/handlers/lottery/draws/draw_handler.go similarity index 100% rename from handlers/lottery/draws/draw_handler.go rename to internal/handlers/lottery/draws/draw_handler.go diff --git a/handlers/lottery/syndicate/syndicate.go b/internal/handlers/lottery/syndicate/syndicate.go similarity index 100% rename from handlers/lottery/syndicate/syndicate.go rename to internal/handlers/lottery/syndicate/syndicate.go diff --git a/handlers/lottery/syndicate/syndicate_invites.go b/internal/handlers/lottery/syndicate/syndicate_invites.go similarity index 100% rename from handlers/lottery/syndicate/syndicate_invites.go rename to internal/handlers/lottery/syndicate/syndicate_invites.go diff --git a/handlers/lottery/tickets/ticket_handler.go b/internal/handlers/lottery/tickets/ticket_handler.go similarity index 100% rename from handlers/lottery/tickets/ticket_handler.go rename to internal/handlers/lottery/tickets/ticket_handler.go diff --git a/handlers/lottery/tickets/ticket_matcher.go b/internal/handlers/lottery/tickets/ticket_matcher.go similarity index 100% rename from handlers/lottery/tickets/ticket_matcher.go rename to internal/handlers/lottery/tickets/ticket_matcher.go diff --git a/handlers/messages.go b/internal/handlers/messages.go similarity index 100% rename from handlers/messages.go rename to internal/handlers/messages.go diff --git a/handlers/notifications.go b/internal/handlers/notifications.go similarity index 100% rename from handlers/notifications.go rename to internal/handlers/notifications.go diff --git a/handlers/results.go b/internal/handlers/results.go similarity index 100% rename from handlers/results.go rename to internal/handlers/results.go diff --git a/handlers/session/account.go b/internal/handlers/session/account.go similarity index 100% rename from handlers/session/account.go rename to internal/handlers/session/account.go diff --git a/handlers/session/auth.go b/internal/handlers/session/auth.go similarity index 100% rename from handlers/session/auth.go rename to internal/handlers/session/auth.go diff --git a/handlers/statistics/thunderball.go b/internal/handlers/statistics/thunderball.go similarity index 100% rename from handlers/statistics/thunderball.go rename to internal/handlers/statistics/thunderball.go diff --git a/handlers/template/templatedata.go b/internal/handlers/template/templatedata.go similarity index 100% rename from handlers/template/templatedata.go rename to internal/handlers/template/templatedata.go diff --git a/helpers/ballslice.go b/internal/helpers/ballslice.go similarity index 100% rename from helpers/ballslice.go rename to internal/helpers/ballslice.go diff --git a/helpers/distinctresults.go b/internal/helpers/distinctresults.go similarity index 100% rename from helpers/distinctresults.go rename to internal/helpers/distinctresults.go diff --git a/helpers/http/session.go b/internal/helpers/http/session.go similarity index 100% rename from helpers/http/session.go rename to internal/helpers/http/session.go diff --git a/helpers/intptr.go b/internal/helpers/intptr.go similarity index 100% rename from helpers/intptr.go rename to internal/helpers/intptr.go diff --git a/helpers/match.go b/internal/helpers/match.go similarity index 100% rename from helpers/match.go rename to internal/helpers/match.go diff --git a/helpers/nullable.go b/internal/helpers/nullable.go similarity index 100% rename from helpers/nullable.go rename to internal/helpers/nullable.go diff --git a/helpers/parse.go b/internal/helpers/parse.go similarity index 100% rename from helpers/parse.go rename to internal/helpers/parse.go diff --git a/helpers/security/admin.go b/internal/helpers/security/admin.go similarity index 100% rename from helpers/security/admin.go rename to internal/helpers/security/admin.go diff --git a/helpers/security/password.go b/internal/helpers/security/password.go similarity index 100% rename from helpers/security/password.go rename to internal/helpers/security/password.go diff --git a/helpers/security/token.go b/internal/helpers/security/token.go similarity index 100% rename from helpers/security/token.go rename to internal/helpers/security/token.go diff --git a/helpers/security/users.go b/internal/helpers/security/users.go similarity index 100% rename from helpers/security/users.go rename to internal/helpers/security/users.go diff --git a/helpers/session/encoding.go b/internal/helpers/session/encoding.go similarity index 100% rename from helpers/session/encoding.go rename to internal/helpers/session/encoding.go diff --git a/helpers/session/loader.go b/internal/helpers/session/loader.go similarity index 100% rename from helpers/session/loader.go rename to internal/helpers/session/loader.go diff --git a/helpers/strconv.go b/internal/helpers/strconv.go similarity index 100% rename from helpers/strconv.go rename to internal/helpers/strconv.go diff --git a/helpers/template/build.go b/internal/helpers/template/build.go similarity index 100% rename from helpers/template/build.go rename to internal/helpers/template/build.go diff --git a/helpers/template/error.go b/internal/helpers/template/error.go similarity index 100% rename from helpers/template/error.go rename to internal/helpers/template/error.go diff --git a/helpers/template/pagination.go b/internal/helpers/template/pagination.go similarity index 100% rename from helpers/template/pagination.go rename to internal/helpers/template/pagination.go diff --git a/middleware/auth.go b/internal/http/middleware/auth.go similarity index 100% rename from middleware/auth.go rename to internal/http/middleware/auth.go diff --git a/middleware/headers.go b/internal/http/middleware/headers.go similarity index 100% rename from middleware/headers.go rename to internal/http/middleware/headers.go diff --git a/middleware/ratelimit.go b/internal/http/middleware/ratelimit.go similarity index 100% rename from middleware/ratelimit.go rename to internal/http/middleware/ratelimit.go diff --git a/middleware/recover.go b/internal/http/middleware/recover.go similarity index 100% rename from middleware/recover.go rename to internal/http/middleware/recover.go diff --git a/middleware/sessiontimeout.go b/internal/http/middleware/sessiontimeout.go similarity index 100% rename from middleware/sessiontimeout.go rename to internal/http/middleware/sessiontimeout.go diff --git a/routes/accountroutes.go b/internal/http/routes/accountroutes.go similarity index 100% rename from routes/accountroutes.go rename to internal/http/routes/accountroutes.go diff --git a/routes/adminroutes.go b/internal/http/routes/adminroutes.go similarity index 100% rename from routes/adminroutes.go rename to internal/http/routes/adminroutes.go diff --git a/routes/resultroutes.go b/internal/http/routes/resultroutes.go similarity index 100% rename from routes/resultroutes.go rename to internal/http/routes/resultroutes.go diff --git a/routes/statisticroutes.go b/internal/http/routes/statisticroutes.go similarity index 100% rename from routes/statisticroutes.go rename to internal/http/routes/statisticroutes.go diff --git a/routes/syndicateroutes.go b/internal/http/routes/syndicateroutes.go similarity index 100% rename from routes/syndicateroutes.go rename to internal/http/routes/syndicateroutes.go diff --git a/logging/config.go b/internal/logging/config.go similarity index 100% rename from logging/config.go rename to internal/logging/config.go diff --git a/logging/messages.go b/internal/logging/messages.go similarity index 100% rename from logging/messages.go rename to internal/logging/messages.go diff --git a/models/audit.go b/internal/models/audit.go similarity index 100% rename from models/audit.go rename to internal/models/audit.go diff --git a/models/config.go b/internal/models/config.go similarity index 60% rename from models/config.go rename to internal/models/config.go index bb37e05..be2a5a2 100644 --- a/models/config.go +++ b/internal/models/config.go @@ -5,6 +5,17 @@ type Config 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"` diff --git a/models/draw.go b/internal/models/draw.go similarity index 100% rename from models/draw.go rename to internal/models/draw.go diff --git a/models/machine.go b/internal/models/machine.go similarity index 100% rename from models/machine.go rename to internal/models/machine.go diff --git a/models/match.go b/internal/models/match.go similarity index 100% rename from models/match.go rename to internal/models/match.go diff --git a/models/prediction.go b/internal/models/prediction.go similarity index 100% rename from models/prediction.go rename to internal/models/prediction.go diff --git a/models/statistics.go b/internal/models/statistics.go similarity index 100% rename from models/statistics.go rename to internal/models/statistics.go diff --git a/models/syndicate.go b/internal/models/syndicate.go similarity index 100% rename from models/syndicate.go rename to internal/models/syndicate.go diff --git a/models/template.go b/internal/models/template.go similarity index 100% rename from models/template.go rename to internal/models/template.go diff --git a/models/ticket.go b/internal/models/ticket.go similarity index 100% rename from models/ticket.go rename to internal/models/ticket.go diff --git a/internal/models/user.go b/internal/models/user.go new file mode 100644 index 0000000..e4e6128 --- /dev/null +++ b/internal/models/user.go @@ -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 +} diff --git a/bootstrap/csrf.go b/internal/platform/bootstrap/csrf.go similarity index 100% rename from bootstrap/csrf.go rename to internal/platform/bootstrap/csrf.go diff --git a/bootstrap/license.go b/internal/platform/bootstrap/license.go similarity index 100% rename from bootstrap/license.go rename to internal/platform/bootstrap/license.go diff --git a/bootstrap/loader.go b/internal/platform/bootstrap/loader.go similarity index 100% rename from bootstrap/loader.go rename to internal/platform/bootstrap/loader.go diff --git a/bootstrap/session.go b/internal/platform/bootstrap/session.go similarity index 100% rename from bootstrap/session.go rename to internal/platform/bootstrap/session.go diff --git a/config/config.go b/internal/platform/config/config.go similarity index 100% rename from config/config.go rename to internal/platform/config/config.go diff --git a/constants/session.go b/internal/platform/constants/session.go similarity index 100% rename from constants/session.go rename to internal/platform/constants/session.go diff --git a/internal/rules/thunderball/rules.go b/internal/rules/thunderball/rules.go new file mode 100644 index 0000000..4473fe9 --- /dev/null +++ b/internal/rules/thunderball/rules.go @@ -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 + } +} diff --git a/internal/rules/types.go b/internal/rules/types.go new file mode 100644 index 0000000..08a2dca --- /dev/null +++ b/internal/rules/types.go @@ -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" +) diff --git a/services/draws/drawlookup.go b/internal/services/draws/drawlookup.go similarity index 100% rename from services/draws/drawlookup.go rename to internal/services/draws/drawlookup.go diff --git a/matcher/engine.go b/internal/services/tickets/engine.go similarity index 100% rename from matcher/engine.go rename to internal/services/tickets/engine.go diff --git a/services/tickets/ticketmatching.go b/internal/services/tickets/ticketmatching.go similarity index 100% rename from services/tickets/ticketmatching.go rename to internal/services/tickets/ticketmatching.go diff --git a/storage/sqlite/admin.go b/internal/storage/mysql/auditLog/create.go similarity index 71% rename from storage/sqlite/admin.go rename to internal/storage/mysql/auditLog/create.go index d33a4cb..e0bc2f8 100644 --- a/storage/sqlite/admin.go +++ b/internal/storage/mysql/auditLog/create.go @@ -4,9 +4,11 @@ import ( "database/sql" "log" "net/http" + "time" securityHelpers "synlotto-website/helpers/security" templateHelpers "synlotto-website/helpers/template" + "synlotto-website/internal/logging" "synlotto-website/middleware" ) @@ -38,3 +40,17 @@ func AdminOnly(db *sql.DB, next http.HandlerFunc) http.HandlerFunc { 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) + } +} diff --git a/internal/storage/mysql/db.go b/internal/storage/mysql/db.go new file mode 100644 index 0000000..aec8947 --- /dev/null +++ b/internal/storage/mysql/db.go @@ -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 +} diff --git a/internal/storage/mysql/messages/create.go b/internal/storage/mysql/messages/create.go new file mode 100644 index 0000000..db6c99b --- /dev/null +++ b/internal/storage/mysql/messages/create.go @@ -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 +} diff --git a/internal/storage/mysql/messages/delete.go b/internal/storage/mysql/messages/delete.go new file mode 100644 index 0000000..2ef79d9 --- /dev/null +++ b/internal/storage/mysql/messages/delete.go @@ -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 \ No newline at end of file diff --git a/storage/sqlite/messages.go b/internal/storage/mysql/messages/read.go similarity index 71% rename from storage/sqlite/messages.go rename to internal/storage/mysql/messages/read.go index f65cbde..3040b30 100644 --- a/storage/sqlite/messages.go +++ b/internal/storage/mysql/messages/read.go @@ -2,8 +2,8 @@ package storage import ( "database/sql" - "fmt" - "synlotto-website/models" + + "synlotto-website/internal/models" ) func GetMessageCount(db *sql.DB, userID int) (int, error) { @@ -62,43 +62,6 @@ func GetMessageByID(db *sql.DB, userID, messageID int) (*models.Message, error) return &m, nil } -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 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 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 -} - func GetArchivedMessages(db *sql.DB, userID int, page, perPage int) []models.Message { offset := (page - 1) * perPage rows, err := db.Query(` @@ -167,12 +130,3 @@ func GetInboxMessageCount(db *sql.DB, userID int) int { } return count } - -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 -} diff --git a/internal/storage/mysql/messages/update.go b/internal/storage/mysql/messages/update.go new file mode 100644 index 0000000..a9d138b --- /dev/null +++ b/internal/storage/mysql/messages/update.go @@ -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 +} diff --git a/internal/storage/mysql/migrations/0001_initial_create.up.sql b/internal/storage/mysql/migrations/0001_initial_create.up.sql new file mode 100644 index 0000000..a6dbc0d --- /dev/null +++ b/internal/storage/mysql/migrations/0001_initial_create.up.sql @@ -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; diff --git a/internal/storage/mysql/notifications/create.go b/internal/storage/mysql/notifications/create.go new file mode 100644 index 0000000..61dc523 --- /dev/null +++ b/internal/storage/mysql/notifications/create.go @@ -0,0 +1,3 @@ +package storage + +// ToDo: somethign must create notifications? diff --git a/internal/storage/mysql/notifications/delete.go b/internal/storage/mysql/notifications/delete.go new file mode 100644 index 0000000..22ba599 --- /dev/null +++ b/internal/storage/mysql/notifications/delete.go @@ -0,0 +1,3 @@ +package storage + +// ToDo: not used, check messages and do something similar maybe dont store them? diff --git a/storage/sqlite/notifications.go b/internal/storage/mysql/notifications/read.go similarity index 73% rename from storage/sqlite/notifications.go rename to internal/storage/mysql/notifications/read.go index 9d4dbf9..74ec21e 100644 --- a/storage/sqlite/notifications.go +++ b/internal/storage/mysql/notifications/read.go @@ -1,13 +1,28 @@ package storage +//ToDo: should be using my own logging wrapper? import ( "database/sql" - "fmt" "log" - "synlotto-website/models" + "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(` @@ -46,40 +61,3 @@ func GetRecentNotifications(db *sql.DB, userID int, limit int) []models.Notifica return notifications } - -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 -} - -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 -} diff --git a/internal/storage/mysql/notifications/update.go b/internal/storage/mysql/notifications/update.go new file mode 100644 index 0000000..65c888f --- /dev/null +++ b/internal/storage/mysql/notifications/update.go @@ -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 +} diff --git a/internal/storage/mysql/results/thunderball/create.go b/internal/storage/mysql/results/thunderball/create.go new file mode 100644 index 0000000..95ebbb1 --- /dev/null +++ b/internal/storage/mysql/results/thunderball/create.go @@ -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 +} diff --git a/internal/storage/mysql/schema.go b/internal/storage/mysql/schema.go new file mode 100644 index 0000000..82be054 --- /dev/null +++ b/internal/storage/mysql/schema.go @@ -0,0 +1 @@ +package storage diff --git a/internal/storage/mysql/seeds/thunderball_seed.go b/internal/storage/mysql/seeds/thunderball_seed.go new file mode 100644 index 0000000..0d13b30 --- /dev/null +++ b/internal/storage/mysql/seeds/thunderball_seed.go @@ -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 don’t 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 +} diff --git a/internal/storage/mysql/syndicate/create.go b/internal/storage/mysql/syndicate/create.go new file mode 100644 index 0000000..37115b3 --- /dev/null +++ b/internal/storage/mysql/syndicate/create.go @@ -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 +} diff --git a/internal/storage/mysql/syndicate/read.go b/internal/storage/mysql/syndicate/read.go new file mode 100644 index 0000000..c9dc81c --- /dev/null +++ b/internal/storage/mysql/syndicate/read.go @@ -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 +} diff --git a/internal/storage/mysql/syndicate/update.go b/internal/storage/mysql/syndicate/update.go new file mode 100644 index 0000000..d0160c0 --- /dev/null +++ b/internal/storage/mysql/syndicate/update.go @@ -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 +} diff --git a/storage/sqlite/insert.go b/internal/storage/mysql/tickets/create.go similarity index 64% rename from storage/sqlite/insert.go rename to internal/storage/mysql/tickets/create.go index 520fd2e..28fbbed 100644 --- a/storage/sqlite/insert.go +++ b/internal/storage/mysql/tickets/create.go @@ -3,32 +3,11 @@ package storage import ( "database/sql" "log" - "strings" - "synlotto-website/helpers" - "synlotto-website/models" + "synlotto-website/internal/helpers" + "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 -} - +// 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{} diff --git a/internal/storage/mysql/users/create.go b/internal/storage/mysql/users/create.go new file mode 100644 index 0000000..ea620f4 --- /dev/null +++ b/internal/storage/mysql/users/create.go @@ -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 +} \ No newline at end of file diff --git a/storage/sqlite/users.go b/internal/storage/mysql/users/read.go similarity index 90% rename from storage/sqlite/users.go rename to internal/storage/mysql/users/read.go index d7a5955..b32ca82 100644 --- a/storage/sqlite/users.go +++ b/internal/storage/mysql/users/read.go @@ -3,8 +3,8 @@ package storage import ( "database/sql" - "synlotto-website/logging" - "synlotto-website/models" + "synlotto-website/internal/logging" + "synlotto-website/internal/models" ) func GetUserByID(db *sql.DB, id int) *models.User { diff --git a/storage/sqlite/db.go b/internal/storage/sqlite/db.go similarity index 100% rename from storage/sqlite/db.go rename to internal/storage/sqlite/db.go diff --git a/storage/sqlite/schema.go b/internal/storage/sqlite/schema.go similarity index 100% rename from storage/sqlite/schema.go rename to internal/storage/sqlite/schema.go diff --git a/internal/storage/sqlite/syndicate.go b/internal/storage/sqlite/syndicate.go new file mode 100644 index 0000000..685f33e --- /dev/null +++ b/internal/storage/sqlite/syndicate.go @@ -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 +} diff --git a/storage/sqlite/thunderball/statisticqueries.go b/internal/storage/sqlite/thunderball/statisticqueries.go similarity index 100% rename from storage/sqlite/thunderball/statisticqueries.go rename to internal/storage/sqlite/thunderball/statisticqueries.go diff --git a/models/user.go b/models/user.go deleted file mode 100644 index 6e1365e..0000000 --- a/models/user.go +++ /dev/null @@ -1,61 +0,0 @@ -package models - -import ( - "database/sql" - "log" - "time" -) - -type User struct { - Id int - Username string - PasswordHash string - IsAdmin bool -} - -type Notification struct { - ID int - UserId int - Subject string - Body string - IsRead bool - CreatedAt time.Time -} - -type Message struct { - ID int - SenderId int - RecipientId int - Subject string - Message string - IsRead bool - CreatedAt time.Time - ArchivedAt *time.Time -} - -var db *sql.DB - -func SetDB(database *sql.DB) { - db = database -} - -func CreateUser(username, passwordHash string) error { - _, err := db.Exec("INSERT INTO users (username, password_hash) VALUES (?, ?)", username, passwordHash) - //ToDo: Why is SQL in here? - return err -} - -func GetUserByUsername(username string) *User { - row := db.QueryRow("SELECT id, username, password_hash FROM users WHERE username = ?", username) - - var user User - err := row.Scan(&user.Id, &user.Username, &user.PasswordHash) - if err != nil { - if err != sql.ErrNoRows { - log.Println("DB error:", err) - } - return nil - } - - return &user -} diff --git a/rules/thunderball.go b/rules/thunderball.go deleted file mode 100644 index cd077f6..0000000 --- a/rules/thunderball.go +++ /dev/null @@ -1,46 +0,0 @@ -package rules - -import "synlotto-website/models" - -type PrizeInfo struct { - Tier string - Amount float64 - Label string -} - -var ThunderballPrizeRules = []models.PrizeRule{ - {Game: "Thunderball", MainMatches: 0, BonusMatches: 1, Tier: "Tier 1"}, - {Game: "Thunderball", MainMatches: 1, BonusMatches: 1, Tier: "Tier 2"}, - {Game: "Thunderball", MainMatches: 2, BonusMatches: 1, Tier: "Tier 3"}, - {Game: "Thunderball", MainMatches: 3, BonusMatches: 0, Tier: "Tier 4"}, - {Game: "Thunderball", MainMatches: 3, BonusMatches: 1, Tier: "Tier 5"}, - {Game: "Thunderball", MainMatches: 4, BonusMatches: 0, Tier: "Tier 6"}, - {Game: "Thunderball", MainMatches: 4, BonusMatches: 1, Tier: "Tier 7"}, - {Game: "Thunderball", MainMatches: 5, BonusMatches: 0, Tier: "Second"}, - {Game: "Thunderball", 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 - } -} diff --git a/storage/sqlite/audit.go b/storage/sqlite/audit.go deleted file mode 100644 index e2b8dad..0000000 --- a/storage/sqlite/audit.go +++ /dev/null @@ -1,22 +0,0 @@ -package storage - -import ( - "net/http" - "time" - - "synlotto-website/logging" -) - -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) - } -} diff --git a/storage/sqlite/syndicate.go b/storage/sqlite/syndicate.go deleted file mode 100644 index 1995dc8..0000000 --- a/storage/sqlite/syndicate.go +++ /dev/null @@ -1,287 +0,0 @@ -package storage - -import ( - "database/sql" - "fmt" - "synlotto-website/models" - "time" -) - -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 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 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 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 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 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 -} - -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 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 -} - -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 -} - -func GetPendingInvites(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 UpdateInviteStatus(db *sql.DB, inviteID int, status string) error { - _, err := db.Exec(` - UPDATE syndicate_invites - SET status = ? - WHERE id = ? - `, status, inviteID) - return err -} - -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 -} - -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 -} diff --git a/static/css/site.css b/web/static/css/site.css similarity index 100% rename from static/css/site.css rename to web/static/css/site.css diff --git a/static/css/topbar.css b/web/static/css/topbar.css similarity index 100% rename from static/css/topbar.css rename to web/static/css/topbar.css diff --git a/templates/account/login.html b/web/templates/account/login.html similarity index 100% rename from templates/account/login.html rename to web/templates/account/login.html diff --git a/templates/account/messages/archived.html b/web/templates/account/messages/archived.html similarity index 100% rename from templates/account/messages/archived.html rename to web/templates/account/messages/archived.html diff --git a/templates/account/messages/index.html b/web/templates/account/messages/index.html similarity index 95% rename from templates/account/messages/index.html rename to web/templates/account/messages/index.html index b690fa5..0207454 100644 --- a/templates/account/messages/index.html +++ b/web/templates/account/messages/index.html @@ -1,4 +1,5 @@ {{ define "content" }} +

Your Inbox

{{ if .Messages }} diff --git a/templates/account/messages/read.html b/web/templates/account/messages/read.html similarity index 100% rename from templates/account/messages/read.html rename to web/templates/account/messages/read.html diff --git a/templates/account/messages/send.html b/web/templates/account/messages/send.html similarity index 100% rename from templates/account/messages/send.html rename to web/templates/account/messages/send.html diff --git a/templates/account/notifications/index.html b/web/templates/account/notifications/index.html similarity index 100% rename from templates/account/notifications/index.html rename to web/templates/account/notifications/index.html diff --git a/templates/account/notifications/read.html b/web/templates/account/notifications/read.html similarity index 100% rename from templates/account/notifications/read.html rename to web/templates/account/notifications/read.html diff --git a/templates/account/signup.html b/web/templates/account/signup.html similarity index 100% rename from templates/account/signup.html rename to web/templates/account/signup.html diff --git a/templates/account/tickets/add_ticket.html b/web/templates/account/tickets/add_ticket.html similarity index 100% rename from templates/account/tickets/add_ticket.html rename to web/templates/account/tickets/add_ticket.html diff --git a/templates/account/tickets/my_tickets.html b/web/templates/account/tickets/my_tickets.html similarity index 100% rename from templates/account/tickets/my_tickets.html rename to web/templates/account/tickets/my_tickets.html diff --git a/templates/admin/dashboard.html b/web/templates/admin/dashboard.html similarity index 100% rename from templates/admin/dashboard.html rename to web/templates/admin/dashboard.html diff --git a/templates/admin/draws/delete_draw.html b/web/templates/admin/draws/delete_draw.html similarity index 100% rename from templates/admin/draws/delete_draw.html rename to web/templates/admin/draws/delete_draw.html diff --git a/templates/admin/draws/list_draws.html b/web/templates/admin/draws/list_draws.html similarity index 100% rename from templates/admin/draws/list_draws.html rename to web/templates/admin/draws/list_draws.html diff --git a/templates/admin/draws/modify_draw.html b/web/templates/admin/draws/modify_draw.html similarity index 100% rename from templates/admin/draws/modify_draw.html rename to web/templates/admin/draws/modify_draw.html diff --git a/templates/admin/draws/new_draw.html b/web/templates/admin/draws/new_draw.html similarity index 100% rename from templates/admin/draws/new_draw.html rename to web/templates/admin/draws/new_draw.html diff --git a/templates/admin/draws/prizes/add_prizes.html b/web/templates/admin/draws/prizes/add_prizes.html similarity index 100% rename from templates/admin/draws/prizes/add_prizes.html rename to web/templates/admin/draws/prizes/add_prizes.html diff --git a/templates/admin/draws/prizes/modify_prizes.html b/web/templates/admin/draws/prizes/modify_prizes.html similarity index 100% rename from templates/admin/draws/prizes/modify_prizes.html rename to web/templates/admin/draws/prizes/modify_prizes.html diff --git a/templates/admin/logs/access_logs.html b/web/templates/admin/logs/access_logs.html similarity index 100% rename from templates/admin/logs/access_logs.html rename to web/templates/admin/logs/access_logs.html diff --git a/templates/admin/logs/audit.html b/web/templates/admin/logs/audit.html similarity index 100% rename from templates/admin/logs/audit.html rename to web/templates/admin/logs/audit.html diff --git a/templates/admin/triggers.html b/web/templates/admin/triggers.html similarity index 100% rename from templates/admin/triggers.html rename to web/templates/admin/triggers.html diff --git a/templates/error/403.html b/web/templates/error/403.html similarity index 100% rename from templates/error/403.html rename to web/templates/error/403.html diff --git a/templates/error/404.html b/web/templates/error/404.html similarity index 100% rename from templates/error/404.html rename to web/templates/error/404.html diff --git a/templates/error/429.html b/web/templates/error/429.html similarity index 100% rename from templates/error/429.html rename to web/templates/error/429.html diff --git a/templates/error/500.html b/web/templates/error/500.html similarity index 100% rename from templates/error/500.html rename to web/templates/error/500.html diff --git a/templates/index.html b/web/templates/index.html similarity index 100% rename from templates/index.html rename to web/templates/index.html diff --git a/templates/main/footer.html b/web/templates/main/footer.html similarity index 100% rename from templates/main/footer.html rename to web/templates/main/footer.html diff --git a/templates/main/layout.html b/web/templates/main/layout.html similarity index 100% rename from templates/main/layout.html rename to web/templates/main/layout.html diff --git a/templates/main/topbar.html b/web/templates/main/topbar.html similarity index 100% rename from templates/main/topbar.html rename to web/templates/main/topbar.html diff --git a/templates/results/thunderball.html b/web/templates/results/thunderball.html similarity index 100% rename from templates/results/thunderball.html rename to web/templates/results/thunderball.html diff --git a/web/templates/statistics/thunderball.html b/web/templates/statistics/thunderball.html new file mode 100644 index 0000000..83967d2 --- /dev/null +++ b/web/templates/statistics/thunderball.html @@ -0,0 +1,18 @@ +{{ define "content" }} +
+

Thunderball Statistics

+ +
+
+

Top 5 (since {{.Since}})

+ + + +{{range .TopSince}} + +{{end}} + +
NumberFrequency
{{.Number}}{{.Frequency}}
+
+
+{{end}} \ No newline at end of file diff --git a/templates/syndicate/create.html b/web/templates/syndicate/create.html similarity index 100% rename from templates/syndicate/create.html rename to web/templates/syndicate/create.html diff --git a/templates/syndicate/index.html b/web/templates/syndicate/index.html similarity index 100% rename from templates/syndicate/index.html rename to web/templates/syndicate/index.html diff --git a/templates/syndicate/invite.html b/web/templates/syndicate/invite.html similarity index 100% rename from templates/syndicate/invite.html rename to web/templates/syndicate/invite.html diff --git a/templates/syndicate/log_ticket.html b/web/templates/syndicate/log_ticket.html similarity index 100% rename from templates/syndicate/log_ticket.html rename to web/templates/syndicate/log_ticket.html diff --git a/templates/syndicate/manager/invite_links.html b/web/templates/syndicate/manager/invite_links.html similarity index 100% rename from templates/syndicate/manager/invite_links.html rename to web/templates/syndicate/manager/invite_links.html diff --git a/templates/syndicate/tickets.html b/web/templates/syndicate/tickets.html similarity index 100% rename from templates/syndicate/tickets.html rename to web/templates/syndicate/tickets.html diff --git a/templates/syndicate/view.html b/web/templates/syndicate/view.html similarity index 100% rename from templates/syndicate/view.html rename to web/templates/syndicate/view.html diff --git a/templates/tickets.html b/web/templates/tickets.html similarity index 100% rename from templates/tickets.html rename to web/templates/tickets.html