Compare commits
40 Commits
4a6bfad880
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cc759ec694 | |||
| f0fc70eac6 | |||
| 61ad033520 | |||
| 9dc01f925a | |||
| 8529116ad2 | |||
| 776ea53a66 | |||
| 5880d1ca43 | |||
| da365aa9ef | |||
| 5177194895 | |||
| a7a5169c67 | |||
| 262536135d | |||
| 8650b1fd63 | |||
| b41e92629b | |||
| 0b2883a494 | |||
| 5520685504 | |||
| e2b30c0234 | |||
| 07f7a50b77 | |||
| f458250d3a | |||
| f2cb283158 | |||
| b9bc29d5bc | |||
| b6b5207d43 | |||
| 34918d770f | |||
| eba25a4fb5 | |||
| e6654fc1b4 | |||
| ddafdd0468 | |||
| 5fcb4fb016 | |||
| 71c8d4d06c | |||
| 244b882f11 | |||
| 8d2ce27a74 | |||
| 72e655674f | |||
| f1e16fbc52 | |||
| aec8022439 | |||
| e1fa6c502e | |||
| aa20652abc | |||
| c9f3863a25 | |||
| 76cdb96966 | |||
| 29cb50bb34 | |||
| ffcc340034 | |||
| af581a4def | |||
| e0b063fab0 |
223
README.md
223
README.md
@@ -0,0 +1,223 @@
|
||||
# Platform Architecture & Tech Stack
|
||||
|
||||
Internal developer documentation for the SynLotto platform infrastructure, covering the core platform modules where comments were updated and maintained. This serves as the reference for how the runtime environment is constructed and how foundational systems interact.
|
||||
|
||||
> **Current as of: Oct 29, 2025**
|
||||
|
||||
---
|
||||
|
||||
## Platform Initialization Overview
|
||||
|
||||
At startup the platform initializes and wires the systems required for HTTP request routing, security, session management, and database persistence.
|
||||
|
||||
Boot sequence executed from bootstrap:
|
||||
|
||||
### Config Load
|
||||
→ MySQL Connect + Validate
|
||||
→ EnsureInitialSchema (Embedded SQL, idempotent)
|
||||
→ Register gob types for session data
|
||||
→ Initialize SessionManager (SCS)
|
||||
→ Create Gin Router (Logging, static assets)
|
||||
→ Inject *App into Gin context for handler access
|
||||
→ Route Handler → SCS LoadAndSave wrapping
|
||||
→ CSRF Wrapping (NoSurf)
|
||||
→ http.Server construction (graceful shutdown capable)
|
||||
|
||||
Application boot from main.go:
|
||||
|
||||
### Initialize template helpers
|
||||
→ Attach global middleware (Auth → Remember)
|
||||
→ Register route groups (Home, Account, Admin, Syndicate, Statistics)
|
||||
→ Start serving HTTP requests
|
||||
→ Graceful shutdown on SIGINT/SIGTERM
|
||||
|
||||
## Platform Files & Responsibilities
|
||||
***internal/platform/bootstrap/loader.go***
|
||||
|
||||
The **application kernel** constructor.
|
||||
|
||||
Creates and wires:
|
||||
|
||||
- Config (loaded externally)
|
||||
|
||||
- MySQL DB connection (with pooling + UTF8MB4 + UTC)
|
||||
|
||||
- Idempotent initial schema application
|
||||
|
||||
- SCS SessionManager
|
||||
|
||||
- Gin router with logging
|
||||
|
||||
- Static mount: /static → ./web/static
|
||||
|
||||
- App → Gin context injection (c.Set("app", app))
|
||||
|
||||
- Custom NoRoute/NoMethod/Recovery error pages
|
||||
|
||||
- Final HTTP handler wrapping: Gin → SCS → CSRF
|
||||
|
||||
Orchestrates: stability of middleware order, security primitives, and transport-level behavior.
|
||||
|
||||
***cmd/api/main.go***
|
||||
|
||||
Top-level runtime control.
|
||||
|
||||
- Initializes template helpers (session manager + site meta)
|
||||
|
||||
- Applies Auth and Remember middleware
|
||||
|
||||
- Registers route groups
|
||||
|
||||
- Starts server in goroutine
|
||||
|
||||
- Uses timed graceful shutdown
|
||||
|
||||
No business logic or boot infrastructure allowed here.
|
||||
|
||||
***internal/platform/config/types.go***
|
||||
|
||||
Strongly typed runtime settings including:
|
||||
|
||||
Config Sections:
|
||||
|
||||
- Database (server, pool settings, credentials)
|
||||
|
||||
- HTTP server settings
|
||||
|
||||
- Session lifetimes + cookie names
|
||||
|
||||
- CSRF cookie name
|
||||
|
||||
- External API licensing
|
||||
|
||||
- Site metadata
|
||||
|
||||
Durations are strings — validated and parsed in platform/session.
|
||||
|
||||
***internal/platform/config/load.go***
|
||||
|
||||
Loads JSON configuration into Config struct.
|
||||
|
||||
- Pure function
|
||||
|
||||
- No mutation of global state
|
||||
|
||||
- Errors propagate to bootstrap
|
||||
|
||||
***internal/platform/config/config.go***
|
||||
|
||||
Singleton wrapper for global configuration access.
|
||||
|
||||
- Init ensures config is assigned only once
|
||||
|
||||
- Get allows consumers to retrieve config object
|
||||
|
||||
Used sparingly — dependency injection via App is primary recommended path.
|
||||
|
||||
***internal/platform/session/session.go***
|
||||
|
||||
Creates and configures SCS session manager.
|
||||
|
||||
Configured behaviors:
|
||||
|
||||
- Absolute lifetime (default 12h if invalid config)
|
||||
|
||||
- Idle timeout enforcement
|
||||
|
||||
- Cookie security:
|
||||
|
||||
- - HttpOnly = true
|
||||
|
||||
- - SameSite = Lax
|
||||
|
||||
- - Secure = based on productionMode
|
||||
|
||||
Responsible only for platform session settings — not auth behavior or token rotation.
|
||||
|
||||
***internal/platform/csrf/csrf.go***
|
||||
|
||||
Applies NoSurf global CSRF protection.
|
||||
|
||||
- Cookie name from config
|
||||
|
||||
- HttpOnly always
|
||||
|
||||
- Secure cookie in production
|
||||
|
||||
- SameSite = Lax
|
||||
|
||||
- Wraps after SCS to access stored session data
|
||||
|
||||
Requires template integration for token distribution.
|
||||
|
||||
***internal/platform/database/schema.go***
|
||||
|
||||
Ensures base DB schema exists using embedded SQL.
|
||||
|
||||
Behavior:
|
||||
|
||||
- Probes users table
|
||||
|
||||
- If any rows exist → assume schema complete
|
||||
|
||||
- Otherwise → executes InitialSchema in a single TX
|
||||
|
||||
Future: schema versioning required for incremental changes.
|
||||
|
||||
## Tech Stack Summary
|
||||
|Concern | Technology |
|
||||
| ------ | ------ |
|
||||
|Web Framework|Gin|
|
||||
|Session Manager|SCS (server-side)|
|
||||
|CSRF Protection|NoSurf|
|
||||
|Database|MySQL|
|
||||
|Migrations|Embedded SQL applied on startup|
|
||||
|Templates|Go html/template|
|
||||
|Static Files|Served via Gin from web/static|
|
||||
|Authentication|Cookie-based session auth|
|
||||
|Error Views|Custom 404, 405, Recovery|
|
||||
|Config Source|JSON configuration file|
|
||||
|Routing|Grouped per feature under internal/http/routes|
|
||||
|
||||
## Security Behavior Summary
|
||||
|Protection| Current Status|
|
||||
| ------ | ------ |
|
||||
|CSRF enforced globally|Yes|
|
||||
|Session cookies HttpOnly|Yes|
|
||||
|Secure cookie in production|Yes|
|
||||
|SameSite policy|Lax|
|
||||
|Idle timeout enforcement|Enabled|
|
||||
|Session rotation on login|Enabled|
|
||||
|DB foreign keys|Enabled|
|
||||
|Secrets managed via JSON config|Temporary measure|
|
||||
|
||||
Security improvements tracked separately.
|
||||
|
||||
## Architectural Rules
|
||||
|Layer |May Access|Must Not Access|
|
||||
| ------ | ------ | ------ |
|
||||
|Platform|DB, Session, Config|Handlers, routes|
|
||||
|Handlers|App, DB, SessionManager, helpers|Bootstrap|
|
||||
|Template helpers|Pure logic only|DB, HTTP|
|
||||
|Middleware|Session, App, routing|Template rendering|
|
||||
|Error pages|No DB or session dependency|Bootstrap internals|
|
||||
|
||||
These boundaries are currently enforced in code.
|
||||
|
||||
## Known Technical Debt
|
||||
|
||||
- Duration parsing and validation improvements
|
||||
|
||||
- Environment variable support for secret fields
|
||||
|
||||
- CSRF token auto-injection in templates
|
||||
|
||||
- Versioned DB migrations
|
||||
|
||||
- Replace remaining global config reads
|
||||
|
||||
- Add structured logging for platform initialization
|
||||
|
||||
- Expanded session store options (persistent)
|
||||
|
||||
Documented in developer backlog for scheduling.
|
||||
@@ -1,5 +1,33 @@
|
||||
// Path /cmd/api
|
||||
// Path: /cmd/api
|
||||
// File: main.go
|
||||
//
|
||||
// Purpose
|
||||
// Application entrypoint. Wires the bootstrapped App into HTTP runtime concerns:
|
||||
// - Initializes template helpers with session + site meta
|
||||
// - Mounts global middleware that require *App (Auth, Remember)
|
||||
// - Registers all route groups outside of bootstrap
|
||||
// - Starts the HTTP server and performs graceful shutdown on SIGINT/SIGTERM
|
||||
//
|
||||
// Responsibilities (as implemented here)
|
||||
// 1) Build the application kernel via bootstrap.Load(configPath).
|
||||
// 2) Initialize template helpers with SessionManager and site metadata.
|
||||
// 3) Attach global middleware that depend on App (Auth first, then Remember).
|
||||
// 4) Register route groups (Home, Account, Admin, Syndicate, Statistics).
|
||||
// 5) Start http.Server in a goroutine and log the bound address.
|
||||
// 6) Block on OS signals and perform a 10s graceful shutdown.
|
||||
//
|
||||
// Notes (code-accurate)
|
||||
// - Config path uses a backslash; consider using forward slashes or filepath.Join
|
||||
// to be OS-neutral (Go accepts forward slashes cross-platform).
|
||||
// - Middleware order matters and matches the master reference: Auth → Remember
|
||||
// (CSRF is already applied inside bootstrap handler wrapping).
|
||||
// - ListenAndServe error handling correctly ignores http.ErrServerClosed.
|
||||
// - Shutdown uses a fixed 10s timeout; consider making this configurable.
|
||||
//
|
||||
// TODOs
|
||||
// - Replace panic on bootstrap/startup with structured logging and exit codes.
|
||||
// - Move config path to env/flag for deploy-time configurability.
|
||||
// - If background workers are added, coordinate their shutdown with the same context.
|
||||
|
||||
package main
|
||||
|
||||
@@ -20,25 +48,29 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Build application kernel (config → DB → schema → sessions → router → CSRF → server)
|
||||
app, err := bootstrap.Load("internal\\platform\\config\\config.json")
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("bootstrap: %w", err))
|
||||
}
|
||||
|
||||
// Initialize template helpers that require session + site metadata
|
||||
templateHelpers.InitSessionManager(app.SessionManager)
|
||||
templateHelpers.InitSiteMeta(app.Config.Site.SiteName, app.Config.Site.CopyrightYearStart, 0)
|
||||
|
||||
// Global middleware that depends on *App
|
||||
// Order is important: AuthMiddleware (idle timeout/last activity) → RememberMiddleware (optional)
|
||||
app.Router.Use(middleware.AuthMiddleware())
|
||||
app.Router.Use(middleware.RememberMiddleware(app))
|
||||
app.Router.Use(middleware.RememberMiddleware(app)) // rotation optional; security hardening TBD
|
||||
|
||||
// Route registration lives OUTSIDE bootstrap
|
||||
// Route registration lives OUTSIDE bootstrap (keeps bootstrap infra-only)
|
||||
routes.RegisterHomeRoutes(app)
|
||||
routes.RegisterAccountRoutes(app)
|
||||
routes.RegisterAdminRoutes(app)
|
||||
routes.RegisterSyndicateRoutes(app)
|
||||
routes.RegisterStatisticsRoutes(app)
|
||||
|
||||
// Start the HTTP server
|
||||
srv := app.Server
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
@@ -48,6 +80,7 @@ func main() {
|
||||
|
||||
fmt.Printf("Server running on http://%s\n", srv.Addr)
|
||||
|
||||
// Graceful shutdown on SIGINT/SIGTERM
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
<-stop
|
||||
@@ -55,5 +88,5 @@ func main() {
|
||||
fmt.Println("Shutting down...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(ctx)
|
||||
_ = srv.Shutdown(ctx) // best-effort; log if needed
|
||||
}
|
||||
|
||||
25
internal/domain/messages/domain.go
Normal file
25
internal/domain/messages/domain.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package domainMessages
|
||||
|
||||
import (
|
||||
"synlotto-website/internal/models"
|
||||
)
|
||||
|
||||
type Message = models.Message
|
||||
|
||||
type CreateMessageInput struct {
|
||||
SenderID int64
|
||||
RecipientID int64 `form:"recipientId" binding:"required,numeric"`
|
||||
Subject string `form:"subject" binding:"required,max=200"`
|
||||
Body string `form:"body" binding:"required"`
|
||||
}
|
||||
|
||||
type MessageService interface {
|
||||
ListInbox(userID int64) ([]Message, error)
|
||||
ListArchived(userID int64) ([]Message, error)
|
||||
GetByID(userID, id int64) (*Message, error)
|
||||
Create(userID int64, in CreateMessageInput) (int64, error)
|
||||
Archive(userID, id int64) error
|
||||
Unarchive(userID, id int64) error
|
||||
MarkRead(userID, id int64) error
|
||||
//MarkUnread(userID, id int64) error
|
||||
}
|
||||
14
internal/domain/notifications/domain.go
Normal file
14
internal/domain/notifications/domain.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package domainMessages
|
||||
|
||||
import (
|
||||
"synlotto-website/internal/models"
|
||||
)
|
||||
|
||||
// ToDo: Should be taken from model.
|
||||
type Notification = models.Notification
|
||||
|
||||
// ToDo: Should interfaces be else where?
|
||||
type NotificationService interface {
|
||||
List(userID int64) ([]Notification, error)
|
||||
GetByID(userID, id int64) (*Notification, error)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"synlotto-website/internal/logging"
|
||||
"synlotto-website/internal/models"
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
"synlotto-website/internal/platform/sessionkeys"
|
||||
auditlogStorage "synlotto-website/internal/storage/auditlog"
|
||||
usersStorage "synlotto-website/internal/storage/users"
|
||||
)
|
||||
@@ -74,6 +75,8 @@ func LoginPost(c *gin.Context) {
|
||||
_ = sm.RenewToken(r.Context())
|
||||
|
||||
sm.Put(r.Context(), "user_id", user.Id)
|
||||
sm.Put(r.Context(), sessionkeys.Username, user.Username)
|
||||
sm.Put(r.Context(), sessionkeys.IsAdmin, user.IsAdmin)
|
||||
sm.Put(r.Context(), "last_activity", time.Now().UTC())
|
||||
sm.Put(r.Context(), "flash", "Welcome back, "+user.Username+"!")
|
||||
|
||||
|
||||
152
internal/handlers/account/messages/archive.go
Normal file
152
internal/handlers/account/messages/archive.go
Normal file
@@ -0,0 +1,152 @@
|
||||
// Package accountMessageHandler
|
||||
// Path: /internal/handlers/account/messages
|
||||
// File: archive.go
|
||||
|
||||
package accountMessageHandler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
templateHandlers "synlotto-website/internal/handlers/template"
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
httpErrors "synlotto-website/internal/http/error"
|
||||
|
||||
"synlotto-website/internal/logging"
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/justinas/nosurf"
|
||||
)
|
||||
|
||||
// GET /account/messages/archived
|
||||
// Renders: web/templates/account/messages/archived.html
|
||||
func (h *AccountMessageHandlers) ArchivedList(c *gin.Context) {
|
||||
app := c.MustGet("app").(*bootstrap.App)
|
||||
sm := app.SessionManager
|
||||
userID := mustUserID(c)
|
||||
|
||||
// pagination
|
||||
page := 1
|
||||
if ps := c.Query("page"); ps != "" {
|
||||
if n, err := strconv.Atoi(ps); err == nil && n > 0 {
|
||||
page = n
|
||||
}
|
||||
}
|
||||
pageSize := 20
|
||||
|
||||
totalPages, totalCount, err := templateHelpers.GetTotalPages(
|
||||
c.Request.Context(),
|
||||
app.DB,
|
||||
"user_messages",
|
||||
"recipientId = ? AND is_archived = TRUE",
|
||||
[]any{userID},
|
||||
pageSize,
|
||||
)
|
||||
if err != nil {
|
||||
logging.Info("❌ count archived error: %v", err)
|
||||
c.String(http.StatusInternalServerError, "Failed to load archived messages")
|
||||
return
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
msgsAll, err := h.Svc.ListArchived(userID)
|
||||
if err != nil {
|
||||
logging.Info("❌ list archived error: %v", err)
|
||||
c.String(http.StatusInternalServerError, "Failed to load archived messages")
|
||||
return
|
||||
}
|
||||
|
||||
// slice in-memory for now
|
||||
start := (page - 1) * pageSize
|
||||
if start > len(msgsAll) {
|
||||
start = len(msgsAll)
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > len(msgsAll) {
|
||||
end = len(msgsAll)
|
||||
}
|
||||
msgs := msgsAll[start:end]
|
||||
|
||||
data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request)
|
||||
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data)
|
||||
if f := sm.PopString(c.Request.Context(), "flash"); f != "" {
|
||||
ctx["Flash"] = f
|
||||
}
|
||||
ctx["CSRFToken"] = nosurf.Token(c.Request)
|
||||
ctx["Title"] = "Archived Messages"
|
||||
ctx["Messages"] = msgs
|
||||
ctx["CurrentPage"] = page
|
||||
ctx["TotalPages"] = totalPages
|
||||
ctx["TotalCount"] = totalCount
|
||||
ctx["PageRange"] = templateHelpers.MakePageRange(1, totalPages)
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/messages/archived.html")
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "layout", ctx); err != nil {
|
||||
logging.Info("❌ Template render error: %v", err)
|
||||
c.String(http.StatusInternalServerError, "Error rendering archived messages")
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
|
||||
}
|
||||
|
||||
// POST /account/messages/archive
|
||||
func (h *AccountMessageHandlers) ArchivePost(c *gin.Context) {
|
||||
app := c.MustGet("app").(*bootstrap.App)
|
||||
sm := app.SessionManager
|
||||
userID := mustUserID(c)
|
||||
|
||||
idStr := c.PostForm("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
httpErrors.RenderStatus(c, sm, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Svc.Archive(userID, id); err != nil {
|
||||
logging.Info("❌ Archive error: %v", err)
|
||||
sm.Put(c.Request.Context(), "flash", "Could not archive message.")
|
||||
c.Redirect(http.StatusSeeOther, "/account/messages")
|
||||
return
|
||||
}
|
||||
|
||||
sm.Put(c.Request.Context(), "flash", "Message archived.")
|
||||
c.Redirect(http.StatusSeeOther, "/account/messages")
|
||||
}
|
||||
|
||||
// POST /account/messages/archived
|
||||
func (h *AccountMessageHandlers) RestoreArchived(c *gin.Context) {
|
||||
app := c.MustGet("app").(*bootstrap.App)
|
||||
sm := app.SessionManager
|
||||
userID := mustUserID(c)
|
||||
|
||||
idStr := c.PostForm("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
sm.Put(c.Request.Context(), "flash", "Invalid message id.")
|
||||
c.Redirect(http.StatusSeeOther, "/account/messages/archive")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Svc.Unarchive(userID, id); err != nil {
|
||||
logging.Info("❌ restore/unarchive error: %v", err)
|
||||
// If no rows affected, show friendly flash; otherwise generic message.
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
sm.Put(c.Request.Context(), "flash", "Message not found or not permitted.")
|
||||
} else {
|
||||
sm.Put(c.Request.Context(), "flash", "Could not restore message.")
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, "/account/messages/archive")
|
||||
return
|
||||
}
|
||||
|
||||
sm.Put(c.Request.Context(), "flash", "Message restored.")
|
||||
c.Redirect(http.StatusSeeOther, "/account/messages/archive")
|
||||
}
|
||||
20
internal/handlers/account/messages/list.go
Normal file
20
internal/handlers/account/messages/list.go
Normal file
@@ -0,0 +1,20 @@
|
||||
// Package accountMessageHandler
|
||||
// Path: /internal/handlers/account/messages
|
||||
// File: list.go
|
||||
// ToDo: helpers for reading getting messages shouldn't really be here. ---
|
||||
|
||||
package accountMessageHandler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func mustUserID(c *gin.Context) int64 {
|
||||
if v, ok := c.Get("userID"); ok {
|
||||
if id, ok2 := v.(int64); ok2 {
|
||||
return id
|
||||
}
|
||||
}
|
||||
// Fallback for stubs:
|
||||
return 1
|
||||
}
|
||||
173
internal/handlers/account/messages/read.go
Normal file
173
internal/handlers/account/messages/read.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// Package accountMessageHandler
|
||||
// Path: /internal/handlers/account/messages
|
||||
// File: read.go
|
||||
// ToDo: Remove SQL
|
||||
|
||||
package accountMessageHandler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
templateHandlers "synlotto-website/internal/handlers/template"
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
errors "synlotto-website/internal/http/error"
|
||||
|
||||
"synlotto-website/internal/logging"
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/justinas/nosurf"
|
||||
)
|
||||
|
||||
// GET /account/messages
|
||||
// Renders: web/templates/account/messages/index.html
|
||||
func (h *AccountMessageHandlers) List(c *gin.Context) {
|
||||
app := c.MustGet("app").(*bootstrap.App)
|
||||
sm := app.SessionManager
|
||||
userID := mustUserID(c)
|
||||
|
||||
// --- Pagination ---
|
||||
page := 1
|
||||
if ps := c.Query("page"); ps != "" {
|
||||
if n, err := strconv.Atoi(ps); err == nil && n > 0 {
|
||||
page = n
|
||||
}
|
||||
}
|
||||
|
||||
pageSize := 20
|
||||
|
||||
totalPages, totalCount, err := templateHelpers.GetTotalPages(
|
||||
c.Request.Context(),
|
||||
app.DB,
|
||||
"user_messages",
|
||||
"recipientId = ? AND is_archived = FALSE",
|
||||
[]any{userID},
|
||||
pageSize,
|
||||
)
|
||||
if err != nil {
|
||||
logging.Info("❌ count inbox error: %v", err)
|
||||
c.String(http.StatusInternalServerError, "Failed to load messages")
|
||||
return
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
// --- Data ---
|
||||
msgsAll, err := h.Svc.ListInbox(userID)
|
||||
if err != nil {
|
||||
logging.Info("❌ list inbox error: %v", err)
|
||||
c.String(http.StatusInternalServerError, "Failed to load messages")
|
||||
return
|
||||
}
|
||||
|
||||
// Temporary in-memory slice (until LIMIT/OFFSET is added)
|
||||
start := (page - 1) * pageSize
|
||||
if start > len(msgsAll) {
|
||||
start = len(msgsAll)
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > len(msgsAll) {
|
||||
end = len(msgsAll)
|
||||
}
|
||||
msgs := msgsAll[start:end]
|
||||
|
||||
// --- Template context ---
|
||||
data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request)
|
||||
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data)
|
||||
|
||||
if f := sm.PopString(c.Request.Context(), "flash"); f != "" {
|
||||
ctx["Flash"] = f
|
||||
}
|
||||
ctx["CSRFToken"] = nosurf.Token(c.Request)
|
||||
ctx["Title"] = "Messages"
|
||||
ctx["Messages"] = msgs
|
||||
ctx["CurrentPage"] = page
|
||||
ctx["TotalPages"] = totalPages
|
||||
ctx["TotalCount"] = totalCount
|
||||
ctx["PageRange"] = templateHelpers.MakePageRange(1, totalPages)
|
||||
|
||||
// --- Render ---
|
||||
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/messages/index.html")
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "layout", ctx); err != nil {
|
||||
logging.Info("❌ Template render error: %v", err)
|
||||
c.String(http.StatusInternalServerError, "Error rendering messages page")
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
|
||||
}
|
||||
|
||||
// GET /account/messages/read?id=123
|
||||
// Renders: web/templates/account/messages/read.html
|
||||
func (h *AccountMessageHandlers) ReadGet(c *gin.Context) {
|
||||
app := c.MustGet("app").(*bootstrap.App)
|
||||
sm := app.SessionManager
|
||||
userID := mustUserID(c)
|
||||
|
||||
idStr := c.Query("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
errors.RenderStatus(c, sm, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := h.Svc.GetByID(userID, id)
|
||||
if err != nil || msg == nil {
|
||||
errors.RenderStatus(c, sm, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request)
|
||||
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data)
|
||||
ctx["CSRFToken"] = nosurf.Token(c.Request)
|
||||
ctx["Title"] = msg.Subject
|
||||
ctx["Message"] = msg
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/messages/read.html")
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.ExecuteTemplate(&buf, "layout", ctx); err != nil {
|
||||
logging.Info("❌ Template render error: %v", err)
|
||||
c.String(http.StatusInternalServerError, "Error rendering message")
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
|
||||
}
|
||||
|
||||
func (h *AccountMessageHandlers) MarkReadPost(c *gin.Context) {
|
||||
app := c.MustGet("app").(*bootstrap.App)
|
||||
sm := app.SessionManager
|
||||
userID := mustUserID(c)
|
||||
|
||||
idStr := c.PostForm("id")
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
sm.Put(c.Request.Context(), "flash", "Invalid message id.")
|
||||
c.Redirect(http.StatusSeeOther, c.Request.Referer()) // back to where they came from
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Svc.MarkRead(userID, id); err != nil {
|
||||
logging.Info("❌ MarkRead error: %v", err)
|
||||
if err == sql.ErrNoRows {
|
||||
sm.Put(c.Request.Context(), "flash", "Message not found or not permitted.")
|
||||
} else {
|
||||
sm.Put(c.Request.Context(), "flash", "Could not mark message as read.")
|
||||
}
|
||||
c.Redirect(http.StatusSeeOther, "/account/messages")
|
||||
return
|
||||
}
|
||||
|
||||
sm.Put(c.Request.Context(), "flash", "Message marked as read.")
|
||||
// Redirect back to referer when possible so UX is smooth.
|
||||
if ref := c.Request.Referer(); ref != "" {
|
||||
c.Redirect(http.StatusSeeOther, ref)
|
||||
} else {
|
||||
c.Redirect(http.StatusSeeOther, "/account/messages")
|
||||
}
|
||||
}
|
||||
104
internal/handlers/account/messages/send.go
Normal file
104
internal/handlers/account/messages/send.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// Package accountMessageHandler
|
||||
// Path: /internal/handlers/account/messages
|
||||
// File: send.go
|
||||
|
||||
package accountMessageHandler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
domain "synlotto-website/internal/domain/messages"
|
||||
templateHandlers "synlotto-website/internal/handlers/template"
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
|
||||
"synlotto-website/internal/logging"
|
||||
"synlotto-website/internal/models"
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/justinas/nosurf"
|
||||
)
|
||||
|
||||
// GET /account/messages/send
|
||||
// Renders: web/templates/account/messages/send.html
|
||||
func (h *AccountMessageHandlers) SendGet(c *gin.Context) {
|
||||
app := c.MustGet("app").(*bootstrap.App)
|
||||
sm := app.SessionManager
|
||||
|
||||
data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request)
|
||||
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data)
|
||||
|
||||
if f := sm.PopString(c.Request.Context(), "flash"); f != "" {
|
||||
ctx["Flash"] = f
|
||||
}
|
||||
ctx["CSRFToken"] = nosurf.Token(c.Request)
|
||||
ctx["Title"] = "Send Message"
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/messages/send.html")
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil {
|
||||
logging.Info("❌ Template render error: %v", err)
|
||||
c.String(http.StatusInternalServerError, "Error rendering send message page")
|
||||
}
|
||||
}
|
||||
|
||||
// POST /account/messages/send
|
||||
func (h *AccountMessageHandlers) SendPost(c *gin.Context) {
|
||||
app := c.MustGet("app").(*bootstrap.App)
|
||||
sm := app.SessionManager
|
||||
|
||||
userID := mustUserID(c)
|
||||
|
||||
var in domain.CreateMessageInput
|
||||
if err := c.ShouldBind(&in); err != nil {
|
||||
// Re-render form with validation errors
|
||||
data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request)
|
||||
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data)
|
||||
if f := sm.PopString(c.Request.Context(), "flash"); f != "" {
|
||||
ctx["Flash"] = f
|
||||
}
|
||||
ctx["CSRFToken"] = nosurf.Token(c.Request)
|
||||
ctx["Title"] = "Send Message"
|
||||
ctx["Error"] = "Please correct the errors below."
|
||||
ctx["Form"] = in
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles(
|
||||
"layout.html",
|
||||
"web/templates/account/messages/send.html",
|
||||
)
|
||||
|
||||
c.Status(http.StatusBadRequest)
|
||||
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil {
|
||||
logging.Info("❌ Template render error: %v", err)
|
||||
c.String(http.StatusInternalServerError, "Error rendering send message page")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.Svc.Create(userID, in); err != nil {
|
||||
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, models.TemplateData{})
|
||||
if f := sm.PopString(c.Request.Context(), "flash"); f != "" {
|
||||
ctx["Flash"] = f
|
||||
}
|
||||
ctx["CSRFToken"] = nosurf.Token(c.Request)
|
||||
ctx["Title"] = "Send Message"
|
||||
ctx["Error"] = "Could not send message."
|
||||
ctx["Form"] = in
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/messages/send.html")
|
||||
|
||||
c.Status(http.StatusInternalServerError)
|
||||
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil {
|
||||
logging.Info("❌ Template render error: %v", err)
|
||||
c.String(http.StatusInternalServerError, "Error rendering send message page")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sm.Put(c.Request.Context(), "flash", "Message sent!")
|
||||
|
||||
// Redirect back to inbox
|
||||
c.Redirect(http.StatusSeeOther, "/account/messages")
|
||||
}
|
||||
11
internal/handlers/account/messages/types.go
Normal file
11
internal/handlers/account/messages/types.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// Package accountMessageHandler
|
||||
// Path: /internal/handlers/account/messages
|
||||
// File: types.go
|
||||
|
||||
package accountMessageHandler
|
||||
|
||||
import domain "synlotto-website/internal/domain/messages"
|
||||
|
||||
type AccountMessageHandlers struct {
|
||||
Svc domain.MessageService
|
||||
}
|
||||
75
internal/handlers/account/notifications/list.go
Normal file
75
internal/handlers/account/notifications/list.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package accountNotificationHandler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
|
||||
"synlotto-website/internal/logging"
|
||||
"synlotto-website/internal/models"
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/justinas/nosurf"
|
||||
)
|
||||
|
||||
// ToDo: functional also in messages needs to come out
|
||||
func mustUserID(c *gin.Context) int64 {
|
||||
// Pull from your auth middleware/session. Panic-unsafe alternative:
|
||||
if v, ok := c.Get("userID"); ok {
|
||||
if id, ok2 := v.(int64); ok2 {
|
||||
return id
|
||||
}
|
||||
}
|
||||
// Fallback for stubs:
|
||||
return 1
|
||||
}
|
||||
|
||||
// ToDo: functional also in messages needs to come out
|
||||
func atoi64(s string) (int64, error) {
|
||||
// small helper to keep imports focused
|
||||
// replace with strconv.ParseInt in real code
|
||||
var n int64
|
||||
for _, ch := range []byte(s) {
|
||||
if ch < '0' || ch > '9' {
|
||||
return 0, &strconvNumErr{}
|
||||
}
|
||||
n = n*10 + int64(ch-'0')
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
type strconvNumErr struct{}
|
||||
|
||||
func (e *strconvNumErr) Error() string { return "invalid number" }
|
||||
|
||||
// GET /account/notifications/:id
|
||||
// Renders: web/templates/account/notifications/read.html
|
||||
func (h *AccountNotificationHandlers) List(c *gin.Context) {
|
||||
app := c.MustGet("app").(*bootstrap.App)
|
||||
sm := app.SessionManager
|
||||
|
||||
userID := mustUserID(c)
|
||||
notes, err := h.Svc.List(userID) // or ListAll/ListUnread – use your method name
|
||||
if err != nil {
|
||||
logging.Info("❌ list notifications error: %v", err)
|
||||
c.String(http.StatusInternalServerError, "Failed to load notifications")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, models.TemplateData{})
|
||||
if f := sm.PopString(c.Request.Context(), "flash"); f != "" {
|
||||
ctx["Flash"] = f
|
||||
}
|
||||
ctx["CSRFToken"] = nosurf.Token(c.Request)
|
||||
ctx["Title"] = "Notifications"
|
||||
ctx["Notifications"] = notes
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/notifications/index.html")
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil {
|
||||
logging.Info("❌ Template render error: %v", err)
|
||||
c.String(http.StatusInternalServerError, "Error rendering notifications page")
|
||||
}
|
||||
}
|
||||
58
internal/handlers/account/notifications/read.go
Normal file
58
internal/handlers/account/notifications/read.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// internal/handlers/account/notifications/read.go
|
||||
package accountNotificationHandler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
"synlotto-website/internal/logging"
|
||||
"synlotto-website/internal/models"
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/justinas/nosurf"
|
||||
)
|
||||
|
||||
// ToDo: functional also in messages needs to come out
|
||||
func parseIDParam(c *gin.Context, name string) (int64, error) {
|
||||
// typical atoi wrapper
|
||||
// (implement: strconv.ParseInt(c.Param(name), 10, 64))
|
||||
return atoi64(c.Param(name))
|
||||
}
|
||||
|
||||
func (h *AccountNotificationHandlers) ReadGet(c *gin.Context) {
|
||||
app := c.MustGet("app").(*bootstrap.App)
|
||||
sm := app.SessionManager
|
||||
|
||||
userID := mustUserID(c)
|
||||
id, err := parseIDParam(c, "id")
|
||||
if err != nil {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
n, err := h.Svc.GetByID(userID, id)
|
||||
if err != nil || n == nil {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, models.TemplateData{})
|
||||
if f := sm.PopString(c.Request.Context(), "flash"); f != "" {
|
||||
ctx["Flash"] = f
|
||||
}
|
||||
ctx["CSRFToken"] = nosurf.Token(c.Request)
|
||||
ctx["Title"] = n.Title // or Subject/Heading depending on your struct
|
||||
ctx["Notification"] = n
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles(
|
||||
"layout.html",
|
||||
"web/templates/account/notifications/read.html",
|
||||
)
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil {
|
||||
logging.Info("❌ Template render error: %v", err)
|
||||
c.String(http.StatusInternalServerError, "Error rendering notification")
|
||||
}
|
||||
}
|
||||
7
internal/handlers/account/notifications/types.go
Normal file
7
internal/handlers/account/notifications/types.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package accountNotificationHandler
|
||||
|
||||
import domain "synlotto-website/internal/domain/notifications"
|
||||
|
||||
type AccountNotificationHandlers struct {
|
||||
Svc domain.NotificationService
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// internal/handlers/account/signup.go
|
||||
package accountHandler
|
||||
|
||||
import (
|
||||
@@ -20,7 +19,6 @@ import (
|
||||
"github.com/justinas/nosurf"
|
||||
)
|
||||
|
||||
// kept for handler-local parsing only (NOT stored in session)
|
||||
type registerForm struct {
|
||||
Username string
|
||||
Email string
|
||||
@@ -39,7 +37,6 @@ func SignupGet(c *gin.Context) {
|
||||
}
|
||||
ctx["CSRFToken"] = nosurf.Token(c.Request)
|
||||
|
||||
// Rehydrate maps (not structs) from session for sticky form + field errors
|
||||
if v := sm.Pop(c.Request.Context(), "register.form"); v != nil {
|
||||
if fm, ok := v.(map[string]string); ok {
|
||||
ctx["Form"] = fm
|
||||
@@ -51,11 +48,7 @@ func SignupGet(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// layout-first, finalized path
|
||||
tmpl := templateHelpers.LoadTemplateFiles(
|
||||
"web/templates/layout.html",
|
||||
"web/templates/account/signup.html",
|
||||
)
|
||||
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/signup.html")
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil {
|
||||
@@ -79,9 +72,8 @@ func SignupPost(c *gin.Context) {
|
||||
AcceptTerms: r.FormValue("accept_terms") == "on",
|
||||
}
|
||||
|
||||
errors := validateRegisterForm(db, form)
|
||||
if len(errors) > 0 {
|
||||
// ✅ Stash maps instead of a struct → gob-safe with SCS
|
||||
errMap := validateRegisterForm(db, form)
|
||||
if len(errMap) > 0 {
|
||||
formMap := map[string]string{
|
||||
"username": form.Username,
|
||||
"email": form.Email,
|
||||
@@ -93,7 +85,7 @@ func SignupPost(c *gin.Context) {
|
||||
}(),
|
||||
}
|
||||
sm.Put(r.Context(), "register.form", formMap)
|
||||
sm.Put(r.Context(), "register.errors", errors)
|
||||
sm.Put(r.Context(), "register.errors", errMap)
|
||||
sm.Put(r.Context(), "flash", "Please fix the highlighted errors.")
|
||||
|
||||
c.Redirect(http.StatusSeeOther, "/account/signup")
|
||||
@@ -101,7 +93,6 @@ func SignupPost(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Hash password
|
||||
hash, err := securityHelpers.HashPassword(form.Password)
|
||||
if err != nil {
|
||||
logging.Info("❌ Hash error: %v", err)
|
||||
@@ -111,18 +102,15 @@ func SignupPost(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create user
|
||||
id, err := usersStorage.CreateUser(db, form.Username, form.Email, hash)
|
||||
if err != nil {
|
||||
logging.Info("❌ CreateUser error: %v", err)
|
||||
// Unique constraints might still trip here
|
||||
sm.Put(r.Context(), "flash", "That username or email is already taken.")
|
||||
c.Redirect(http.StatusSeeOther, "/account/signup")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Audit registration
|
||||
auditlogStorage.LogSignup(
|
||||
db,
|
||||
id,
|
||||
@@ -165,6 +153,5 @@ func validateRegisterForm(db *sql.DB, f registerForm) map[string]string {
|
||||
}
|
||||
|
||||
func looksLikeEmail(s string) bool {
|
||||
// Keep it simple; you can swap for a stricter validator later
|
||||
return strings.Count(s, "@") == 1 && strings.Contains(s, ".")
|
||||
}
|
||||
|
||||
161
internal/handlers/account/tickets/add.go
Normal file
161
internal/handlers/account/tickets/add.go
Normal file
@@ -0,0 +1,161 @@
|
||||
// Package accountTicketHandlers
|
||||
// Path: /internal/handlers/account/tickets/
|
||||
// File: add.go
|
||||
//
|
||||
// Purpose
|
||||
// Renders & processes the Add Ticket form for authenticated users.
|
||||
//
|
||||
// Responsibilities
|
||||
// 1) Validate user input (game type, draw date, balls and optional bonuses)
|
||||
// 2) Convert string form values into typed model fields
|
||||
// 3) Save through storage layer (InsertTicket)
|
||||
// 4) Prevent DB access from unauthenticated contexts
|
||||
// 5) Use PRG pattern (POST/Redirect/GET)
|
||||
//
|
||||
// Notes
|
||||
// - No direct SQL here — storage package enforces constraints
|
||||
// - CSRF provided via nosurf
|
||||
// - TODO: Replace inline session key with central sessionkeys.UserID
|
||||
|
||||
package accountTicketHandlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
templateHandlers "synlotto-website/internal/handlers/template"
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
ticketStorage "synlotto-website/internal/storage/tickets"
|
||||
|
||||
"synlotto-website/internal/models"
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/justinas/nosurf"
|
||||
)
|
||||
|
||||
// TODO: Replace with centralized key from sessionkeys package
|
||||
const sessionKeyUserID = "UserID"
|
||||
|
||||
func AddGet(c *gin.Context) {
|
||||
app := c.MustGet("app").(*bootstrap.App)
|
||||
data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request)
|
||||
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, data)
|
||||
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/tickets/add_ticket.html")
|
||||
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.ExecuteTemplate(c.Writer, "account/tickets/add_ticket.html", ctx); err != nil {
|
||||
c.String(http.StatusInternalServerError, "render error: %v", err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func AddPost(c *gin.Context) {
|
||||
app := c.MustGet("app").(*bootstrap.App)
|
||||
|
||||
var f addForm
|
||||
_ = c.ShouldBind(&f)
|
||||
f.Errors = map[string]string{}
|
||||
|
||||
// Validate required fields
|
||||
if f.GameType == "" {
|
||||
f.Errors["game"] = "Game type is required."
|
||||
}
|
||||
if f.DrawDate == "" {
|
||||
f.Errors["draw_date"] = "Draw date is required."
|
||||
}
|
||||
|
||||
balls, ballErrs := parseBalls(f.Ball1, f.Ball2, f.Ball3, f.Ball4, f.Ball5)
|
||||
for k, v := range ballErrs {
|
||||
f.Errors[k] = v
|
||||
}
|
||||
|
||||
var drawDate time.Time
|
||||
if f.DrawDate != "" {
|
||||
if d, err := time.Parse("2006-01-02", f.DrawDate); err == nil {
|
||||
drawDate = d
|
||||
} else {
|
||||
f.Errors["draw_date"] = "Invalid date (use YYYY-MM-DD)."
|
||||
}
|
||||
}
|
||||
|
||||
var bonus1Ptr, bonus2Ptr *int
|
||||
if f.Bonus1 != "" {
|
||||
if n, err := strconv.Atoi(f.Bonus1); err == nil {
|
||||
bonus1Ptr = &n
|
||||
} else {
|
||||
f.Errors["bonus1"] = "Bonus 1 must be a number."
|
||||
}
|
||||
}
|
||||
if f.Bonus2 != "" {
|
||||
if n, err := strconv.Atoi(f.Bonus2); err == nil {
|
||||
bonus2Ptr = &n
|
||||
} else {
|
||||
f.Errors["bonus2"] = "Bonus 2 must be a number."
|
||||
}
|
||||
}
|
||||
|
||||
if len(f.Errors) > 0 {
|
||||
f.CSRFToken = nosurf.Token(c.Request)
|
||||
c.HTML(http.StatusUnprocessableEntity, "account/tickets/add_ticket.html", gin.H{
|
||||
"title": "Add Ticket",
|
||||
"form": f,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Build the ticket model expected by ticketStorage.InsertTicket
|
||||
ticket := models.Ticket{
|
||||
GameType: f.GameType,
|
||||
DrawDate: drawDate,
|
||||
Ball1: balls[0],
|
||||
Ball2: balls[1],
|
||||
Ball3: balls[2],
|
||||
Ball4: balls[3],
|
||||
Ball5: balls[4],
|
||||
Bonus1: bonus1Ptr,
|
||||
Bonus2: bonus2Ptr,
|
||||
// TODO: populate UserID from session when per-user tickets enabled
|
||||
}
|
||||
|
||||
if err := ticketStorage.InsertTicket(app.DB, ticket); err != nil {
|
||||
// optional: set flash and re-render
|
||||
f.Errors["form"] = "Could not save ticket. Please try again."
|
||||
f.CSRFToken = nosurf.Token(c.Request)
|
||||
c.HTML(http.StatusInternalServerError, "account/tickets/add_ticket.html", gin.H{
|
||||
"title": "Add Ticket",
|
||||
"form": f,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusSeeOther, "/account/tickets")
|
||||
}
|
||||
|
||||
// helpers
|
||||
func parseBalls(b1, b2, b3, b4, b5 string) ([5]int, map[string]string) {
|
||||
errs := map[string]string{}
|
||||
toInt := func(name, v string) (int, bool) {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
errs[name] = "Must be a number."
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
var out [5]int
|
||||
ok := true
|
||||
if out[0], ok = toInt("ball1", b1); !ok {
|
||||
}
|
||||
if out[1], ok = toInt("ball2", b2); !ok {
|
||||
}
|
||||
if out[2], ok = toInt("ball3", b3); !ok {
|
||||
}
|
||||
if out[3], ok = toInt("ball4", b4); !ok {
|
||||
}
|
||||
if out[4], ok = toInt("ball5", b5); !ok {
|
||||
}
|
||||
return out, errs
|
||||
}
|
||||
69
internal/handlers/account/tickets/list.go
Normal file
69
internal/handlers/account/tickets/list.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Package accountTicketHandlers
|
||||
// Path: /internal/handlers/account/tickets/
|
||||
// File: list.go
|
||||
//
|
||||
// Purpose
|
||||
// List all tickets belonging to the currently authenticated user.
|
||||
//
|
||||
// Responsibilities
|
||||
// - Validate session context
|
||||
// - Query DB for tickets filtered by user_id
|
||||
// - Transform rows into template-safe values
|
||||
//
|
||||
// TODO
|
||||
// - Move SQL query into storage layer (read model)
|
||||
// - Support pagination or date filtering
|
||||
|
||||
package accountTicketHandlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/justinas/nosurf"
|
||||
)
|
||||
|
||||
func List(c *gin.Context) {
|
||||
app := c.MustGet("app").(*bootstrap.App)
|
||||
sm := app.SessionManager
|
||||
|
||||
userIDAny := sm.Get(c.Request.Context(), sessionKeyUserID)
|
||||
userID, ok := userIDAny.(int64)
|
||||
if !ok || userID == 0 {
|
||||
c.Redirect(http.StatusSeeOther, "/account/login")
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := app.DB.QueryContext(c.Request.Context(), `
|
||||
SELECT id, numbers, game, price, purchased_at, created_at
|
||||
FROM my_tickets
|
||||
WHERE userId = ?
|
||||
ORDER BY purchased_at DESC, id DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
c.HTML(http.StatusInternalServerError, "account/tickets/my_tickets.html", gin.H{
|
||||
"title": "My Tickets",
|
||||
"err": "Could not load your tickets.",
|
||||
})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []ticketRow
|
||||
for rows.Next() {
|
||||
var t ticketRow
|
||||
if err := rows.Scan(&t.ID, &t.Numbers, &t.Game, &t.Price, &t.PurchasedAt, &t.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, t)
|
||||
}
|
||||
|
||||
view := gin.H{
|
||||
"title": "My Tickets",
|
||||
"tickets": items,
|
||||
"csrfToken": nosurf.Token(c.Request), // useful if list page has inline delete in future
|
||||
}
|
||||
c.HTML(http.StatusOK, "account/tickets/my_tickets.html", view)
|
||||
}
|
||||
39
internal/handlers/account/tickets/types.go
Normal file
39
internal/handlers/account/tickets/types.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Package accountTicketHandlers
|
||||
// Path: /internal/handlers/account/tickets/
|
||||
// File: types.go
|
||||
//
|
||||
// Purpose
|
||||
// Form and view models for ticket create + list flows.
|
||||
// These types are not persisted directly.
|
||||
//
|
||||
// Notes
|
||||
// Mapping exists only from request → model → template
|
||||
|
||||
package accountTicketHandlers
|
||||
|
||||
import "time"
|
||||
|
||||
// Add Ticket form structure
|
||||
type addForm struct {
|
||||
GameType string `form:"game"` // e.g. "Lotto", "EuroMillions"
|
||||
DrawDate string `form:"draw_date"` // yyyy-mm-dd from <input type="date">
|
||||
Ball1 string `form:"ball1"`
|
||||
Ball2 string `form:"ball2"`
|
||||
Ball3 string `form:"ball3"`
|
||||
Ball4 string `form:"ball4"`
|
||||
Ball5 string `form:"ball5"`
|
||||
Bonus1 string `form:"bonus1"` // optional
|
||||
Bonus2 string `form:"bonus2"` // optional
|
||||
Errors map[string]string
|
||||
CSRFToken string
|
||||
}
|
||||
|
||||
// Ticket list renderer (subset of DB ticket fields)
|
||||
type ticketRow struct {
|
||||
ID int64
|
||||
Numbers string
|
||||
Game *string
|
||||
Price *string
|
||||
PurchasedAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -145,16 +145,24 @@ func SyndicateLogTicketHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
|
||||
case http.MethodPost:
|
||||
gameType := r.FormValue("game_type")
|
||||
drawDate := r.FormValue("draw_date")
|
||||
drawDateStr := r.FormValue("draw_date")
|
||||
method := r.FormValue("purchase_method")
|
||||
|
||||
err := ticketStorage.InsertTicket(app.DB, models.Ticket{
|
||||
dt, err := helpers.ParseDrawDate(drawDateStr)
|
||||
if err != nil {
|
||||
templateHelpers.SetFlash(r, "Invalid draw date")
|
||||
http.Redirect(w, r, fmt.Sprintf("/syndicate/view?id=%d", syndicateId), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
err = ticketStorage.InsertTicket(app.DB, models.Ticket{
|
||||
UserId: userID,
|
||||
GameType: gameType,
|
||||
DrawDate: drawDate,
|
||||
DrawDate: dt,
|
||||
PurchaseMethod: method,
|
||||
SyndicateId: &syndicateId,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
templateHelpers.SetFlash(r, "Failed to add ticket.")
|
||||
} else {
|
||||
|
||||
@@ -74,10 +74,18 @@ func AddTicket(app *bootstrap.App) http.HandlerFunc {
|
||||
}
|
||||
|
||||
game := r.FormValue("game_type")
|
||||
drawDate := r.FormValue("draw_date")
|
||||
drawDateStr := r.FormValue("draw_date")
|
||||
purchaseMethod := r.FormValue("purchase_method")
|
||||
purchaseDate := r.FormValue("purchase_date")
|
||||
purchaseTime := r.FormValue("purchase_time")
|
||||
|
||||
dt, err := helpers.ParseDrawDate(drawDateStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid draw date", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
drawDateDB := helpers.FormatDrawDate(dt) // "YYYY-MM-DD"
|
||||
|
||||
if purchaseTime != "" {
|
||||
purchaseDate += "T" + purchaseTime
|
||||
}
|
||||
@@ -165,7 +173,7 @@ func AddTicket(app *bootstrap.App) http.HandlerFunc {
|
||||
purchase_method, purchase_date, image_path
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
userID, game, drawDate,
|
||||
userID, game, drawDateDB,
|
||||
b[0], b[1], b[2], b[3], b[4], b[5],
|
||||
bo[0], bo[1],
|
||||
purchaseMethod, purchaseDate, imagePath,
|
||||
@@ -195,10 +203,18 @@ func SubmitTicket(app *bootstrap.App) http.HandlerFunc {
|
||||
}
|
||||
|
||||
game := r.FormValue("game_type")
|
||||
drawDate := r.FormValue("draw_date")
|
||||
drawDateStr := r.FormValue("draw_date")
|
||||
purchaseMethod := r.FormValue("purchase_method")
|
||||
purchaseDate := r.FormValue("purchase_date")
|
||||
purchaseTime := r.FormValue("purchase_time")
|
||||
|
||||
dt, err := helpers.ParseDrawDate(drawDateStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid draw date", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
drawDateDB := helpers.FormatDrawDate(dt)
|
||||
|
||||
if purchaseTime != "" {
|
||||
purchaseDate += "T" + purchaseTime
|
||||
}
|
||||
@@ -253,7 +269,7 @@ func SubmitTicket(app *bootstrap.App) http.HandlerFunc {
|
||||
purchase_method, purchase_date, image_path
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
userID, game, drawDate,
|
||||
userID, game, drawDateDB,
|
||||
b[0], b[1], b[2], b[3], b[4], b[5],
|
||||
bo[0], bo[1],
|
||||
purchaseMethod, purchaseDate, imagePath,
|
||||
@@ -299,6 +315,7 @@ func GetMyTickets(app *bootstrap.App) http.HandlerFunc {
|
||||
|
||||
for rows.Next() {
|
||||
var t models.Ticket
|
||||
var drawDateStr string // ← add
|
||||
var b1, b2, b3, b4, b5, b6, bo1, bo2 sql.NullInt64
|
||||
var matchedMain, matchedBonus sql.NullInt64
|
||||
var prizeTier sql.NullString
|
||||
@@ -307,7 +324,7 @@ func GetMyTickets(app *bootstrap.App) http.HandlerFunc {
|
||||
var prizeAmount sql.NullFloat64
|
||||
|
||||
if err := rows.Scan(
|
||||
&t.Id, &t.GameType, &t.DrawDate,
|
||||
&t.Id, &t.GameType, &drawDateStr, // ← was &t.DrawDate
|
||||
&b1, &b2, &b3, &b4, &b5, &b6,
|
||||
&bo1, &bo2,
|
||||
&t.PurchaseMethod, &t.PurchaseDate, &t.ImagePath, &t.Duplicate,
|
||||
@@ -317,6 +334,11 @@ func GetMyTickets(app *bootstrap.App) http.HandlerFunc {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse into time.Time (UTC)
|
||||
if dt, err := helpers.ParseDrawDate(drawDateStr); err == nil {
|
||||
t.DrawDate = dt
|
||||
}
|
||||
|
||||
// Normalize fields
|
||||
t.Ball1 = int(b1.Int64)
|
||||
t.Ball2 = int(b2.Int64)
|
||||
@@ -351,7 +373,7 @@ func GetMyTickets(app *bootstrap.App) http.HandlerFunc {
|
||||
t.BonusBalls = helpers.BuildBonusSlice(t)
|
||||
|
||||
// Fetch matching draw info
|
||||
draw := draws.GetDrawResultForTicket(app.DB, t.GameType, t.DrawDate)
|
||||
draw := draws.GetDrawResultForTicket(app.DB, t.GameType, helpers.FormatDrawDate(t.DrawDate))
|
||||
t.MatchedDraw = draw
|
||||
|
||||
tickets = append(tickets, t)
|
||||
|
||||
@@ -177,6 +177,6 @@ func RestoreMessageHandler(app *bootstrap.App) http.HandlerFunc {
|
||||
templateHelpers.SetFlash(r, "Message restored.")
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/account/messages/archived", http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/account/messages/archive", http.StatusSeeOther)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,8 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
|
||||
"synlotto-website/internal/helpers"
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
"synlotto-website/internal/http/middleware"
|
||||
"synlotto-website/internal/models"
|
||||
)
|
||||
@@ -20,7 +19,6 @@ 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
|
||||
@@ -46,7 +44,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
|
||||
doSearch := isValidDate(query) || isValidNumber(query)
|
||||
|
||||
whereClause := "WHERE 1=1"
|
||||
args := []interface{}{}
|
||||
args := []any{}
|
||||
|
||||
if doSearch {
|
||||
whereClause += " AND (draw_date = ? OR id = ?)"
|
||||
@@ -65,7 +63,21 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
|
||||
args = append(args, ballSetFilter)
|
||||
}
|
||||
|
||||
totalPages, totalResults := templateHelpers.GetTotalPages(db, "results_thunderball", whereClause, args, pageSize)
|
||||
// ✅ FIX: Proper GetTotalPages call with context + correct table name
|
||||
totalPages, totalResults, err := templateHelpers.GetTotalPages(
|
||||
r.Context(),
|
||||
db,
|
||||
"results_thunderball",
|
||||
whereClause,
|
||||
args,
|
||||
pageSize,
|
||||
)
|
||||
if err != nil {
|
||||
log.Println("❌ Pagination count error:", err)
|
||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if page < 1 || page > totalPages {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -79,7 +91,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
|
||||
LIMIT ? OFFSET ?`
|
||||
argsWithLimit := append(args, pageSize, offset)
|
||||
|
||||
rows, err := db.Query(querySQL, argsWithLimit...)
|
||||
rows, err := db.QueryContext(r.Context(), querySQL, argsWithLimit...)
|
||||
if err != nil {
|
||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||
log.Println("❌ DB error:", err)
|
||||
@@ -113,7 +125,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
|
||||
noResultsMsg = "No results found for \"" + query + "\""
|
||||
}
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles("thunderball.html", "web/templates/results/thunderball.html")
|
||||
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/results/thunderball.html")
|
||||
|
||||
err = tmpl.ExecuteTemplate(w, "layout", map[string]interface{}{
|
||||
"Results": results,
|
||||
|
||||
31
internal/helpers/dates.go
Normal file
31
internal/helpers/dates.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
var drawDateLayouts = []string{
|
||||
time.RFC3339, // 2006-01-02T15:04:05Z07:00
|
||||
"2006-01-02", // 2025-10-29
|
||||
"2006-01-02 15:04", // 2025-10-29 20:30
|
||||
"2006-01-02 15:04:05", // 2025-10-29 20:30:59
|
||||
}
|
||||
|
||||
// ParseDrawDate tries multiple layouts and returns UTC.
|
||||
func ParseDrawDate(s string) (time.Time, error) {
|
||||
for _, l := range drawDateLayouts {
|
||||
if t, err := time.ParseInLocation(l, s, time.Local); err == nil {
|
||||
return t.UTC(), nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("cannot parse draw date: %q", s)
|
||||
}
|
||||
|
||||
// FormatDrawDate normalizes a time to the storage format you use in SQL (date only).
|
||||
func FormatDrawDate(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format("2006-01-02")
|
||||
}
|
||||
@@ -47,6 +47,7 @@ func TemplateContext(w http.ResponseWriter, r *http.Request, data models.Templat
|
||||
}
|
||||
}
|
||||
|
||||
// ToDo the funcs need breaking up getting large
|
||||
func TemplateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"plus1": func(i int) int { return i + 1 },
|
||||
|
||||
@@ -1,27 +1,72 @@
|
||||
// internal/helpers/pagination/pagination.go (move out of template/*)
|
||||
package templateHelper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ToDo: Sql shouldnt be here.
|
||||
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
|
||||
// Whitelist
|
||||
var allowedTables = map[string]struct{}{
|
||||
"user_messages": {},
|
||||
"user_notifications": {},
|
||||
"results_thunderball": {},
|
||||
}
|
||||
|
||||
// GetTotalPages counts rows and returns (totalPages, totalCount).
|
||||
func GetTotalPages(ctx context.Context, db *sql.DB, table, whereClause string, args []any, pageSize int) (int, int64, error) {
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
totalPages = (totalCount + pageSize - 1) / pageSize
|
||||
if _, ok := allowedTables[table]; !ok {
|
||||
return 1, 0, fmt.Errorf("table not allowed: %s", table)
|
||||
}
|
||||
|
||||
q := fmt.Sprintf("SELECT COUNT(*) FROM %s", table)
|
||||
if whereClause != "" {
|
||||
q += " WHERE " + whereClause
|
||||
}
|
||||
|
||||
var totalCount int64
|
||||
cctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := db.QueryRowContext(cctx, q, args...).Scan(&totalCount); err != nil {
|
||||
return 1, 0, fmt.Errorf("count %s: %w", table, err)
|
||||
}
|
||||
totalPages := int((totalCount + int64(pageSize) - 1) / int64(pageSize))
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
return totalPages, totalCount
|
||||
return totalPages, totalCount, nil
|
||||
}
|
||||
|
||||
func MakePageRange(current, total int) []int {
|
||||
var pages []int
|
||||
if total < 1 {
|
||||
return []int{1}
|
||||
}
|
||||
|
||||
pages := make([]int, 0, total)
|
||||
for i := 1; i <= total; i++ {
|
||||
pages = append(pages, i)
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
func ClampPage(p, total int) int {
|
||||
if p < 1 {
|
||||
return 1
|
||||
}
|
||||
if p > total {
|
||||
return total
|
||||
}
|
||||
return p
|
||||
}
|
||||
func OffsetLimit(page, pageSize int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
return (page - 1) * pageSize, pageSize
|
||||
}
|
||||
|
||||
@@ -6,37 +6,69 @@ import (
|
||||
"os"
|
||||
|
||||
templateHelpers "synlotto-website/internal/helpers/template"
|
||||
|
||||
"synlotto-website/internal/models"
|
||||
"synlotto-website/internal/platform/sessionkeys"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RenderStatus renders web/templates/error/<status>.html inside layout.html.
|
||||
// RenderStatus renders web/templates/error/<status>.html inside layout.html,
|
||||
// using ONLY session data (no DB) so 404/500 pages don't crash and still
|
||||
// look "logged in" when a session exists.
|
||||
func RenderStatus(c *gin.Context, sessions *scs.SessionManager, status int) {
|
||||
// Base context
|
||||
ctx := templateHelpers.TemplateContext(c.Writer, c.Request, models.TemplateData{})
|
||||
|
||||
// Flash (SCS)
|
||||
if f := sessions.PopString(c.Request.Context(), "flash"); f != "" {
|
||||
ctx["Flash"] = f
|
||||
r := c.Request
|
||||
uid := int64(0)
|
||||
if v := sessions.Get(r.Context(), sessionkeys.UserID); v != nil {
|
||||
switch t := v.(type) {
|
||||
case int64:
|
||||
uid = t
|
||||
case int:
|
||||
uid = int64(t)
|
||||
}
|
||||
}
|
||||
|
||||
// --- build minimal template data from session
|
||||
var data models.TemplateData
|
||||
if uid > 0 {
|
||||
uname := ""
|
||||
if v := sessions.Get(r.Context(), sessionkeys.Username); v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
uname = s
|
||||
}
|
||||
}
|
||||
isAdmin := false
|
||||
if v := sessions.Get(r.Context(), sessionkeys.IsAdmin); v != nil {
|
||||
if b, ok := v.(bool); ok {
|
||||
isAdmin = b
|
||||
}
|
||||
}
|
||||
data.User = &models.User{
|
||||
Id: uid,
|
||||
Username: uname,
|
||||
IsAdmin: isAdmin,
|
||||
}
|
||||
data.IsAdmin = isAdmin
|
||||
}
|
||||
|
||||
ctxMap := templateHelpers.TemplateContext(c.Writer, r, data)
|
||||
if f := sessions.PopString(r.Context(), sessionkeys.Flash); f != "" {
|
||||
ctxMap["Flash"] = f
|
||||
}
|
||||
|
||||
// Use your finalized paths
|
||||
pagePath := fmt.Sprintf("web/templates/error/%d.html", status)
|
||||
if _, err := os.Stat(pagePath); err != nil {
|
||||
c.String(status, http.StatusText(status))
|
||||
return
|
||||
}
|
||||
|
||||
// Keep your "layout first" load order
|
||||
tmpl := templateHelpers.LoadTemplateFiles(
|
||||
"web/templates/layout.html",
|
||||
pagePath,
|
||||
)
|
||||
|
||||
c.Status(status)
|
||||
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctx); err != nil {
|
||||
if err := tmpl.ExecuteTemplate(c.Writer, "layout", ctxMap); err != nil {
|
||||
c.String(status, http.StatusText(status))
|
||||
}
|
||||
}
|
||||
@@ -45,11 +77,13 @@ func RenderStatus(c *gin.Context, sessions *scs.SessionManager, status int) {
|
||||
func NoRoute(sessions *scs.SessionManager) gin.HandlerFunc {
|
||||
return func(c *gin.Context) { RenderStatus(c, sessions, http.StatusNotFound) }
|
||||
}
|
||||
|
||||
func NoMethod(sessions *scs.SessionManager) gin.HandlerFunc {
|
||||
return func(c *gin.Context) { RenderStatus(c, sessions, http.StatusMethodNotAllowed) }
|
||||
}
|
||||
|
||||
func Recovery(sessions *scs.SessionManager) gin.RecoveryFunc {
|
||||
return func(c *gin.Context, _ interface{}) {
|
||||
return func(c *gin.Context, rec interface{}) {
|
||||
RenderStatus(c, sessions, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
52
internal/http/middleware/admin.go
Normal file
52
internal/http/middleware/admin.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
httphelpers "synlotto-website/internal/helpers/http"
|
||||
securityHelpers "synlotto-website/internal/helpers/security"
|
||||
auditlogStorage "synlotto-website/internal/storage/auditlog"
|
||||
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AdminOnly() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
app := c.MustGet("app").(*bootstrap.App)
|
||||
sm := app.SessionManager
|
||||
ctx := c.Request.Context()
|
||||
|
||||
v := sm.Get(ctx, "user_id")
|
||||
var uid int64
|
||||
switch t := v.(type) {
|
||||
case int64:
|
||||
uid = t
|
||||
case int:
|
||||
uid = int64(t)
|
||||
default:
|
||||
c.Redirect(http.StatusSeeOther, "/account/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if !securityHelpers.IsAdmin(app.DB, int(uid)) {
|
||||
c.String(http.StatusForbidden, "Forbidden")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
auditlogStorage.LogAdminAccess(
|
||||
app.DB,
|
||||
uid,
|
||||
c.Request.URL.Path,
|
||||
httphelpers.ClientIP(c.Request),
|
||||
c.Request.UserAgent(),
|
||||
time.Now().UTC(),
|
||||
)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ func AuthMiddleware() gin.HandlerFunc {
|
||||
|
||||
if v := sm.Get(ctx, sessionkeys.LastActivity); v != nil {
|
||||
if last, ok := v.(time.Time); ok && time.Since(last) > sm.Lifetime {
|
||||
// don't destroy here; just rotate and bounce to login with a flash
|
||||
_ = sm.RenewToken(ctx)
|
||||
sm.Put(ctx, sessionkeys.Flash, "Your session has timed out.")
|
||||
c.Redirect(http.StatusSeeOther, "/account/login")
|
||||
@@ -29,7 +30,10 @@ func AuthMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
sm.Put(ctx, sessionkeys.LastActivity, time.Now().UTC())
|
||||
// if logged in, update last activity
|
||||
if sm.Exists(ctx, sessionkeys.UserID) {
|
||||
sm.Put(ctx, sessionkeys.LastActivity, time.Now().UTC())
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -66,8 +70,7 @@ func RememberMiddleware(app *bootstrap.App) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
if sessionHelper.HashVerifier(verifier) != hash {
|
||||
// Tampered token – revoke for safety.
|
||||
_ = sessionHelper.RevokeToken(app.DB, selector)
|
||||
_ = sessionHelper.RevokeToken(app.DB, selector) // tampered
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
@@ -76,6 +79,9 @@ func RememberMiddleware(app *bootstrap.App) gin.HandlerFunc {
|
||||
_ = sm.RenewToken(ctx)
|
||||
sm.Put(ctx, sessionkeys.UserID, userID)
|
||||
sm.Put(ctx, sessionkeys.LastActivity, time.Now().UTC())
|
||||
// (Optional) if you can look up username/is_admin here, also set:
|
||||
// sm.Put(ctx, sessionkeys.Username, uname)
|
||||
// sm.Put(ctx, sessionkeys.IsAdmin, isAdmin)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
@@ -86,8 +92,10 @@ func RequireAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
app := c.MustGet("app").(*bootstrap.App)
|
||||
sm := app.SessionManager
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if sm.GetInt(c.Request.Context(), sessionkeys.UserID) == 0 {
|
||||
// ✅ Use Exists to be robust to int vs int64 storage
|
||||
if !sm.Exists(ctx, sessionkeys.UserID) {
|
||||
c.Redirect(http.StatusSeeOther, "/account/login")
|
||||
c.Abort()
|
||||
return
|
||||
@@ -95,3 +103,18 @@ func RequireAuth() gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// Redirects authenticated users away from public auth pages.
|
||||
func PublicOnly() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
app := c.MustGet("app").(*bootstrap.App)
|
||||
sm := app.SessionManager
|
||||
|
||||
if sm.Exists(c.Request.Context(), sessionkeys.UserID) {
|
||||
c.Redirect(http.StatusSeeOther, "/")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
26
internal/http/middleware/errorlog.go
Normal file
26
internal/http/middleware/errorlog.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// internal/http/middleware/errorlog.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"synlotto-website/internal/logging"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ErrorLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
c.Next()
|
||||
|
||||
if len(c.Errors) == 0 {
|
||||
return
|
||||
}
|
||||
for _, e := range c.Errors {
|
||||
logging.Info("❌ %s %s -> %d in %v: %v",
|
||||
c.Request.Method, c.FullPath(), c.Writer.Status(),
|
||||
time.Since(start), e.Err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,30 @@
|
||||
// Package routes
|
||||
// Path: /internal/http/routes
|
||||
// File: accountroutes.go
|
||||
//
|
||||
// Purpose
|
||||
// Defines all /account route groups including:
|
||||
//
|
||||
// - Public authentication pages (login, signup)
|
||||
// - Protected session actions (logout)
|
||||
// - Auth-protected ticket management pages
|
||||
//
|
||||
// Responsibilities (as implemented here)
|
||||
// 1) PublicOnly guard on login/signup pages
|
||||
// 2) RequireAuth guard on logout and tickets pages
|
||||
// 3) Clean REST path structure for tickets ("/account/tickets")
|
||||
//
|
||||
// Notes
|
||||
// - AuthMiddleware must come before RequireAuth
|
||||
// - Ticket routes rely on authenticated user context
|
||||
|
||||
package routes
|
||||
|
||||
import (
|
||||
accountHandlers "synlotto-website/internal/handlers/account"
|
||||
accountHandler "synlotto-website/internal/handlers/account"
|
||||
accountMsgHandlers "synlotto-website/internal/handlers/account/messages"
|
||||
accountNotificationHandler "synlotto-website/internal/handlers/account/notifications"
|
||||
accountTicketHandler "synlotto-website/internal/handlers/account/tickets"
|
||||
|
||||
"synlotto-website/internal/http/middleware"
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
@@ -10,15 +33,62 @@ import (
|
||||
func RegisterAccountRoutes(app *bootstrap.App) {
|
||||
r := app.Router
|
||||
|
||||
acc := r.Group("/account")
|
||||
acc.GET("/login", accountHandlers.LoginGet)
|
||||
acc.POST("/login", accountHandlers.LoginPost)
|
||||
acc.GET("/signup", accountHandlers.SignupGet)
|
||||
acc.POST("/signup", accountHandlers.SignupPost)
|
||||
// Instantiate handlers that have method receivers
|
||||
messageSvc := app.Services.Messages
|
||||
msgH := &accountMsgHandlers.AccountMessageHandlers{Svc: messageSvc}
|
||||
|
||||
// Protected logout
|
||||
notificationSvc := app.Services.Notifications
|
||||
notifH := &accountNotificationHandler.AccountNotificationHandlers{Svc: notificationSvc}
|
||||
|
||||
// ticketSvc := app.Services.TicketService
|
||||
// ticketH := &accountTickets.AccountTicketHandlers{Svc: ticketSvc}
|
||||
|
||||
// Public account pages
|
||||
acc := r.Group("/account")
|
||||
acc.Use(middleware.PublicOnly())
|
||||
{
|
||||
acc.GET("/login", accountHandler.LoginGet)
|
||||
acc.POST("/login", accountHandler.LoginPost)
|
||||
acc.GET("/signup", accountHandler.SignupGet)
|
||||
acc.POST("/signup", accountHandler.SignupPost)
|
||||
}
|
||||
|
||||
// Auth-required account actions
|
||||
accAuth := r.Group("/account")
|
||||
accAuth.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
|
||||
accAuth.POST("/logout", accountHandlers.Logout)
|
||||
accAuth.GET("/logout", accountHandlers.Logout) //ToDo: keep if you still support GET?
|
||||
{
|
||||
accAuth.POST("/logout", accountHandler.Logout)
|
||||
accAuth.GET("/logout", accountHandler.Logout) // optional
|
||||
}
|
||||
|
||||
// Messages (auth-required)
|
||||
messages := r.Group("/account/messages")
|
||||
messages.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
|
||||
{
|
||||
messages.GET("/", msgH.List)
|
||||
messages.GET("/read", msgH.ReadGet)
|
||||
messages.GET("/send", msgH.SendGet)
|
||||
messages.POST("/send", msgH.SendPost)
|
||||
messages.GET("/archive", msgH.ArchivedList) // view archived messages
|
||||
messages.POST("/archive", msgH.ArchivePost) // archive a message
|
||||
messages.POST("/restore", msgH.RestoreArchived)
|
||||
messages.POST("/mark-read", msgH.MarkReadPost)
|
||||
}
|
||||
|
||||
// Notifications (auth-required)
|
||||
notifications := r.Group("/account/notifications")
|
||||
notifications.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
|
||||
{
|
||||
notifications.GET("/", notifH.List)
|
||||
notifications.GET("/:id", notifH.ReadGet) // renders read.html
|
||||
}
|
||||
|
||||
// Tickets (auth-required)
|
||||
tickets := r.Group("/account/tickets")
|
||||
tickets.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
|
||||
{
|
||||
tickets.GET("/", accountTicketHandler.List) // GET /account/tickets
|
||||
tickets.GET("/add", accountTicketHandler.AddGet) // GET /account/tickets/add
|
||||
tickets.POST("/add", accountTicketHandler.AddPost) // POST /account/tickets/add
|
||||
}
|
||||
}
|
||||
|
||||
17
internal/models/message.go
Normal file
17
internal/models/message.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
ID int
|
||||
SenderId int
|
||||
RecipientId int
|
||||
Subject string
|
||||
Body string
|
||||
IsRead bool
|
||||
IsArchived bool
|
||||
CreatedAt time.Time
|
||||
ArchivedAt *time.Time
|
||||
}
|
||||
12
internal/models/notification.go
Normal file
12
internal/models/notification.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Notification struct {
|
||||
ID int
|
||||
UserId int
|
||||
Title string
|
||||
Body string
|
||||
IsRead bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -1,29 +1,50 @@
|
||||
// Package models
|
||||
// Path: internal/models/
|
||||
// File: ticket.go
|
||||
//
|
||||
// Purpose
|
||||
// Canonical persistence model for tickets as stored in DB,
|
||||
// plus display helpers populated at read time.
|
||||
//
|
||||
// Responsibilities
|
||||
// - Represents input values for ticket creation
|
||||
// - Stores normalized draw fields for comparison
|
||||
// - Optional fields (bonus, syndicate) use pointer types
|
||||
//
|
||||
// Notes
|
||||
// - Read-only display fields must not be persisted directly
|
||||
// - TODO: enforce UserID presence once per-user tickets are fully enabled
|
||||
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
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
|
||||
Id int // Persistent DB primary key
|
||||
UserId int // FK to users(id) when multi-user enabled
|
||||
SyndicateId *int // Optional FK if purchased via syndicate
|
||||
GameType string // Lottery type (e.g., Lotto)
|
||||
DrawDate time.Time // Stored as UTC datetime to avoid timezone issues
|
||||
Ball1 int
|
||||
Ball2 int
|
||||
Ball3 int
|
||||
Ball4 int
|
||||
Ball5 int
|
||||
Ball6 int // Only if game type requires
|
||||
|
||||
// Optional bonus balls
|
||||
Bonus1 *int
|
||||
Bonus2 *int
|
||||
PurchaseMethod string
|
||||
PurchaseDate string
|
||||
PurchaseDate string // TODO: convert to time.Time
|
||||
ImagePath string
|
||||
Duplicate bool
|
||||
Duplicate bool // Calculated during insert
|
||||
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.
|
||||
// Non-DB display helpers populated in read model
|
||||
Balls []int
|
||||
BonusBalls []int
|
||||
MatchedDraw DrawResult
|
||||
|
||||
@@ -13,25 +13,3 @@ type User struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -1,30 +1,51 @@
|
||||
// Package bootstrap
|
||||
// Path /internal/platform/bootstrap
|
||||
// Path: /internal/platform/bootstrap
|
||||
// File: loader.go
|
||||
//
|
||||
// Purpose:
|
||||
// Purpose
|
||||
// Centralized application initializer (the “application kernel”).
|
||||
// This constructs and wires together the core runtime graph used by the
|
||||
// entire system: configuration, database, session manager (SCS), router (Gin),
|
||||
// Constructs and wires the core runtime graph used by the system:
|
||||
// configuration, database, schema bootstrap, session manager (SCS), router (Gin),
|
||||
// CSRF wrapper (nosurf), and the HTTP server.
|
||||
//
|
||||
// Responsibilities:
|
||||
// 1) Load strongly-typed configuration.
|
||||
// 2) Initialize long-lived infrastructure (DB, sessions).
|
||||
// 3) Build the Gin router and mount global middleware and routes.
|
||||
// 4) Wrap the router with SCS (LoadAndSave) and CSRF in the correct order.
|
||||
// 5) Construct the http.Server and expose the assembled components via App.
|
||||
// Responsibilities (as implemented here)
|
||||
// 1) Load strongly-typed configuration from disk (config.Load).
|
||||
// 2) Open MySQL with pool tuning and DSN options (parseTime, utf8mb4, UTC).
|
||||
// 3) Ensure initial schema on an empty DB (databasePlatform.EnsureInitialSchema).
|
||||
// 4) Register gob types needed by sessions (map[string]string, []string, time.Time).
|
||||
// 5) Create an SCS session manager via platform/session.New.
|
||||
// 6) Build a Gin engine, attach global middleware, static mounts, and error handlers.
|
||||
// 7) Inject *App into Gin context (c.Set("app", app)) for handler access.
|
||||
// 8) Wrap Gin with SCS LoadAndSave, then wrap that with CSRF (nosurf).
|
||||
// 9) Construct http.Server with Handler and ReadHeaderTimeout.
|
||||
//
|
||||
// HTTP stack order (important):
|
||||
// HTTP stack order (matches code)
|
||||
// Gin Router → SCS LoadAndSave → CSRF Wrapper → http.Server
|
||||
//
|
||||
// Design guarantees:
|
||||
// - Single source of truth via the App struct.
|
||||
// - Stable middleware order (SCS must wrap Gin before CSRF).
|
||||
// Design guarantees
|
||||
// - Single source of truth via the App struct (Config, DB, SessionManager, Router, Handler, Server).
|
||||
// - Stable middleware order: SCS wraps Gin before CSRF.
|
||||
// - Gin handlers can access *App via c.MustGet("app").
|
||||
// - Error surfaces are unified via custom NoRoute/NoMethod/Recovery handlers.
|
||||
// - Extensible: add infra (cache/mailer/metrics) here.
|
||||
//
|
||||
// Change log:
|
||||
// Operational details observed in this file
|
||||
// - MySQL DSN uses: parseTime=true, charset=utf8mb4, loc=UTC.
|
||||
// - Pool sizing and conn lifetime are read from config if set.
|
||||
// - Schema application is idempotent and runs on startup.
|
||||
// - Static files are served at /static and /favicon.ico.
|
||||
// - Logging uses gin.Logger(). Recovery uses gin.CustomRecovery with weberr.Recovery.
|
||||
// - ReadHeaderTimeout is set to 10s (currently hard-coded).
|
||||
//
|
||||
// Notes & TODOs (code-accurate)
|
||||
// - There’s a second DB ping: openMySQL() pings, and Load() pings again.
|
||||
// This is harmless but redundant; consider deleting one for clarity.
|
||||
// - gin.Recovery() is commented out; we use CustomRecovery instead (intentional).
|
||||
// - Consider moving ReadHeaderTimeout to config to match the rest of server tuning.
|
||||
//
|
||||
// Change log
|
||||
// [2025-10-28] Document EnsureInitialSchema bootstrap, explicit gob registrations,
|
||||
// clarified middleware/handler order, and server timeout behavior.
|
||||
// [2025-10-24] Migrated to SCS-first wrapping and explicit App wiring.
|
||||
|
||||
package bootstrap
|
||||
@@ -32,12 +53,17 @@ package bootstrap
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
domainMsgs "synlotto-website/internal/domain/messages"
|
||||
domainNotifs "synlotto-website/internal/domain/notifications"
|
||||
weberr "synlotto-website/internal/http/error"
|
||||
databasePlatform "synlotto-website/internal/platform/database"
|
||||
messagesvc "synlotto-website/internal/platform/services/messages"
|
||||
notifysvc "synlotto-website/internal/platform/services/notifications"
|
||||
|
||||
"synlotto-website/internal/platform/config"
|
||||
"synlotto-website/internal/platform/csrf"
|
||||
@@ -56,36 +82,45 @@ type App struct {
|
||||
Router *gin.Engine
|
||||
Handler http.Handler
|
||||
Server *http.Server
|
||||
|
||||
Services struct {
|
||||
Messages domainMsgs.MessageService
|
||||
Notifications domainNotifs.NotificationService
|
||||
}
|
||||
}
|
||||
|
||||
func Load(configPath string) (*App, error) {
|
||||
// Load configuration
|
||||
cfg, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
// Open DB
|
||||
db, err := openMySQL(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
return nil, fmt.Errorf("mysql ping: %w", err)
|
||||
}
|
||||
|
||||
// Ensure initial schema (idempotent; safe on restarts)
|
||||
if err := databasePlatform.EnsureInitialSchema(db); err != nil {
|
||||
return nil, fmt.Errorf("ensure schema: %w", err)
|
||||
}
|
||||
|
||||
// Register gob types used in session values
|
||||
gob.Register(map[string]string{})
|
||||
gob.Register([]string{})
|
||||
gob.Register(time.Time{})
|
||||
|
||||
// Create SCS session manager
|
||||
sessions := session.New(cfg)
|
||||
|
||||
// Build Gin router and global middleware
|
||||
router := gin.New()
|
||||
router.Use(gin.Logger(), gin.Recovery())
|
||||
router.Use(gin.Logger())
|
||||
router.Static("/static", "./web/static")
|
||||
router.StaticFile("/favicon.ico", "./web/static/favicon.ico")
|
||||
|
||||
// Assemble App prior to injecting into context
|
||||
app := &App{
|
||||
Config: cfg,
|
||||
DB: db,
|
||||
@@ -93,23 +128,30 @@ func Load(configPath string) (*App, error) {
|
||||
Router: router,
|
||||
}
|
||||
|
||||
app.Services.Messages = messagesvc.New(db)
|
||||
app.Services.Notifications = notifysvc.New(db)
|
||||
|
||||
// Inject *App into Gin context for handler access
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("app", app)
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// Error handling surfaces
|
||||
router.NoRoute(weberr.NoRoute(app.SessionManager))
|
||||
router.NoMethod(weberr.NoMethod(app.SessionManager))
|
||||
router.Use(gin.CustomRecovery(weberr.Recovery(app.SessionManager)))
|
||||
|
||||
// Wrap: Gin → SCS → CSRF (nosurf)
|
||||
handler := sessions.LoadAndSave(router)
|
||||
handler = csrf.Wrap(handler, cfg)
|
||||
|
||||
// 9) Build HTTP server
|
||||
addr := fmt.Sprintf("%s:%d", cfg.HttpServer.Address, cfg.HttpServer.Port)
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 10 * time.Second, // ToDo: consider moving to config
|
||||
ReadHeaderTimeout: cfg.HttpServer.ReadHeaderTimeout,
|
||||
}
|
||||
|
||||
app.Handler = handler
|
||||
@@ -121,15 +163,17 @@ func Load(configPath string) (*App, error) {
|
||||
func openMySQL(cfg config.Config) (*sql.DB, error) {
|
||||
dbCfg := cfg.Database
|
||||
|
||||
// Credentials are used as-is; escaping handled by DSN rules for mysql driver.
|
||||
escapedUser := dbCfg.Username
|
||||
escapedPass := dbCfg.Password
|
||||
|
||||
// DSN opts: parseTime=true, utf8mb4, UTC location
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&charset=utf8mb4,utf8&loc=UTC",
|
||||
escapedUser,
|
||||
escapedPass,
|
||||
dbCfg.Server,
|
||||
dbCfg.Port,
|
||||
dbCfg.DatabaseNamed,
|
||||
dbCfg.DatabaseName,
|
||||
)
|
||||
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
@@ -137,6 +181,7 @@ func openMySQL(cfg config.Config) (*sql.DB, error) {
|
||||
return nil, fmt.Errorf("mysql open: %w", err)
|
||||
}
|
||||
|
||||
// Pool tuning from config (optional)
|
||||
if dbCfg.MaxOpenConnections > 0 {
|
||||
db.SetMaxOpenConns(dbCfg.MaxOpenConnections)
|
||||
}
|
||||
@@ -149,6 +194,7 @@ func openMySQL(cfg config.Config) (*sql.DB, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Connectivity check with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
|
||||
@@ -1,3 +1,37 @@
|
||||
// Package config
|
||||
// Path: /internal/platform/config
|
||||
// File: config.go
|
||||
//
|
||||
// Purpose
|
||||
// Provide a safe one-time initialization and global access point for
|
||||
// the application's Config object, once it has been constructed during
|
||||
// bootstrap.
|
||||
//
|
||||
// This allows other packages to retrieve configuration without needing
|
||||
// dependency injection at every call site, while still preventing
|
||||
// accidental mutation after init.
|
||||
//
|
||||
// Responsibilities (as implemented here)
|
||||
// 1) Store a single *Config instance for the lifetime of the process.
|
||||
// 2) Ensure Init() can only succeed once via sync.Once.
|
||||
// 3) Expose Get() as a global accessor.
|
||||
//
|
||||
// Design notes
|
||||
// - Config is written once at startup via Init() inside bootstrap.
|
||||
// - Calls to Init() after the first are ignored silently.
|
||||
// - Get() may return nil if called before Init() — caller must ensure
|
||||
// bootstrap has completed.
|
||||
//
|
||||
// TODOs (from current architectural direction)
|
||||
// - Evaluate replacing global access with explicit dependency injection
|
||||
// in future modules for stronger compile-time guarantees.
|
||||
// - Consider panicking or logging if Get() is called before Init().
|
||||
// - Move non-static configuration into runtime struct(s) owned by App.
|
||||
// - Ensure immutability: avoid mutating Config fields after Init().
|
||||
//
|
||||
// Change log
|
||||
// [2025-10-28] Documentation aligned with real runtime responsibilities.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,34 @@
|
||||
// Package config
|
||||
// Path: /internal/platform/config
|
||||
// File: load.go
|
||||
//
|
||||
// Purpose
|
||||
// Responsible solely for loading strongly-typed application configuration
|
||||
// from a JSON file on disk. This is the *input* stage of configuration
|
||||
// lifecycle — the resulting Config is consumed by bootstrap and may be
|
||||
// optionally stored globally via config.Init().
|
||||
//
|
||||
// Responsibilities (as implemented here)
|
||||
// 1) Read configuration JSON file from a specified path.
|
||||
// 2) Deserialize into the Config struct (strongly typed).
|
||||
// 3) Return the populated Config value or an error.
|
||||
//
|
||||
// Design notes
|
||||
// - Path is caller-controlled (bootstrap decides where config.json lives).
|
||||
// - No defaults or validation are enforced here — errors bubble to bootstrap.
|
||||
// - Pure function: no globals mutated, safe for tests and reuse.
|
||||
// - Load returns a **value**, not a pointer, avoiding accidental mutation
|
||||
// unless caller explicitly stores it.
|
||||
//
|
||||
// TODOs (from current architecture direction)
|
||||
// - Add schema validation for required config fields.
|
||||
// - Add environment override support for deployment flexibility.
|
||||
// - Consider merging with a future layered config system (file + env + flags).
|
||||
// - Emit structured errors including path details for troubleshooting.
|
||||
//
|
||||
// Change log
|
||||
// [2025-10-29] Documentation aligned with bootstrap integration and config.Init() use.
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
@@ -5,6 +36,11 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// Load reads the JSON configuration file located at `path`
|
||||
// and unmarshals it into a Config struct.
|
||||
//
|
||||
// Caller is responsible for passing the result into bootstrap and/or
|
||||
// config.Init() to make it globally available.
|
||||
func Load(path string) (Config, error) {
|
||||
var cfg Config
|
||||
|
||||
|
||||
@@ -1,40 +1,81 @@
|
||||
// Package config
|
||||
// Path: /internal/platform/config
|
||||
// File: types.go
|
||||
//
|
||||
// Purpose
|
||||
// Defines the strongly-typed configuration structure for the entire system.
|
||||
// Populated from JSON via config.Load() and stored in bootstrap for use by:
|
||||
// - MySQL connectivity + pooling
|
||||
// - HTTP server binding + security mode
|
||||
// - SCS session configuration
|
||||
// - CSRF cookie policy
|
||||
// - External licensing API configuration
|
||||
// - Template meta (site-wide branding)
|
||||
//
|
||||
// Design notes
|
||||
// - Nested struct fields map directly to JSON blocks.
|
||||
// - Types are primarily string-based for durations (parsed by bootstrap).
|
||||
// - Field names reflect actual usage in the code today.
|
||||
// - All configuration values are held immutable after bootstrap.Load.
|
||||
//
|
||||
// TODOs (observations from current design)
|
||||
// - Add `json:"database"` tag to Database struct for JSON consistency
|
||||
// (currently missing; loader still works due to exported field name fallback).
|
||||
// - Validate required fields at bootstrap (server/port/site name).
|
||||
// - Move sensitive fields (password/API key) to env-driven overrides.
|
||||
// - Convert duration strings to `time.Duration` when loading (type-safe).
|
||||
//
|
||||
// Change log
|
||||
// [2025-10-29] Documentation created to align with full settings usage.
|
||||
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
// Config represents all runtime configuration for the application.
|
||||
// Loaded from JSON and passed into bootstrap for wiring platform components.
|
||||
type Config struct {
|
||||
// CSRF cookie naming and storage controls
|
||||
CSRF struct {
|
||||
CookieName string `json:"cookieName"`
|
||||
} `json:"csrf"`
|
||||
|
||||
// Database connection settings + tuning
|
||||
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"`
|
||||
DatabaseName string `json:"databaseName"`
|
||||
MaxOpenConnections int `json:"maxOpenConnections"` // optional tuning
|
||||
MaxIdleConnections int `json:"maxIdleConnections"` // optional tuning
|
||||
ConnectionMaxLifetime string `json:"connectionMaxLifetime"` // duration as string, parsed in bootstrap
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Password string `json:"password"` // sensitive; consider environment secrets
|
||||
}
|
||||
|
||||
// HTTP server exposure and security toggles
|
||||
HttpServer struct {
|
||||
Port int `json:"port"`
|
||||
Address string `json:"address"`
|
||||
ProductionMode bool `json:"productionMode"`
|
||||
Port int `json:"port"`
|
||||
Address string `json:"address"`
|
||||
ProductionMode bool `json:"productionMode"` // controls Secure cookie flag
|
||||
ReadHeaderTimeout time.Duration `json:"readHeaderTimeout"` // config in nanoseconds
|
||||
} `json:"httpServer"`
|
||||
|
||||
// Remote licensing API service configuration
|
||||
License struct {
|
||||
APIURL string `json:"apiUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
APIKey string `json:"apiKey"` // sensitive; consider environment secrets
|
||||
} `json:"license"`
|
||||
|
||||
// Session (SCS) configuration: cookie names, lifetime + idle timeout
|
||||
Session struct {
|
||||
CookieName string `json:"cookieName"`
|
||||
Lifetime string `json:"lifetime"`
|
||||
IdleTimeout string `json:"idleTimeout"`
|
||||
Lifetime string `json:"lifetime"` // duration as string; parsed in platform/session
|
||||
IdleTimeout string `json:"idleTimeout"` // duration as string; parsed in platform/session
|
||||
RememberCookieName string `json:"rememberCookieName"`
|
||||
RememberDuration string `json:"rememberDuration"`
|
||||
RememberDuration string `json:"rememberDuration"` // duration as string; parsed in Remember middleware
|
||||
} `json:"session"`
|
||||
|
||||
// Site metadata provided to templates (branding/UI only)
|
||||
Site struct {
|
||||
SiteName string `json:"siteName"`
|
||||
CopyrightYearStart int `json:"copyrightYearStart"`
|
||||
|
||||
@@ -1,3 +1,40 @@
|
||||
// Package csrf
|
||||
// Path: /internal/platform/csrf
|
||||
// File: csrf.go
|
||||
//
|
||||
// Purpose
|
||||
//
|
||||
// Centralized CSRF protection wrapper using justinas/nosurf.
|
||||
// Applies default CSRF protections across the entire HTTP handler tree
|
||||
// after SCS session load/save wrapping.
|
||||
//
|
||||
// Responsibilities (as implemented here)
|
||||
// 1. Construct a nosurf middleware handler over the provided http.Handler.
|
||||
// 2. Configure the base CSRF cookie using values from the App configuration.
|
||||
// 3. Enforce HttpOnly and SameSite=Lax defaults.
|
||||
// 4. Enable Secure flag automatically in production mode.
|
||||
//
|
||||
// HTTP stack order (per bootstrap)
|
||||
//
|
||||
// Gin Router → SCS LoadAndSave → CSRF Wrapper → http.Server
|
||||
//
|
||||
// Design notes
|
||||
// - The nosurf package automatically:
|
||||
// - Inserts CSRF token into responses (e.g., via nosurf.Token(c.Request))
|
||||
// - Validates token on state-changing requests (POST, PUT, etc.)
|
||||
// - CSRF cookie name is configurable via config.Config.
|
||||
// - Secure flag is tied to cfg.HttpServer.ProductionMode (recommended).
|
||||
// - Global protection: all routed POSTs are covered automatically.
|
||||
//
|
||||
// TODOs (observations from current implementation)
|
||||
// - Expose helper to fetch token into Gin templates via context key.
|
||||
// - Consider SameSiteStrictMode once OAuth/external logins are defined.
|
||||
// - Add domain and MaxAge settings for more precise control.
|
||||
// - Provide per-route opt-outs if needed for webhook endpoints.
|
||||
//
|
||||
// Change log
|
||||
//
|
||||
// [2025-10-29] Documentation updated to reflect middleware position and cookie policy.
|
||||
package csrf
|
||||
|
||||
import (
|
||||
@@ -8,6 +45,11 @@ import (
|
||||
"github.com/justinas/nosurf"
|
||||
)
|
||||
|
||||
// Wrap applies nosurf CSRF middleware to the given handler,
|
||||
// configuring the CSRF cookie based on App configuration.
|
||||
//
|
||||
// Caller must ensure this is positioned *outside* SCS LoadAndSave
|
||||
// so CSRF can access session data when generating/validating tokens.
|
||||
func Wrap(h http.Handler, cfg config.Config) http.Handler {
|
||||
cs := nosurf.New(h)
|
||||
cs.SetBaseCookie(http.Cookie{
|
||||
|
||||
@@ -1,3 +1,39 @@
|
||||
// Package databasePlatform
|
||||
// Path: /internal/platform/database
|
||||
// File: schema.go
|
||||
//
|
||||
// Purpose
|
||||
// Bootstrap and verify the initial application schema for MySQL using
|
||||
// embedded SQL. Applies the full schema only when the target database
|
||||
// is detected as "empty" via a probe query.
|
||||
//
|
||||
// Responsibilities (as implemented here)
|
||||
// 1) Detect whether the schema has been initialized by probing the users table.
|
||||
// 2) If empty, apply the embedded initial schema inside a single transaction.
|
||||
// 3) Use helper ExecScript to execute multi-statement SQL safely.
|
||||
// 4) Fail fast with contextual errors on probe/apply failures.
|
||||
//
|
||||
// Idempotency strategy
|
||||
// - Uses migrationSQL.ProbeUsersTable to query a known table and count rows.
|
||||
// - If count > 0, assumes schema exists and exits without applying SQL.
|
||||
// - This makes startup safe to repeat across restarts.
|
||||
//
|
||||
// Design notes
|
||||
// - InitialSchema is embedded (no external SQL files at runtime).
|
||||
// - Application is all-or-nothing via a single transaction.
|
||||
// - Console prints currently provide debug visibility during boot.
|
||||
// - Probe focuses on the "users" table as the presence indicator.
|
||||
//
|
||||
// TODOs (observations from current implementation)
|
||||
// - Replace debug prints with structured logging (levelled).
|
||||
// - Consider probing for table existence rather than row count to avoid
|
||||
// the edge case where users table exists but has zero rows.
|
||||
// - Introduce a schema version table for forward migrations.
|
||||
// - Expand error context to include which statement failed in ExecScript.
|
||||
//
|
||||
// Change log
|
||||
// [2025-10-29] Documentation aligned with embedded migrations and probe logic.
|
||||
|
||||
package databasePlatform
|
||||
|
||||
import (
|
||||
@@ -8,9 +44,12 @@ import (
|
||||
migrationSQL "synlotto-website/internal/storage/migrations"
|
||||
)
|
||||
|
||||
// EnsureInitialSchema ensures the database contains the baseline schema.
|
||||
// If the probe indicates an existing install, the function is a no-op.
|
||||
func EnsureInitialSchema(db *sql.DB) error {
|
||||
fmt.Println("✅ EnsureInitialSchema called") // temp debug
|
||||
|
||||
// Probe: if users table exists & has rows, treat schema as present.
|
||||
var cnt int
|
||||
if err := db.QueryRow(migrationSQL.ProbeUsersTable).Scan(&cnt); err != nil {
|
||||
return fmt.Errorf("probe users table failed: %w", err)
|
||||
@@ -20,9 +59,10 @@ func EnsureInitialSchema(db *sql.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanity: show embedded SQL length so we know it actually embedded
|
||||
// Sanity: visibility for embedded SQL payload size.
|
||||
fmt.Printf("📦 Initial SQL bytes: %d\n", len(migrationSQL.InitialSchema)) // temp debug
|
||||
|
||||
// Apply full schema atomically.
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
301
internal/platform/services/messages/service.go
Normal file
301
internal/platform/services/messages/service.go
Normal file
@@ -0,0 +1,301 @@
|
||||
// Package messagesvc
|
||||
// Path: /internal/platform/services/messages
|
||||
// File: service.go
|
||||
|
||||
package messagesvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"synlotto-website/internal/logging"
|
||||
|
||||
domain "synlotto-website/internal/domain/messages"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// Service implements domain.Service.
|
||||
type Service struct {
|
||||
DB *sql.DB
|
||||
Dialect string // "postgres", "mysql", "sqlite"
|
||||
Now func() time.Time
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func New(db *sql.DB, opts ...func(*Service)) *Service {
|
||||
s := &Service{
|
||||
DB: db,
|
||||
Dialect: "mysql", // default; works with LastInsertId
|
||||
Now: time.Now,
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Ensure *Service satisfies the domain interface.
|
||||
var _ domain.MessageService = (*Service)(nil)
|
||||
|
||||
func (s *Service) ListInbox(userID int64) ([]domain.Message, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
|
||||
defer cancel()
|
||||
|
||||
q := `
|
||||
SELECT id, senderId, recipientId, subject, body, is_read, is_archived, created_at
|
||||
FROM user_messages
|
||||
WHERE recipientId = ? AND is_archived = FALSE
|
||||
ORDER BY created_at DESC`
|
||||
q = s.bind(q)
|
||||
|
||||
rows, err := s.DB.QueryContext(ctx, q, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []domain.Message
|
||||
for rows.Next() {
|
||||
var m domain.Message
|
||||
if err := rows.Scan(&m.ID, &m.SenderId, &m.RecipientId, &m.Subject, &m.Body, &m.IsRead, &m.IsArchived, &m.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) ListArchived(userID int64) ([]domain.Message, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
|
||||
defer cancel()
|
||||
|
||||
q := `
|
||||
SELECT id, senderId, recipientId, subject, body,
|
||||
is_read, is_archived, created_at, archived_at
|
||||
FROM user_messages
|
||||
WHERE recipientId = ? AND is_archived = TRUE
|
||||
ORDER BY created_at DESC`
|
||||
q = s.bind(q)
|
||||
|
||||
rows, err := s.DB.QueryContext(ctx, q, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []domain.Message
|
||||
for rows.Next() {
|
||||
var m domain.Message
|
||||
var archived sql.NullTime
|
||||
|
||||
if err := rows.Scan(
|
||||
&m.ID,
|
||||
&m.SenderId,
|
||||
&m.RecipientId,
|
||||
&m.Subject,
|
||||
&m.Body,
|
||||
&m.IsRead,
|
||||
&m.IsArchived,
|
||||
&m.CreatedAt,
|
||||
&archived,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if archived.Valid {
|
||||
t := archived.Time
|
||||
m.ArchivedAt = &t
|
||||
} else {
|
||||
m.ArchivedAt = nil
|
||||
}
|
||||
|
||||
out = append(out, m)
|
||||
}
|
||||
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) GetByID(userID, id int64) (*domain.Message, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
|
||||
defer cancel()
|
||||
|
||||
q := `
|
||||
SELECT id, senderId, recipientId, subject, body, is_read, is_archived, created_at
|
||||
FROM user_messages
|
||||
WHERE recipientId = ? AND id = ?`
|
||||
q = s.bind(q)
|
||||
|
||||
var m domain.Message
|
||||
err := s.DB.QueryRowContext(ctx, q, userID, id).
|
||||
Scan(&m.ID, &m.SenderId, &m.RecipientId, &m.Subject, &m.Body, &m.IsRead, &m.IsArchived, &m.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (s *Service) Create(senderID int64, in domain.CreateMessageInput) (int64, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
|
||||
defer cancel()
|
||||
|
||||
// ✅ make sure this matches your current table/column names
|
||||
const q = `
|
||||
INSERT INTO user_messages
|
||||
(senderId, recipientId, subject, body, is_read, is_archived, created_at)
|
||||
VALUES
|
||||
(?, ?, ?, ?, 0, 0, CURRENT_TIMESTAMP)
|
||||
`
|
||||
|
||||
// 👀 Log the SQL and arguments (truncate body in logs if you prefer)
|
||||
logging.Info("🧪 SQL Exec: %s | args: senderId=%d recipientId=%d subject=%q body_len=%d", compactSQL(q), senderID, in.RecipientID, in.Subject, len(in.Body))
|
||||
|
||||
res, err := s.DB.ExecContext(ctx, q, senderID, in.RecipientID, in.Subject, in.Body)
|
||||
if err != nil {
|
||||
// Surface MySQL code/message (very helpful for FK #1452 etc.)
|
||||
var me *mysql.MySQLError
|
||||
if errors.As(err, &me) {
|
||||
wrapped := fmt.Errorf("insert user_messages: mysql #%d %s | args(senderId=%d, recipientId=%d, subject=%q, body_len=%d)",
|
||||
me.Number, me.Message, senderID, in.RecipientID, in.Subject, len(in.Body))
|
||||
logging.Info("❌ %v", wrapped)
|
||||
return 0, wrapped
|
||||
}
|
||||
wrapped := fmt.Errorf("insert user_messages: %w | args(senderId=%d, recipientId=%d, subject=%q, body_len=%d)",
|
||||
err, senderID, in.RecipientID, in.Subject, len(in.Body))
|
||||
logging.Info("❌ %v", wrapped)
|
||||
return 0, wrapped
|
||||
}
|
||||
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
wrapped := fmt.Errorf("lastInsertId user_messages: %w", err)
|
||||
logging.Info("❌ %v", wrapped)
|
||||
return 0, wrapped
|
||||
}
|
||||
|
||||
logging.Info("✅ Inserted message id=%d", id)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func compactSQL(s string) string {
|
||||
out := make([]rune, 0, len(s))
|
||||
space := false
|
||||
for _, r := range s {
|
||||
if r == '\n' || r == '\t' || r == '\r' || r == ' ' {
|
||||
if !space {
|
||||
out = append(out, ' ')
|
||||
space = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
space = false
|
||||
out = append(out, r)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func (s *Service) bind(q string) string {
|
||||
if s.Dialect != "postgres" {
|
||||
return q
|
||||
}
|
||||
n := 0
|
||||
out := make([]byte, 0, len(q)+8)
|
||||
for i := 0; i < len(q); i++ {
|
||||
if q[i] == '?' {
|
||||
n++
|
||||
out = append(out, '$')
|
||||
out = append(out, []byte(intToStr(n))...)
|
||||
continue
|
||||
}
|
||||
out = append(out, q[i])
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func (s *Service) Archive(userID, id int64) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
|
||||
defer cancel()
|
||||
|
||||
q := `
|
||||
UPDATE user_messages
|
||||
SET is_archived = 1, archived_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND recipientId = ?
|
||||
`
|
||||
q = s.bind(q)
|
||||
|
||||
res, err := s.DB.ExecContext(ctx, q, id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func intToStr(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b [12]byte
|
||||
i := len(b)
|
||||
for n > 0 {
|
||||
i--
|
||||
b[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
return string(b[i:])
|
||||
}
|
||||
|
||||
func (s *Service) Unarchive(userID, id int64) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
|
||||
defer cancel()
|
||||
|
||||
q := `
|
||||
UPDATE user_messages
|
||||
SET is_archived = 0, archived_at = NULL
|
||||
WHERE id = ? AND recipientId = ?
|
||||
`
|
||||
q = s.bind(q)
|
||||
|
||||
res, err := s.DB.ExecContext(ctx, q, id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) MarkRead(userID, id int64) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
|
||||
defer cancel()
|
||||
|
||||
q := `
|
||||
UPDATE user_messages
|
||||
SET is_read = 1
|
||||
WHERE id = ? AND recipientId = ?
|
||||
`
|
||||
q = s.bind(q)
|
||||
|
||||
res, err := s.DB.ExecContext(ctx, q, id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
85
internal/platform/services/notifications/service.go
Normal file
85
internal/platform/services/notifications/service.go
Normal file
@@ -0,0 +1,85 @@
|
||||
// Package notifysvc
|
||||
// Path: /internal/platform/services/notifications
|
||||
// File: service.go
|
||||
// ToDo: carve out sql
|
||||
|
||||
package notifysvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
domain "synlotto-website/internal/domain/notifications"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
DB *sql.DB
|
||||
Now func() time.Time
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func New(db *sql.DB, opts ...func(*Service)) *Service {
|
||||
s := &Service{
|
||||
DB: db,
|
||||
Now: time.Now,
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func WithTimeout(d time.Duration) func(*Service) { return func(s *Service) { s.Timeout = d } }
|
||||
|
||||
// List returns newest-first notifications for a user.
|
||||
// ToDo:table is users_notification, where as messages is plural, this table seems oto use user_id reather than userId need to unify. Do i want to prefix with users/user
|
||||
func (s *Service) List(userID int64) ([]domain.Notification, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
|
||||
defer cancel()
|
||||
|
||||
const q = `
|
||||
SELECT id, title, body, is_read, created_at
|
||||
FROM users_notification
|
||||
WHERE user_Id = ?
|
||||
ORDER BY created_at DESC`
|
||||
|
||||
rows, err := s.DB.QueryContext(ctx, q, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []domain.Notification
|
||||
for rows.Next() {
|
||||
var n domain.Notification
|
||||
if err := rows.Scan(&n.ID, &n.Title, &n.Body, &n.IsRead, &n.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) GetByID(userID, id int64) (*domain.Notification, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
|
||||
defer cancel()
|
||||
|
||||
const q = `
|
||||
SELECT id, title, body, is_read, created_at
|
||||
FROM notifications
|
||||
WHERE userId = ? AND id = ?`
|
||||
|
||||
var n domain.Notification
|
||||
err := s.DB.QueryRowContext(ctx, q, userID, id).
|
||||
Scan(&n.ID, &n.Title, &n.Body, &n.IsRead, &n.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
@@ -1,7 +1,38 @@
|
||||
// Package session
|
||||
// Path: /internal/platform/session
|
||||
// File: session.go
|
||||
//
|
||||
// Purpose
|
||||
// Initialize and configure the SCS (Server-Side Sessions) session manager
|
||||
// based on application configuration. Controls session lifetime, idle timeout,
|
||||
// cookie policy, and security posture.
|
||||
//
|
||||
// Responsibilities (as implemented here)
|
||||
// 1) Create SCS session manager used globally via bootstrap.
|
||||
// 2) Parse session lifetime + idle timeout from configuration.
|
||||
// 3) Apply secure cookie settings (HttpOnly, SameSite, Secure if production).
|
||||
// 4) Provide sensible defaults if configuration is invalid.
|
||||
//
|
||||
// Design notes
|
||||
// - SCS stores session data server-side (DB, file, mem, etc. — backend not set here).
|
||||
// - Cookie lifespan is enforced server-side (not just client expiry).
|
||||
// - Secure flag toggled via cfg.HttpServer.ProductionMode.
|
||||
// - Defaults keep application functional even if config is incomplete.
|
||||
//
|
||||
// TODOs (observations from current implementation)
|
||||
// - Add structured validation + error logging for invalid duration strings.
|
||||
// - Move secure cookie flag to config for more granular environment control.
|
||||
// - Consider enabling:
|
||||
// • Cookie.Persist (for "keep me logged in" flows)
|
||||
// • Cookie.SameSite = StrictMode by default
|
||||
// - Potentially expose SCS store configuration here (DB-backed sessions).
|
||||
//
|
||||
// Change log
|
||||
// [2025-10-29] Documentation aligned with final session architecture.
|
||||
|
||||
package session
|
||||
|
||||
import (
|
||||
"encoding/gob"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -10,8 +41,9 @@ import (
|
||||
"github.com/alexedwards/scs/v2"
|
||||
)
|
||||
|
||||
// New constructs a new SCS SessionManager using values from Config,
|
||||
// falling back to secure defaults if configuration is missing/invalid.
|
||||
func New(cfg config.Config) *scs.SessionManager {
|
||||
gob.Register(time.Time{})
|
||||
s := scs.New()
|
||||
|
||||
// Lifetime (absolute max age)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package sessionkeys
|
||||
|
||||
//ToDo: Is this just putting in "user_id" rather than the users ID?
|
||||
const (
|
||||
UserID = "user_id"
|
||||
Username = "username"
|
||||
IsAdmin = "is_admin"
|
||||
LastActivity = "last_activity"
|
||||
Flash = "flash"
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package services
|
||||
|
||||
// ToDo: these aren't really "services"
|
||||
import (
|
||||
"database/sql"
|
||||
"log"
|
||||
|
||||
@@ -32,6 +32,10 @@ func RunTicketMatching(db *sql.DB, triggeredBy string) (models.MatchRunStats, er
|
||||
var pending []models.Ticket
|
||||
for rows.Next() {
|
||||
var t models.Ticket
|
||||
var drawDateStr string
|
||||
if dt, err := helpers.ParseDrawDate(drawDateStr); err == nil {
|
||||
t.DrawDate = dt
|
||||
}
|
||||
var b1, b2, b3, b4, b5, b6, bo1, bo2 sql.NullInt64
|
||||
if err := rows.Scan(
|
||||
&t.Id, &t.GameType, &t.DrawDate,
|
||||
@@ -58,7 +62,7 @@ func RunTicketMatching(db *sql.DB, triggeredBy string) (models.MatchRunStats, er
|
||||
BonusBalls: helpers.BuildBonusSlice(t),
|
||||
}
|
||||
|
||||
draw := drawsSvc.GetDrawResultForTicket(db, t.GameType, t.DrawDate)
|
||||
draw := drawsSvc.GetDrawResultForTicket(db, t.GameType, helpers.FormatDrawDate(t.DrawDate))
|
||||
if draw.DrawID == 0 {
|
||||
// No draw yet → skip
|
||||
continue
|
||||
|
||||
@@ -1,76 +1,55 @@
|
||||
package storage
|
||||
package auditlogStorage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
securityHelpers "synlotto-website/internal/helpers/security"
|
||||
|
||||
"synlotto-website/internal/logging"
|
||||
"synlotto-website/internal/platform/bootstrap"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const insertAdminAccessSQL = `
|
||||
INSERT INTO admin_access_log
|
||||
(user_id, path, ip, user_agent, accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`
|
||||
const insertLoginSQL = `
|
||||
INSERT INTO audit_login
|
||||
(user_id, username, success, ip, user_agent, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
const insertRegistrationSQL = `
|
||||
INSERT INTO audit_registration
|
||||
(user_id, username, email, ip, user_agent, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
func AdminOnly() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
app := c.MustGet("app").(*bootstrap.App)
|
||||
sm := app.SessionManager
|
||||
ctx := c.Request.Context()
|
||||
// LogLoginAttempt stores a login attempt. Pass userID if known; otherwise it's NULL.
|
||||
func LogLoginAttempt(db *sql.DB, ip, userAgent, username string, success bool, userID ...int64) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Require logged in (assumes RequireAuth already ran; this is a safety net)
|
||||
v := sm.Get(ctx, "user_id")
|
||||
var uid int64
|
||||
switch t := v.(type) {
|
||||
case int64:
|
||||
uid = t
|
||||
case int:
|
||||
uid = int64(t)
|
||||
default:
|
||||
c.Redirect(http.StatusSeeOther, "/account/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check admin
|
||||
if !securityHelpers.IsAdmin(app.DB, int(uid)) {
|
||||
// Optional: log access attempt here or in a helper
|
||||
c.String(http.StatusForbidden, "Forbidden")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Optionally record access (moved here from storage)
|
||||
_, _ = app.DB.Exec(`
|
||||
INSERT INTO admin_access_log (user_id, path, ip, user_agent, accessed_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, uid, c.Request.URL.Path, c.ClientIP(), c.Request.UserAgent(), time.Now().UTC())
|
||||
|
||||
c.Next()
|
||||
var uid sql.NullInt64
|
||||
if len(userID) > 0 {
|
||||
uid.Valid = true
|
||||
uid.Int64 = userID[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Todo has to add in - db *sql.DB to make this work should this not be an import as all functions use it, more importantly no functions in storage just sql?
|
||||
// Handler Call - auditlogStorage.LogLoginAttempt(db, r.RemoteAddr, r.UserAgent(), username, ok)
|
||||
func LogLoginAttempt(db *sql.DB, rIP, rUA, username string, success bool) {
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO audit_login (username, success, ip, user_agent, timestamp)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
username, success, rIP, rUA, time.Now().UTC(),
|
||||
_, err := db.ExecContext(ctx, insertLoginSQL,
|
||||
uid,
|
||||
username,
|
||||
success,
|
||||
ip,
|
||||
userAgent,
|
||||
time.Now().UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
logging.Info("❌ Failed to log login:", err)
|
||||
logging.Info("❌ Failed to log login: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// LogSignup stores a registration event.
|
||||
func LogSignup(db *sql.DB, userID int64, username, email, ip, userAgent string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
@@ -82,3 +61,16 @@ func LogSignup(db *sql.DB, userID int64, username, email, ip, userAgent string)
|
||||
logging.Info("❌ Failed to log registration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// LogAdminAccess stores an admin access record.
|
||||
func LogAdminAccess(db *sql.DB, userID int64, path, ip, userAgent string, at time.Time) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := db.ExecContext(ctx, insertAdminAccessSQL,
|
||||
userID, path, ip, userAgent, at,
|
||||
)
|
||||
if err != nil {
|
||||
logging.Info("❌ Failed to log admin access: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
func SendMessage(db *sql.DB, senderID, recipientID int, subject, message string) error {
|
||||
func SendMessage(db *sql.DB, senderID, recipientID int, subject, body string) error {
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO users_messages (senderId, recipientId, subject, message)
|
||||
INSERT INTO user_messages (senderId, recipientId, subject, body)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, senderID, recipientID, subject, message)
|
||||
`, senderID, recipientID, subject, body)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
func GetMessageCount(db *sql.DB, userID int) (int, error) {
|
||||
var count int
|
||||
err := db.QueryRow(`
|
||||
SELECT COUNT(*) FROM users_messages
|
||||
SELECT COUNT(*) FROM user_messages
|
||||
WHERE recipientId = ? AND is_read = FALSE AND is_archived = FALSE
|
||||
`, userID).Scan(&count)
|
||||
return count, err
|
||||
@@ -17,8 +17,8 @@ func GetMessageCount(db *sql.DB, userID int) (int, error) {
|
||||
|
||||
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
|
||||
SELECT id, senderId, recipientId, subject, body, is_read, created_at
|
||||
FROM user_messages
|
||||
WHERE recipientId = ? AND is_archived = FALSE
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
@@ -36,7 +36,7 @@ func GetRecentMessages(db *sql.DB, userID int, limit int) []models.Message {
|
||||
&m.SenderId,
|
||||
&m.RecipientId,
|
||||
&m.Subject,
|
||||
&m.Message,
|
||||
&m.Body,
|
||||
&m.IsRead,
|
||||
&m.CreatedAt,
|
||||
)
|
||||
@@ -49,13 +49,13 @@ func GetRecentMessages(db *sql.DB, userID int, limit int) []models.Message {
|
||||
|
||||
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
|
||||
SELECT id, senderId, recipientId, subject, body, is_read, created_at
|
||||
FROM user_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)
|
||||
err := row.Scan(&m.ID, &m.SenderId, &m.RecipientId, &m.Subject, &m.Body, &m.IsRead, &m.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -65,8 +65,8 @@ func GetMessageByID(db *sql.DB, userID, messageID int) (*models.Message, error)
|
||||
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
|
||||
SELECT id, senderId, recipientId, subject, body, is_read, created_at, archived_at
|
||||
FROM user_messages
|
||||
WHERE recipientId = ? AND is_archived = TRUE
|
||||
ORDER BY archived_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
@@ -81,7 +81,7 @@ func GetArchivedMessages(db *sql.DB, userID int, page, perPage int) []models.Mes
|
||||
var m models.Message
|
||||
err := rows.Scan(
|
||||
&m.ID, &m.SenderId, &m.RecipientId,
|
||||
&m.Subject, &m.Message, &m.IsRead,
|
||||
&m.Subject, &m.Body, &m.IsRead,
|
||||
&m.CreatedAt, &m.ArchivedAt,
|
||||
)
|
||||
if err == nil {
|
||||
@@ -94,8 +94,8 @@ func GetArchivedMessages(db *sql.DB, userID int, page, perPage int) []models.Mes
|
||||
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
|
||||
SELECT id, senderId, recipientId, subject, body, is_read, created_at
|
||||
FROM user_messages
|
||||
WHERE recipientId = ? AND is_archived = FALSE
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
@@ -110,7 +110,7 @@ func GetInboxMessages(db *sql.DB, userID int, page, perPage int) []models.Messag
|
||||
var m models.Message
|
||||
err := rows.Scan(
|
||||
&m.ID, &m.SenderId, &m.RecipientId,
|
||||
&m.Subject, &m.Message, &m.IsRead, &m.CreatedAt,
|
||||
&m.Subject, &m.Body, &m.IsRead, &m.CreatedAt,
|
||||
)
|
||||
if err == nil {
|
||||
messages = append(messages, m)
|
||||
@@ -122,7 +122,7 @@ func GetInboxMessages(db *sql.DB, userID int, page, perPage int) []models.Messag
|
||||
func GetInboxMessageCount(db *sql.DB, userID int) int {
|
||||
var count int
|
||||
err := db.QueryRow(`
|
||||
SELECT COUNT(*) FROM users_messages
|
||||
SELECT COUNT(*) FROM user_messages
|
||||
WHERE recipientId = ? AND is_archived = FALSE
|
||||
`, userID).Scan(&count)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
func ArchiveMessage(db *sql.DB, userID, messageID int) error {
|
||||
_, err := db.Exec(`
|
||||
UPDATE users_messages
|
||||
UPDATE user_messages
|
||||
SET is_archived = TRUE, archived_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND recipientId = ?
|
||||
`, messageID, userID)
|
||||
@@ -16,7 +16,7 @@ func ArchiveMessage(db *sql.DB, userID, messageID int) error {
|
||||
|
||||
func MarkMessageAsRead(db *sql.DB, messageID, userID int) error {
|
||||
result, err := db.Exec(`
|
||||
UPDATE users_messages
|
||||
UPDATE user_messages
|
||||
SET is_read = TRUE
|
||||
WHERE id = ? AND recipientId = ?
|
||||
`, messageID, userID)
|
||||
@@ -36,7 +36,7 @@ func MarkMessageAsRead(db *sql.DB, messageID, userID int) error {
|
||||
|
||||
func RestoreMessage(db *sql.DB, userID, messageID int) error {
|
||||
_, err := db.Exec(`
|
||||
UPDATE users_messages
|
||||
UPDATE user_messages
|
||||
SET is_archived = FALSE, archived_at = NULL
|
||||
WHERE id = ? AND recipientId = ?
|
||||
`, messageID, userID)
|
||||
|
||||
@@ -4,6 +4,17 @@
|
||||
-- - 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,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
is_admin TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP(),
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP()
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE audit_registration (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
@@ -18,15 +29,7 @@ CREATE TABLE audit_registration (
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 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
|
||||
-- THUNDERBALL RESULTS // ToDo: Ballset should be a TINYINT
|
||||
CREATE TABLE IF NOT EXISTS results_thunderball (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
draw_date DATE NOT NULL UNIQUE,
|
||||
@@ -137,20 +140,20 @@ CREATE TABLE IF NOT EXISTS my_tickets (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- USERS MESSAGES
|
||||
CREATE TABLE IF NOT EXISTS users_messages (
|
||||
CREATE TABLE IF NOT EXISTS user_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,
|
||||
body 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
|
||||
CONSTRAINT fk_user_messages_sender
|
||||
FOREIGN KEY (senderId) REFERENCES users(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT fk_users_messages_recipient
|
||||
CONSTRAINT fk_user_messages_recipient
|
||||
FOREIGN KEY (recipientId) REFERENCES users(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -214,14 +217,17 @@ CREATE TABLE IF NOT EXISTS audit_log (
|
||||
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
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT UNSIGNED NULL,
|
||||
username VARCHAR(191) NOT NULL,
|
||||
success TINYINT(1) NOT NULL,
|
||||
ip VARCHAR(64) NOT NULL,
|
||||
user_agent VARCHAR(255),
|
||||
timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_audit_login_user_id (user_id),
|
||||
CONSTRAINT fk_audit_login_user
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- SYNDICATES
|
||||
|
||||
@@ -16,7 +16,7 @@ func GetNotificationByID(db *sql.DB, userID, notificationID int) (*models.Notifi
|
||||
`, notificationID, userID)
|
||||
|
||||
var n models.Notification
|
||||
err := row.Scan(&n.ID, &n.UserId, &n.Subject, &n.Body, &n.IsRead)
|
||||
err := row.Scan(&n.ID, &n.UserId, &n.Title, &n.Body, &n.IsRead)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -27,7 +27,7 @@ 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)
|
||||
WHERE user_Id = ? AND is_read = FALSE`, userID).Scan(&count)
|
||||
|
||||
if err != nil {
|
||||
log.Println("⚠️ Failed to count notifications:", err)
|
||||
@@ -41,7 +41,7 @@ func GetRecentNotifications(db *sql.DB, userID int, limit int) []models.Notifica
|
||||
rows, err := db.Query(`
|
||||
SELECT id, subject, body, is_read, created_at
|
||||
FROM users_notification
|
||||
WHERE user_id = ?
|
||||
WHERE user_Id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?`, userID, limit)
|
||||
if err != nil {
|
||||
@@ -54,7 +54,7 @@ func GetRecentNotifications(db *sql.DB, userID int, limit int) []models.Notifica
|
||||
|
||||
for rows.Next() {
|
||||
var n models.Notification
|
||||
if err := rows.Scan(&n.ID, &n.Subject, &n.Body, &n.IsRead, &n.CreatedAt); err == nil {
|
||||
if err := rows.Scan(&n.ID, &n.Title, &n.Body, &n.IsRead, &n.CreatedAt); err == nil {
|
||||
notifications = append(notifications, n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"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{}
|
||||
@@ -24,14 +23,18 @@ func InsertTicket(db *sql.DB, ticket models.Ticket) error {
|
||||
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 ?;`
|
||||
// Use NULL-safe equality <=> for possible NULLs
|
||||
const dupQuery = `
|
||||
SELECT COUNT(*) FROM my_tickets
|
||||
WHERE game_type = ?
|
||||
AND draw_date = ?
|
||||
AND ball1 = ? AND ball2 = ? AND ball3 = ?
|
||||
AND ball4 = ? AND ball5 = ?
|
||||
AND bonus1 <=> ? AND bonus2 <=> ?;
|
||||
`
|
||||
|
||||
var count int
|
||||
err := db.QueryRow(query,
|
||||
if err := db.QueryRow(dupQuery,
|
||||
ticket.GameType,
|
||||
ticket.DrawDate,
|
||||
ticket.Ball1,
|
||||
@@ -41,30 +44,30 @@ func InsertTicket(db *sql.DB, ticket models.Ticket) error {
|
||||
ticket.Ball5,
|
||||
bonus1Val,
|
||||
bonus2Val,
|
||||
).Scan(&count)
|
||||
|
||||
).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
isDuplicate := count > 0
|
||||
|
||||
insert := `
|
||||
INSERT INTO my_tickets (
|
||||
game_type, draw_date,
|
||||
ball1, ball2, ball3, ball4, ball5,
|
||||
bonus1, bonus2, duplicate
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`
|
||||
const insert = `
|
||||
INSERT INTO my_tickets (
|
||||
game_type, draw_date,
|
||||
ball1, ball2, ball3, ball4, ball5,
|
||||
bonus1, bonus2, duplicate
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
`
|
||||
|
||||
_, err = db.Exec(insert,
|
||||
_, 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
|
||||
}
|
||||
|
||||
@@ -7,38 +7,45 @@
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{ .Subject }}</h5>
|
||||
<p class="card-text">{{ .Message }}</p>
|
||||
<p class="card-text">{{ .Body }}</p>
|
||||
<p class="card-text">
|
||||
<small class="text-muted">Archived: {{ .ArchivedAt.Format "02 Jan 2006 15:04" }}</small>
|
||||
<small class="text-muted">
|
||||
Archived:
|
||||
{{ with .ArchivedAt }}
|
||||
{{ .Format "02 Jan 2006 15:04" }}
|
||||
{{ else }}
|
||||
—
|
||||
{{ end }}
|
||||
</small>
|
||||
</p>
|
||||
<form method="POST" action="/account/messages/restore" class="m-0">
|
||||
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
|
||||
<input type="hidden" name="id" value="{{ .ID }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-success">Restore</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="/account/messages/restore" class="m-0">
|
||||
{{ $.CSRFField }}
|
||||
<input type="hidden" name="id" value="{{ .ID }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-success">Restore</button>
|
||||
</form>
|
||||
{{ end }}
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<nav>
|
||||
<ul class="pagination">
|
||||
{{ if gt .Page 1 }}
|
||||
<!-- Pagination Controls (keep if your funcs exist) -->
|
||||
<nav>
|
||||
<ul class="pagination">
|
||||
{{ if gt .CurrentPage 1 }}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ minus1 .Page }}">Previous</a>
|
||||
<a class="page-link" href="?page={{ sub .CurrentPage 1 }}">Previous</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
{{ if .HasMore }}
|
||||
{{ end }}
|
||||
{{ if lt .CurrentPage .TotalPages }}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ plus1 .Page }}">Next</a>
|
||||
<a class="page-link" href="?page={{ add .CurrentPage 1 }}">Next</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</nav>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</nav>
|
||||
{{ else }}
|
||||
<div class="alert alert-info text-center">No archived messages.</div>
|
||||
{{ end }}
|
||||
|
||||
<a href="/account/messages" class="btn btn-secondary mt-3">Back to Inbox</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
@@ -1,55 +1,139 @@
|
||||
{{ define "content" }}
|
||||
<!-- Todo lists messages but doesn't show which ones have been read and unread-->
|
||||
<div class="container py-5">
|
||||
<h2>Your Inbox</h2>
|
||||
|
||||
{{ if .Messages }}
|
||||
<ul class="list-group mb-4">
|
||||
{{ range .Messages }}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<a href="/account/messages/read?id={{ .ID }}" class="fw-bold text-dark">{{ .Subject }}</a>
|
||||
<br>
|
||||
<small class="text-muted">{{ .CreatedAt.Format "02 Jan 2006 15:04" }}</small>
|
||||
</div>
|
||||
<form method="POST" action="/account/messages/archive?id={{ .ID }}" class="m-0">
|
||||
{{ $.CSRFField }}
|
||||
<input type="hidden" name="id" value="{{ .ID }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Archive</button>
|
||||
</form>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
<!-- Pagination -->
|
||||
<nav>
|
||||
<ul class="pagination">
|
||||
{{ if gt .CurrentPage 1 }}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ sub .CurrentPage 1 }}">Previous</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
<ul class="list-group mb-4">
|
||||
{{ range .Messages }}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center {{ if .IsRead }}read{{ end }}" data-msg-id="{{ .ID }}">
|
||||
<div>
|
||||
<a href="/account/messages/read?id={{ .ID }}" class="fw-bold text-dark">{{ .Subject }}</a><br>
|
||||
<small class="text-muted">{{ .CreatedAt.Format "02 Jan 2006 15:04" }}</small>
|
||||
</div>
|
||||
|
||||
{{ range $i := .PageRange }}
|
||||
<li class="page-item {{ if eq $i $.CurrentPage }}active{{ end }}">
|
||||
<a class="page-link" href="?page={{ $i }}">{{ $i }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
|
||||
{{ if lt .CurrentPage .TotalPages }}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ add .CurrentPage 1 }}">Next</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</nav>
|
||||
{{/* Archive form (existing) */}}
|
||||
<form method="POST" action="/account/messages/archive" class="m-0">
|
||||
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
|
||||
<input type="hidden" name="id" value="{{ .ID }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Archive</button>
|
||||
</form>
|
||||
|
||||
{{/* Mark-read: only show when unread */}}
|
||||
{{ if not .IsRead }}
|
||||
<!-- Non-AJAX fallback form (submit will refresh) -->
|
||||
<form method="POST" action="/account/messages/mark-read" class="m-0 d-inline-block mark-read-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
|
||||
<input type="hidden" name="id" value="{{ .ID }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary mark-read-btn"
|
||||
data-msg-id="{{ .ID }}"
|
||||
data-csrf="{{ $.CSRFToken }}">Mark read</button>
|
||||
</form>
|
||||
{{ end }}
|
||||
</div>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
|
||||
<nav>
|
||||
<ul class="pagination">
|
||||
{{ if gt .CurrentPage 1 }}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ sub .CurrentPage 1 }}">Previous</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
|
||||
{{ range $i := .PageRange }}
|
||||
<li class="page-item {{ if eq $i $.CurrentPage }}active{{ end }}">
|
||||
<a class="page-link" href="?page={{ $i }}">{{ $i }}</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
|
||||
{{ if lt .CurrentPage .TotalPages }}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ add .CurrentPage 1 }}">Next</a>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</nav>
|
||||
{{ else }}
|
||||
<div class="alert alert-info">No messages found.</div>
|
||||
<div class="alert alert-info text-center">No messages found.</div>
|
||||
{{ end }}
|
||||
|
||||
<div class="mt-3">
|
||||
<a href="/account/messages/send" class="btn btn-primary">Compose Message</a>
|
||||
<a href="/account/messages/archived" class="btn btn-outline-secondary ms-2">View Archived</a>
|
||||
<a href="/account/messages/archive" class="btn btn-outline-secondary ms-2">View Archived</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* AJAX enhancement: unobtrusive — safe fallback to regular form when JS disabled */}}
|
||||
<script>
|
||||
;(function(){
|
||||
// Ensure browser supports fetch + FormData; otherwise we fallback to regular form submit.
|
||||
if (!window.fetch || !window.FormData) return;
|
||||
|
||||
// Helper to decrement topbar message count badge (assumes badge element id="message-count")
|
||||
function decrementMessageCount() {
|
||||
var el = document.getElementById('message-count');
|
||||
if (!el) return;
|
||||
var current = parseInt(el.textContent || el.innerText || '0', 10) || 0;
|
||||
var next = Math.max(0, current - 1);
|
||||
if (next <= 0) {
|
||||
// remove badge or hide it
|
||||
el.remove();
|
||||
} else {
|
||||
el.textContent = String(next);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle clicks on mark-read buttons, submit via fetch, update DOM
|
||||
document.addEventListener('click', function(e){
|
||||
var btn = e.target.closest('.mark-read-btn');
|
||||
if (!btn) return;
|
||||
|
||||
// Prevent the default form POST (non-AJAX fallback)
|
||||
e.preventDefault();
|
||||
|
||||
var msgID = btn.dataset.msgId;
|
||||
var csrf = btn.dataset.csrf;
|
||||
|
||||
if (!msgID) {
|
||||
// fallback to normal submit if something's wrong
|
||||
var frm = btn.closest('form');
|
||||
if (frm) frm.submit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Build urlencoded body like a regular form
|
||||
var body = new URLSearchParams();
|
||||
body.append('id', msgID);
|
||||
if (csrf) body.append('csrf_token', csrf);
|
||||
|
||||
fetch('/account/messages/mark-read', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: body.toString(),
|
||||
credentials: 'same-origin'
|
||||
}).then(function(resp){
|
||||
if (resp.ok) {
|
||||
// UI update: remove the mark-read button, give item a .read class, update topbar count
|
||||
var li = document.querySelector('li[data-msg-id="' + msgID + '"]');
|
||||
if (li) {
|
||||
li.classList.add('read');
|
||||
// remove any mark-read form/button inside
|
||||
var form = li.querySelector('.mark-read-form');
|
||||
if (form) form.remove();
|
||||
}
|
||||
decrementMessageCount();
|
||||
} else {
|
||||
// If server returned non-2xx, fall back to full reload to show flash
|
||||
resp.text().then(function(){ window.location.reload(); }).catch(function(){ window.location.reload(); });
|
||||
}
|
||||
}).catch(function(){ window.location.reload(); });
|
||||
}, false);
|
||||
})();
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
@@ -4,12 +4,65 @@
|
||||
<h2>{{ .Message.Subject }}</h2>
|
||||
<p class="text-muted">Received: {{ .Message.CreatedAt.Format "02 Jan 2006 15:04" }}</p>
|
||||
<hr>
|
||||
<p>{{ .Message.Message }}</p>
|
||||
<a href="/account/messages" class="btn btn-secondary mt-4">Back to Inbox</a> <a href="/account/messages/archive?id={{ .Message.ID }}" class="btn btn-outline-danger mt-3">Archive</a>
|
||||
<p>{{ .Message.Body }}</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<button id="mark-read-btn" data-id="{{ .Message.ID }}" class="btn btn-outline-success">Mark As Read</button>
|
||||
|
||||
<form method="POST" action="/account/messages/archive" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
|
||||
<input type="hidden" name="id" value="{{ .Message.ID }}">
|
||||
<button type="submit" class="btn btn-outline-danger">Archive</button>
|
||||
</form>
|
||||
|
||||
<a href="/account/messages" class="btn btn-secondary">Back to Inbox</a>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="alert alert-danger text-center">
|
||||
Message not found or access denied.
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const btn = document.getElementById("mark-read-btn");
|
||||
if (!btn) return;
|
||||
|
||||
btn.addEventListener("click", async function () {
|
||||
const id = this.dataset.id;
|
||||
const res = await fetch("/account/messages/mark-read", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
id: id,
|
||||
csrf_token: "{{ $.CSRFToken }}"
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
this.classList.remove("btn-outline-success");
|
||||
this.classList.add("btn-success");
|
||||
this.textContent = "Marked As Read ✔";
|
||||
|
||||
const badge = document.getElementById("message-count");
|
||||
if (badge) {
|
||||
let count = parseInt(badge.textContent);
|
||||
if (!isNaN(count)) {
|
||||
count = Math.max(count - 1, 0);
|
||||
if (count === 0) {
|
||||
badge.remove();
|
||||
} else {
|
||||
badge.textContent = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
alert("Failed to mark as read.");
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
{{ define "content" }}
|
||||
<div class="container py-5">
|
||||
<h2>Send a Message</h2>
|
||||
|
||||
{{ if .Flash }}
|
||||
<div class="alert alert-info">{{ .Flash }}</div>
|
||||
{{ end }}
|
||||
{{ if .Error }}
|
||||
<div class="alert alert-danger">{{ .Error }}</div>
|
||||
{{ end }}
|
||||
|
||||
<form method="POST" action="/account/messages/send">
|
||||
{{ .CSRFField }}
|
||||
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="recipient_id" class="form-label">Recipient User ID</label>
|
||||
<input type="number" class="form-control" name="recipient_id" required>
|
||||
<label for="recipientId" class="form-label">Recipient User ID</label>
|
||||
<input type="number" class="form-control" name="recipientId" value="{{ with .Form }}{{ .RecipientID }}{{ end }}" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="subject" class="form-label">Subject</label>
|
||||
<input type="text" class="form-control" name="subject" required>
|
||||
<input type="text" class="form-control" name="subject" value="{{ with .Form }}{{ .Subject }}{{ end }}" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="message" class="form-label">Message</label>
|
||||
<textarea class="form-control" name="message" rows="5" required></textarea>
|
||||
<label for="body" class="form-label">Message</label>
|
||||
<textarea class="form-control" name="body" rows="5" required>{{ with .Form }}{{ .Body }}{{ end }}</textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Send</button>
|
||||
</form>
|
||||
|
||||
<a href="/account/messages" class="btn btn-secondary mt-3">Back to Inbox</a>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
@@ -1,43 +1,103 @@
|
||||
{{ define "content" }}
|
||||
<h2>Create your account</h2>
|
||||
{{ if .Flash }}<div class="flash">{{ .Flash }}</div>{{ end }}
|
||||
|
||||
{{ if .Flash }}
|
||||
<div class="alert alert-warning" role="alert">{{ .Flash }}</div>
|
||||
{{ end }}
|
||||
|
||||
<form method="POST" action="/account/signup" class="form">
|
||||
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
|
||||
|
||||
{{ $form := .Form }}
|
||||
{{ $errs := .Errors }}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" name="username" id="username" required class="form-control"
|
||||
value="{{ with .Form }}{{ .Username }}{{ end }}">
|
||||
{{ with .Errors }}{{ with index . "username" }}<div class="error">{{ . }}</div>{{ end }}{{ end }}
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
id="username"
|
||||
class="form-control {{ if $errs }}{{ if index $errs "username" }}is-invalid{{ end }}{{ end }}"
|
||||
required
|
||||
value="{{ if $form }}{{ index $form "username" }}{{ end }}"
|
||||
autocomplete="username"
|
||||
>
|
||||
{{ if $errs }}
|
||||
{{ with index $errs "username" }}
|
||||
<div class="invalid-feedback">{{ . }}</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" name="email" id="email" required class="form-control"
|
||||
value="{{ with .Form }}{{ .Email }}{{ end }}">
|
||||
{{ with .Errors }}{{ with index . "email" }}<div class="error">{{ . }}</div>{{ end }}{{ end }}
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
class="form-control {{ if $errs }}{{ if index $errs "email" }}is-invalid{{ end }}{{ end }}"
|
||||
required
|
||||
value="{{ if $form }}{{ index $form "email" }}{{ end }}"
|
||||
autocomplete="email"
|
||||
>
|
||||
{{ if $errs }}
|
||||
{{ with index $errs "email" }}
|
||||
<div class="invalid-feedback">{{ . }}</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" name="password" id="password" required class="form-control">
|
||||
{{ with .Errors }}{{ with index . "password" }}<div class="error">{{ . }}</div>{{ end }}{{ end }}
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
id="password"
|
||||
class="form-control {{ if $errs }}{{ if index $errs "password" }}is-invalid{{ end }}{{ end }}"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
>
|
||||
{{ if $errs }}
|
||||
{{ with index $errs "password" }}
|
||||
<div class="invalid-feedback">{{ . }}</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
<div class="form-text">Minimum 8 characters.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password_confirm">Confirm Password</label>
|
||||
<input type="password" name="password_confirm" id="password_confirm" required class="form-control">
|
||||
{{ with .Errors }}{{ with index . "password_confirm" }}<div class="error">{{ . }}</div>{{ end }}{{ end }}
|
||||
<label for="password_confirm" class="form-label">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password_confirm"
|
||||
id="password_confirm"
|
||||
class="form-control {{ if $errs }}{{ if index $errs "password_confirm" }}is-invalid{{ end }}{{ end }}"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
>
|
||||
{{ if $errs }}
|
||||
{{ with index $errs "password_confirm" }}
|
||||
<div class="invalid-feedback">{{ . }}</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" name="accept_terms" id="accept_terms" class="form-check-input"
|
||||
{{ with .Form }}{{ if .AcceptTerms }}checked{{ end }}{{ end }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="accept_terms"
|
||||
id="accept_terms"
|
||||
class="form-check-input {{ if $errs }}{{ if index $errs "accept_terms" }}is-invalid{{ end }}{{ end }}"
|
||||
{{ if $form }}{{ if eq (index $form "accept_terms") "on" }}checked{{ end }}{{ end }}
|
||||
>
|
||||
<label for="accept_terms" class="form-check-label">I accept the terms</label>
|
||||
{{ with .Errors }}{{ with index . "accept_terms" }}<div class="error">{{ . }}</div>{{ end }}{{ end }}
|
||||
{{ if $errs }}
|
||||
{{ with index $errs "accept_terms" }}
|
||||
<div class="invalid-feedback d-block">{{ . }}</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Create account</button>
|
||||
</form>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<h2>Log My Ticket</h2>
|
||||
|
||||
<form method="POST" action="/account/tickets/add_ticket" enctype="multipart/form-data" id="ticketForm">
|
||||
{{ .csrfField }}
|
||||
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
|
||||
|
||||
<div class="form-section">
|
||||
<label>Game:
|
||||
@@ -49,7 +49,7 @@
|
||||
</div>
|
||||
|
||||
<div id="ticketLinesContainer">
|
||||
<!-- JS will insert ticket lines here -->
|
||||
<!-- todo, maybe ajax so it doesnt refresh?-->
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
| <a href="/legal/privacy">Privacy Policy</a> |
|
||||
<a href="/legal/terms">Terms & Conditions</a> |
|
||||
<a href="/contact">Contact Us</a>
|
||||
<br>
|
||||
|
||||
</small>
|
||||
</footer>
|
||||
{{ end }}
|
||||
@@ -66,11 +66,6 @@
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="col px-md-4 pt-4">
|
||||
{{ if .Flash }}
|
||||
<div class="alert alert-info" role="alert">
|
||||
{{ .Flash }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ template "content" . }}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
aria-expanded="false">
|
||||
<i class="bi bi-bell fs-5 position-relative">
|
||||
{{ if gt .NotificationCount 0 }}
|
||||
<span class="position-absolute top-0 start-0 translate-middle badge rounded-pill bg-warning text-dark badge-small">
|
||||
<span id="notification-count"
|
||||
class="position-absolute top-0 start-0 translate-middle badge rounded-pill bg-warning text-dark badge-small">
|
||||
{{ if gt .NotificationCount 15 }}15+{{ else }}{{ .NotificationCount }}{{ end }}
|
||||
</span>
|
||||
{{ end }}
|
||||
@@ -41,7 +42,6 @@
|
||||
aria-labelledby="notificationDropdown">
|
||||
<li class="dropdown-header text-center fw-bold">Notifications</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
|
||||
{{ $total := len .Notifications }}
|
||||
{{ range $i, $n := .Notifications }}
|
||||
<li class="px-3 py-2">
|
||||
@@ -55,15 +55,11 @@
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{{ if lt (add $i 1) $total }}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{{ if lt (add $i 1) $total }}<li><hr class="dropdown-divider"></li>{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
|
||||
{{ if not .Notifications }}
|
||||
<li class="text-center text-muted py-2">No notifications</li>
|
||||
{{ end }}
|
||||
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li class="text-center"><a href="/account/notifications" class="dropdown-item">View all notifications</a></li>
|
||||
</ul>
|
||||
@@ -75,7 +71,8 @@
|
||||
aria-expanded="false">
|
||||
<i class="bi bi-envelope fs-5 position-relative">
|
||||
{{ if gt .MessageCount 0 }}
|
||||
<span class="position-absolute top-0 start-0 translate-middle badge rounded-pill bg-danger text-dark badge-small">
|
||||
<span id="message-count"
|
||||
class="position-absolute top-0 start-0 translate-middle badge rounded-pill bg-danger text-dark badge-small">
|
||||
{{ if gt .MessageCount 15 }}15+{{ else }}{{ .MessageCount }}{{ end }}
|
||||
</span>
|
||||
{{ end }}
|
||||
@@ -85,7 +82,6 @@
|
||||
aria-labelledby="messageDropdown">
|
||||
<li class="dropdown-header text-center fw-bold">Messages</li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
|
||||
{{ if .Messages }}
|
||||
{{ range $i, $m := .Messages }}
|
||||
<li class="px-3 py-2">
|
||||
@@ -94,7 +90,7 @@
|
||||
<i class="bi bi-person-circle me-2 fs-4 text-secondary"></i>
|
||||
<div>
|
||||
<div class="fw-semibold">{{ $m.Subject }}</div>
|
||||
<small class="text-muted">{{ truncate $m.Message 40 }}</small>
|
||||
<small class="text-muted">{{ truncate $m.Body 40 }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -103,15 +99,15 @@
|
||||
{{ else }}
|
||||
<li class="text-center text-muted py-2">No messages</li>
|
||||
{{ end }}
|
||||
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li class="text-center"><a href="/account/messages" class="dropdown-item">View all messages</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- User Greeting/Dropdown -->
|
||||
<!-- User Dropdown -->
|
||||
<div class="dropdown">
|
||||
<a class="nav-link dropdown-toggle text-dark" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<a class="nav-link dropdown-toggle text-dark" href="#" id="userDropdown" role="button"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Hello, {{ .User.Username }}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="userDropdown">
|
||||
|
||||
@@ -1,18 +1,39 @@
|
||||
{{ define "content" }}
|
||||
<div class="wrap">
|
||||
<h1>Thunderball Statistics</h1>
|
||||
<h1>Thunderball Statistics</h1>
|
||||
<p>
|
||||
Discover everything you need to supercharge your Thunderball picks! Explore which numbers have been drawn the most, which ones are the rarest,
|
||||
and even which lucky pairs and triplets keep showing up again and again. You can also check out which numbers are long overdue for a win perfect
|
||||
for anyone playing the waiting game!
|
||||
</p>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>Top 5 (since {{.Since}})</h3>
|
||||
<table>
|
||||
<thead><tr><th>Number</th><th>Frequency</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .TopSince}}
|
||||
<tr><td>{{.Number}}</td><td>{{.Frequency}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p>
|
||||
All statistics are based on every draw from <i><b>9th May 2010</b></i> right through to (since {{.LastDraw}}), covering the current ball pool of 1–39.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Feeling curious about the earlier era of Thunderball draws? Dive into the historical Thunderball statistics to uncover data from before 9th May 2010, back when the ball pool ranged from 1–34.
|
||||
</p>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>Top 5 (since {{.Since}})</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Number</th>
|
||||
<th>Frequency</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .TopSince}}
|
||||
<tr>
|
||||
<td>{{.Number}}</td>
|
||||
<td>{{.Frequency}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user