Compare commits
21 Commits
34918d770f
...
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 |
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)
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
templateHandlers "synlotto-website/internal/handlers/template"
|
||||||
|
templateHelpers "synlotto-website/internal/helpers/template"
|
||||||
ticketStorage "synlotto-website/internal/storage/tickets"
|
ticketStorage "synlotto-website/internal/storage/tickets"
|
||||||
|
|
||||||
"synlotto-website/internal/models"
|
"synlotto-website/internal/models"
|
||||||
@@ -37,10 +39,17 @@ import (
|
|||||||
const sessionKeyUserID = "UserID"
|
const sessionKeyUserID = "UserID"
|
||||||
|
|
||||||
func AddGet(c *gin.Context) {
|
func AddGet(c *gin.Context) {
|
||||||
c.HTML(http.StatusOK, "account/tickets/add_ticket.html", gin.H{
|
app := c.MustGet("app").(*bootstrap.App)
|
||||||
"title": "Add Ticket",
|
data := templateHandlers.BuildTemplateData(app, c.Writer, c.Request)
|
||||||
"csrfToken": nosurf.Token(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) {
|
func AddPost(c *gin.Context) {
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ func List(c *gin.Context) {
|
|||||||
|
|
||||||
rows, err := app.DB.QueryContext(c.Request.Context(), `
|
rows, err := app.DB.QueryContext(c.Request.Context(), `
|
||||||
SELECT id, numbers, game, price, purchased_at, created_at
|
SELECT id, numbers, game, price, purchased_at, created_at
|
||||||
FROM tickets
|
FROM my_tickets
|
||||||
WHERE user_id = ?
|
WHERE userId = ?
|
||||||
ORDER BY purchased_at DESC, id DESC
|
ORDER BY purchased_at DESC, id DESC
|
||||||
`, userID)
|
`, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -177,6 +177,6 @@ func RestoreMessageHandler(app *bootstrap.App) http.HandlerFunc {
|
|||||||
templateHelpers.SetFlash(r, "Message restored.")
|
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"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
templateHelpers "synlotto-website/internal/helpers/template"
|
|
||||||
|
|
||||||
"synlotto-website/internal/helpers"
|
"synlotto-website/internal/helpers"
|
||||||
|
templateHelpers "synlotto-website/internal/helpers/template"
|
||||||
"synlotto-website/internal/http/middleware"
|
"synlotto-website/internal/http/middleware"
|
||||||
"synlotto-website/internal/models"
|
"synlotto-website/internal/models"
|
||||||
)
|
)
|
||||||
@@ -20,7 +19,6 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||||||
limiter := middleware.GetVisitorLimiter(ip)
|
limiter := middleware.GetVisitorLimiter(ip)
|
||||||
|
|
||||||
if !limiter.Allow() {
|
if !limiter.Allow() {
|
||||||
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
|
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
|
||||||
return
|
return
|
||||||
@@ -46,7 +44,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
|
|||||||
doSearch := isValidDate(query) || isValidNumber(query)
|
doSearch := isValidDate(query) || isValidNumber(query)
|
||||||
|
|
||||||
whereClause := "WHERE 1=1"
|
whereClause := "WHERE 1=1"
|
||||||
args := []interface{}{}
|
args := []any{}
|
||||||
|
|
||||||
if doSearch {
|
if doSearch {
|
||||||
whereClause += " AND (draw_date = ? OR id = ?)"
|
whereClause += " AND (draw_date = ? OR id = ?)"
|
||||||
@@ -65,7 +63,21 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
|
|||||||
args = append(args, ballSetFilter)
|
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 {
|
if page < 1 || page > totalPages {
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
@@ -79,7 +91,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
|
|||||||
LIMIT ? OFFSET ?`
|
LIMIT ? OFFSET ?`
|
||||||
argsWithLimit := append(args, pageSize, offset)
|
argsWithLimit := append(args, pageSize, offset)
|
||||||
|
|
||||||
rows, err := db.Query(querySQL, argsWithLimit...)
|
rows, err := db.QueryContext(r.Context(), querySQL, argsWithLimit...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||||
log.Println("❌ DB error:", err)
|
log.Println("❌ DB error:", err)
|
||||||
@@ -113,7 +125,7 @@ func ResultsThunderball(db *sql.DB) http.HandlerFunc {
|
|||||||
noResultsMsg = "No results found for \"" + query + "\""
|
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{}{
|
err = tmpl.ExecuteTemplate(w, "layout", map[string]interface{}{
|
||||||
"Results": results,
|
"Results": results,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ func TemplateContext(w http.ResponseWriter, r *http.Request, data models.Templat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ToDo the funcs need breaking up getting large
|
||||||
func TemplateFuncs() template.FuncMap {
|
func TemplateFuncs() template.FuncMap {
|
||||||
return template.FuncMap{
|
return template.FuncMap{
|
||||||
"plus1": func(i int) int { return i + 1 },
|
"plus1": func(i int) int { return i + 1 },
|
||||||
|
|||||||
@@ -1,27 +1,72 @@
|
|||||||
|
// internal/helpers/pagination/pagination.go (move out of template/*)
|
||||||
package templateHelper
|
package templateHelper
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ToDo: Sql shouldnt be here.
|
// Whitelist
|
||||||
func GetTotalPages(db *sql.DB, tableName, whereClause string, args []interface{}, pageSize int) (totalPages, totalCount int) {
|
var allowedTables = map[string]struct{}{
|
||||||
query := "SELECT COUNT(*) FROM " + tableName + " " + whereClause
|
"user_messages": {},
|
||||||
row := db.QueryRow(query, args...)
|
"user_notifications": {},
|
||||||
if err := row.Scan(&totalCount); err != nil {
|
"results_thunderball": {},
|
||||||
return 1, 0
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if totalPages < 1 {
|
||||||
totalPages = 1
|
totalPages = 1
|
||||||
}
|
}
|
||||||
return totalPages, totalCount
|
return totalPages, totalCount, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func MakePageRange(current, total int) []int {
|
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++ {
|
for i := 1; i <= total; i++ {
|
||||||
pages = append(pages, i)
|
pages = append(pages, i)
|
||||||
}
|
}
|
||||||
return pages
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,14 +18,9 @@ import (
|
|||||||
// using ONLY session data (no DB) so 404/500 pages don't crash and still
|
// using ONLY session data (no DB) so 404/500 pages don't crash and still
|
||||||
// look "logged in" when a session exists.
|
// look "logged in" when a session exists.
|
||||||
func RenderStatus(c *gin.Context, sessions *scs.SessionManager, status int) {
|
func RenderStatus(c *gin.Context, sessions *scs.SessionManager, status int) {
|
||||||
// Synthesize minimal TemplateData from session only
|
r := c.Request
|
||||||
var data models.TemplateData
|
uid := int64(0)
|
||||||
|
if v := sessions.Get(r.Context(), sessionkeys.UserID); v != nil {
|
||||||
ctx := c.Request.Context()
|
|
||||||
|
|
||||||
// Read minimal user snapshot from session
|
|
||||||
var uid int64
|
|
||||||
if v := sessions.Get(ctx, sessionkeys.UserID); v != nil {
|
|
||||||
switch t := v.(type) {
|
switch t := v.(type) {
|
||||||
case int64:
|
case int64:
|
||||||
uid = t
|
uid = t
|
||||||
@@ -33,22 +28,22 @@ func RenderStatus(c *gin.Context, sessions *scs.SessionManager, status int) {
|
|||||||
uid = int64(t)
|
uid = int64(t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- build minimal template data from session
|
||||||
|
var data models.TemplateData
|
||||||
if uid > 0 {
|
if uid > 0 {
|
||||||
// username and is_admin are optional but make navbar correct
|
uname := ""
|
||||||
var uname string
|
if v := sessions.Get(r.Context(), sessionkeys.Username); v != nil {
|
||||||
if v := sessions.Get(ctx, sessionkeys.Username); v != nil {
|
|
||||||
if s, ok := v.(string); ok {
|
if s, ok := v.(string); ok {
|
||||||
uname = s
|
uname = s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var isAdmin bool
|
isAdmin := false
|
||||||
if v := sessions.Get(ctx, sessionkeys.IsAdmin); v != nil {
|
if v := sessions.Get(r.Context(), sessionkeys.IsAdmin); v != nil {
|
||||||
if b, ok := v.(bool); ok {
|
if b, ok := v.(bool); ok {
|
||||||
isAdmin = b
|
isAdmin = b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a lightweight user; avoids DB lookups in error paths
|
|
||||||
data.User = &models.User{
|
data.User = &models.User{
|
||||||
Id: uid,
|
Id: uid,
|
||||||
Username: uname,
|
Username: uname,
|
||||||
@@ -57,15 +52,11 @@ func RenderStatus(c *gin.Context, sessions *scs.SessionManager, status int) {
|
|||||||
data.IsAdmin = isAdmin
|
data.IsAdmin = isAdmin
|
||||||
}
|
}
|
||||||
|
|
||||||
// Turn into the template context map (adds site meta, funcs, etc.)
|
ctxMap := templateHelpers.TemplateContext(c.Writer, r, data)
|
||||||
ctxMap := templateHelpers.TemplateContext(c.Writer, c.Request, data)
|
if f := sessions.PopString(r.Context(), sessionkeys.Flash); f != "" {
|
||||||
|
|
||||||
// Flash (SCS)
|
|
||||||
if f := sessions.PopString(ctx, sessionkeys.Flash); f != "" {
|
|
||||||
ctxMap["Flash"] = f
|
ctxMap["Flash"] = f
|
||||||
}
|
}
|
||||||
|
|
||||||
// Template paths (layout-first)
|
|
||||||
pagePath := fmt.Sprintf("web/templates/error/%d.html", status)
|
pagePath := fmt.Sprintf("web/templates/error/%d.html", status)
|
||||||
if _, err := os.Stat(pagePath); err != nil {
|
if _, err := os.Stat(pagePath); err != nil {
|
||||||
c.String(status, http.StatusText(status))
|
c.String(status, http.StatusText(status))
|
||||||
@@ -86,11 +77,13 @@ func RenderStatus(c *gin.Context, sessions *scs.SessionManager, status int) {
|
|||||||
func NoRoute(sessions *scs.SessionManager) gin.HandlerFunc {
|
func NoRoute(sessions *scs.SessionManager) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) { RenderStatus(c, sessions, http.StatusNotFound) }
|
return func(c *gin.Context) { RenderStatus(c, sessions, http.StatusNotFound) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func NoMethod(sessions *scs.SessionManager) gin.HandlerFunc {
|
func NoMethod(sessions *scs.SessionManager) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) { RenderStatus(c, sessions, http.StatusMethodNotAllowed) }
|
return func(c *gin.Context) { RenderStatus(c, sessions, http.StatusMethodNotAllowed) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func Recovery(sessions *scs.SessionManager) gin.RecoveryFunc {
|
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)
|
RenderStatus(c, sessions, http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,8 +21,10 @@
|
|||||||
package routes
|
package routes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
accountHandlers "synlotto-website/internal/handlers/account"
|
accountHandler "synlotto-website/internal/handlers/account"
|
||||||
accountTicketHandlers "synlotto-website/internal/handlers/account/tickets"
|
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/http/middleware"
|
||||||
"synlotto-website/internal/platform/bootstrap"
|
"synlotto-website/internal/platform/bootstrap"
|
||||||
@@ -31,30 +33,62 @@ import (
|
|||||||
func RegisterAccountRoutes(app *bootstrap.App) {
|
func RegisterAccountRoutes(app *bootstrap.App) {
|
||||||
r := app.Router
|
r := app.Router
|
||||||
|
|
||||||
|
// Instantiate handlers that have method receivers
|
||||||
|
messageSvc := app.Services.Messages
|
||||||
|
msgH := &accountMsgHandlers.AccountMessageHandlers{Svc: messageSvc}
|
||||||
|
|
||||||
|
notificationSvc := app.Services.Notifications
|
||||||
|
notifH := &accountNotificationHandler.AccountNotificationHandlers{Svc: notificationSvc}
|
||||||
|
|
||||||
|
// ticketSvc := app.Services.TicketService
|
||||||
|
// ticketH := &accountTickets.AccountTicketHandlers{Svc: ticketSvc}
|
||||||
|
|
||||||
// Public account pages
|
// Public account pages
|
||||||
acc := r.Group("/account")
|
acc := r.Group("/account")
|
||||||
acc.Use(middleware.PublicOnly())
|
acc.Use(middleware.PublicOnly())
|
||||||
{
|
{
|
||||||
acc.GET("/login", accountHandlers.LoginGet)
|
acc.GET("/login", accountHandler.LoginGet)
|
||||||
acc.POST("/login", accountHandlers.LoginPost)
|
acc.POST("/login", accountHandler.LoginPost)
|
||||||
acc.GET("/signup", accountHandlers.SignupGet)
|
acc.GET("/signup", accountHandler.SignupGet)
|
||||||
acc.POST("/signup", accountHandlers.SignupPost)
|
acc.POST("/signup", accountHandler.SignupPost)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth-required account actions
|
// Auth-required account actions
|
||||||
accAuth := r.Group("/account")
|
accAuth := r.Group("/account")
|
||||||
accAuth.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
|
accAuth.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
|
||||||
{
|
{
|
||||||
accAuth.POST("/logout", accountHandlers.Logout)
|
accAuth.POST("/logout", accountHandler.Logout)
|
||||||
accAuth.GET("/logout", accountHandlers.Logout) // optional
|
accAuth.GET("/logout", accountHandler.Logout) // optional
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages (auth-required)
|
||||||
|
messages := 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 (auth-required)
|
||||||
tickets := r.Group("/account/tickets")
|
tickets := r.Group("/account/tickets")
|
||||||
tickets.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
|
tickets.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
|
||||||
{
|
{
|
||||||
tickets.GET("/", accountTicketHandlers.List) // GET /account/tickets
|
tickets.GET("/", accountTicketHandler.List) // GET /account/tickets
|
||||||
tickets.GET("/add", accountTicketHandlers.AddGet) // GET /account/tickets/add
|
tickets.GET("/add", accountTicketHandler.AddGet) // GET /account/tickets/add
|
||||||
tickets.POST("/add", accountTicketHandlers.AddPost) // POST /account/tickets/add
|
tickets.POST("/add", accountTicketHandler.AddPost) // POST /account/tickets/add
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -13,25 +13,3 @@ type User struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -58,8 +58,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
domainMsgs "synlotto-website/internal/domain/messages"
|
||||||
|
domainNotifs "synlotto-website/internal/domain/notifications"
|
||||||
weberr "synlotto-website/internal/http/error"
|
weberr "synlotto-website/internal/http/error"
|
||||||
databasePlatform "synlotto-website/internal/platform/database"
|
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/config"
|
||||||
"synlotto-website/internal/platform/csrf"
|
"synlotto-website/internal/platform/csrf"
|
||||||
@@ -78,6 +82,11 @@ type App struct {
|
|||||||
Router *gin.Engine
|
Router *gin.Engine
|
||||||
Handler http.Handler
|
Handler http.Handler
|
||||||
Server *http.Server
|
Server *http.Server
|
||||||
|
|
||||||
|
Services struct {
|
||||||
|
Messages domainMsgs.MessageService
|
||||||
|
Notifications domainNotifs.NotificationService
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load(configPath string) (*App, error) {
|
func Load(configPath string) (*App, error) {
|
||||||
@@ -119,6 +128,9 @@ func Load(configPath string) (*App, error) {
|
|||||||
Router: router,
|
Router: router,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.Services.Messages = messagesvc.New(db)
|
||||||
|
app.Services.Notifications = notifysvc.New(db)
|
||||||
|
|
||||||
// Inject *App into Gin context for handler access
|
// Inject *App into Gin context for handler access
|
||||||
router.Use(func(c *gin.Context) {
|
router.Use(func(c *gin.Context) {
|
||||||
c.Set("app", app)
|
c.Set("app", app)
|
||||||
@@ -139,7 +151,7 @@ func Load(configPath string) (*App, error) {
|
|||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
ReadHeaderTimeout: 10 * time.Second, // ToDo: consider moving to config
|
ReadHeaderTimeout: cfg.HttpServer.ReadHeaderTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
app.Handler = handler
|
app.Handler = handler
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
|
|
||||||
package config
|
package config
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
// Config represents all runtime configuration for the application.
|
// Config represents all runtime configuration for the application.
|
||||||
// Loaded from JSON and passed into bootstrap for wiring platform components.
|
// Loaded from JSON and passed into bootstrap for wiring platform components.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -55,6 +57,7 @@ type Config struct {
|
|||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
ProductionMode bool `json:"productionMode"` // controls Secure cookie flag
|
ProductionMode bool `json:"productionMode"` // controls Secure cookie flag
|
||||||
|
ReadHeaderTimeout time.Duration `json:"readHeaderTimeout"` // config in nanoseconds
|
||||||
} `json:"httpServer"`
|
} `json:"httpServer"`
|
||||||
|
|
||||||
// Remote licensing API service configuration
|
// Remote licensing API service configuration
|
||||||
|
|||||||
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,5 +1,6 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
|
// ToDo: these aren't really "services"
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"log"
|
"log"
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import (
|
|||||||
"database/sql"
|
"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(`
|
_, err := db.Exec(`
|
||||||
INSERT INTO users_messages (senderId, recipientId, subject, message)
|
INSERT INTO user_messages (senderId, recipientId, subject, body)
|
||||||
VALUES (?, ?, ?, ?)
|
VALUES (?, ?, ?, ?)
|
||||||
`, senderID, recipientID, subject, message)
|
`, senderID, recipientID, subject, body)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
func GetMessageCount(db *sql.DB, userID int) (int, error) {
|
func GetMessageCount(db *sql.DB, userID int) (int, error) {
|
||||||
var count int
|
var count int
|
||||||
err := db.QueryRow(`
|
err := db.QueryRow(`
|
||||||
SELECT COUNT(*) FROM users_messages
|
SELECT COUNT(*) FROM user_messages
|
||||||
WHERE recipientId = ? AND is_read = FALSE AND is_archived = FALSE
|
WHERE recipientId = ? AND is_read = FALSE AND is_archived = FALSE
|
||||||
`, userID).Scan(&count)
|
`, userID).Scan(&count)
|
||||||
return count, err
|
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 {
|
func GetRecentMessages(db *sql.DB, userID int, limit int) []models.Message {
|
||||||
rows, err := db.Query(`
|
rows, err := db.Query(`
|
||||||
SELECT id, senderId, recipientId, subject, message, is_read, created_at
|
SELECT id, senderId, recipientId, subject, body, is_read, created_at
|
||||||
FROM users_messages
|
FROM user_messages
|
||||||
WHERE recipientId = ? AND is_archived = FALSE
|
WHERE recipientId = ? AND is_archived = FALSE
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
@@ -36,7 +36,7 @@ func GetRecentMessages(db *sql.DB, userID int, limit int) []models.Message {
|
|||||||
&m.SenderId,
|
&m.SenderId,
|
||||||
&m.RecipientId,
|
&m.RecipientId,
|
||||||
&m.Subject,
|
&m.Subject,
|
||||||
&m.Message,
|
&m.Body,
|
||||||
&m.IsRead,
|
&m.IsRead,
|
||||||
&m.CreatedAt,
|
&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) {
|
func GetMessageByID(db *sql.DB, userID, messageID int) (*models.Message, error) {
|
||||||
row := db.QueryRow(`
|
row := db.QueryRow(`
|
||||||
SELECT id, senderId, recipientId, subject, message, is_read, created_at
|
SELECT id, senderId, recipientId, subject, body, is_read, created_at
|
||||||
FROM users_messages
|
FROM user_messages
|
||||||
WHERE id = ? AND recipientId = ?
|
WHERE id = ? AND recipientId = ?
|
||||||
`, messageID, userID)
|
`, messageID, userID)
|
||||||
|
|
||||||
var m models.Message
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
func GetArchivedMessages(db *sql.DB, userID int, page, perPage int) []models.Message {
|
||||||
offset := (page - 1) * perPage
|
offset := (page - 1) * perPage
|
||||||
rows, err := db.Query(`
|
rows, err := db.Query(`
|
||||||
SELECT id, senderId, recipientId, subject, message, is_read, created_at, archived_at
|
SELECT id, senderId, recipientId, subject, body, is_read, created_at, archived_at
|
||||||
FROM users_messages
|
FROM user_messages
|
||||||
WHERE recipientId = ? AND is_archived = TRUE
|
WHERE recipientId = ? AND is_archived = TRUE
|
||||||
ORDER BY archived_at DESC
|
ORDER BY archived_at DESC
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
@@ -81,7 +81,7 @@ func GetArchivedMessages(db *sql.DB, userID int, page, perPage int) []models.Mes
|
|||||||
var m models.Message
|
var m models.Message
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&m.ID, &m.SenderId, &m.RecipientId,
|
&m.ID, &m.SenderId, &m.RecipientId,
|
||||||
&m.Subject, &m.Message, &m.IsRead,
|
&m.Subject, &m.Body, &m.IsRead,
|
||||||
&m.CreatedAt, &m.ArchivedAt,
|
&m.CreatedAt, &m.ArchivedAt,
|
||||||
)
|
)
|
||||||
if err == nil {
|
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 {
|
func GetInboxMessages(db *sql.DB, userID int, page, perPage int) []models.Message {
|
||||||
offset := (page - 1) * perPage
|
offset := (page - 1) * perPage
|
||||||
rows, err := db.Query(`
|
rows, err := db.Query(`
|
||||||
SELECT id, senderId, recipientId, subject, message, is_read, created_at
|
SELECT id, senderId, recipientId, subject, body, is_read, created_at
|
||||||
FROM users_messages
|
FROM user_messages
|
||||||
WHERE recipientId = ? AND is_archived = FALSE
|
WHERE recipientId = ? AND is_archived = FALSE
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
@@ -110,7 +110,7 @@ func GetInboxMessages(db *sql.DB, userID int, page, perPage int) []models.Messag
|
|||||||
var m models.Message
|
var m models.Message
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&m.ID, &m.SenderId, &m.RecipientId,
|
&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 {
|
if err == nil {
|
||||||
messages = append(messages, m)
|
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 {
|
func GetInboxMessageCount(db *sql.DB, userID int) int {
|
||||||
var count int
|
var count int
|
||||||
err := db.QueryRow(`
|
err := db.QueryRow(`
|
||||||
SELECT COUNT(*) FROM users_messages
|
SELECT COUNT(*) FROM user_messages
|
||||||
WHERE recipientId = ? AND is_archived = FALSE
|
WHERE recipientId = ? AND is_archived = FALSE
|
||||||
`, userID).Scan(&count)
|
`, userID).Scan(&count)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
func ArchiveMessage(db *sql.DB, userID, messageID int) error {
|
func ArchiveMessage(db *sql.DB, userID, messageID int) error {
|
||||||
_, err := db.Exec(`
|
_, err := db.Exec(`
|
||||||
UPDATE users_messages
|
UPDATE user_messages
|
||||||
SET is_archived = TRUE, archived_at = CURRENT_TIMESTAMP
|
SET is_archived = TRUE, archived_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ? AND recipientId = ?
|
WHERE id = ? AND recipientId = ?
|
||||||
`, messageID, userID)
|
`, messageID, userID)
|
||||||
@@ -16,7 +16,7 @@ func ArchiveMessage(db *sql.DB, userID, messageID int) error {
|
|||||||
|
|
||||||
func MarkMessageAsRead(db *sql.DB, messageID, userID int) error {
|
func MarkMessageAsRead(db *sql.DB, messageID, userID int) error {
|
||||||
result, err := db.Exec(`
|
result, err := db.Exec(`
|
||||||
UPDATE users_messages
|
UPDATE user_messages
|
||||||
SET is_read = TRUE
|
SET is_read = TRUE
|
||||||
WHERE id = ? AND recipientId = ?
|
WHERE id = ? AND recipientId = ?
|
||||||
`, messageID, userID)
|
`, messageID, userID)
|
||||||
@@ -36,7 +36,7 @@ func MarkMessageAsRead(db *sql.DB, messageID, userID int) error {
|
|||||||
|
|
||||||
func RestoreMessage(db *sql.DB, userID, messageID int) error {
|
func RestoreMessage(db *sql.DB, userID, messageID int) error {
|
||||||
_, err := db.Exec(`
|
_, err := db.Exec(`
|
||||||
UPDATE users_messages
|
UPDATE user_messages
|
||||||
SET is_archived = FALSE, archived_at = NULL
|
SET is_archived = FALSE, archived_at = NULL
|
||||||
WHERE id = ? AND recipientId = ?
|
WHERE id = ? AND recipientId = ?
|
||||||
`, messageID, userID)
|
`, messageID, userID)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ CREATE TABLE audit_registration (
|
|||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
-- THUNDERBALL RESULTS
|
-- THUNDERBALL RESULTS // ToDo: Ballset should be a TINYINT
|
||||||
CREATE TABLE IF NOT EXISTS results_thunderball (
|
CREATE TABLE IF NOT EXISTS results_thunderball (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
draw_date DATE NOT NULL UNIQUE,
|
draw_date DATE NOT NULL UNIQUE,
|
||||||
@@ -140,20 +140,20 @@ CREATE TABLE IF NOT EXISTS my_tickets (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- USERS MESSAGES
|
-- 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,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
senderId BIGINT UNSIGNED NOT NULL,
|
senderId BIGINT UNSIGNED NOT NULL,
|
||||||
recipientId BIGINT UNSIGNED NOT NULL,
|
recipientId BIGINT UNSIGNED NOT NULL,
|
||||||
subject VARCHAR(255) NOT NULL,
|
subject VARCHAR(255) NOT NULL,
|
||||||
message MEDIUMTEXT,
|
body MEDIUMTEXT,
|
||||||
is_read TINYINT(1) NOT NULL DEFAULT 0,
|
is_read TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
is_archived TINYINT(1) NOT NULL DEFAULT 0,
|
is_archived TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
archived_at DATETIME NULL,
|
archived_at DATETIME NULL,
|
||||||
CONSTRAINT fk_users_messages_sender
|
CONSTRAINT fk_user_messages_sender
|
||||||
FOREIGN KEY (senderId) REFERENCES users(id)
|
FOREIGN KEY (senderId) REFERENCES users(id)
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
CONSTRAINT fk_users_messages_recipient
|
CONSTRAINT fk_user_messages_recipient
|
||||||
FOREIGN KEY (recipientId) REFERENCES users(id)
|
FOREIGN KEY (recipientId) REFERENCES users(id)
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func GetNotificationByID(db *sql.DB, userID, notificationID int) (*models.Notifi
|
|||||||
`, notificationID, userID)
|
`, notificationID, userID)
|
||||||
|
|
||||||
var n models.Notification
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ func GetNotificationCount(db *sql.DB, userID int) int {
|
|||||||
var count int
|
var count int
|
||||||
err := db.QueryRow(`
|
err := db.QueryRow(`
|
||||||
SELECT COUNT(*) FROM users_notification
|
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 {
|
if err != nil {
|
||||||
log.Println("⚠️ Failed to count notifications:", err)
|
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(`
|
rows, err := db.Query(`
|
||||||
SELECT id, subject, body, is_read, created_at
|
SELECT id, subject, body, is_read, created_at
|
||||||
FROM users_notification
|
FROM users_notification
|
||||||
WHERE user_id = ?
|
WHERE user_Id = ?
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT ?`, userID, limit)
|
LIMIT ?`, userID, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -54,7 +54,7 @@ func GetRecentNotifications(db *sql.DB, userID int, limit int) []models.Notifica
|
|||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var n models.Notification
|
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)
|
notifications = append(notifications, n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,30 +7,37 @@
|
|||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">{{ .Subject }}</h5>
|
<h5 class="card-title">{{ .Subject }}</h5>
|
||||||
<p class="card-text">{{ .Message }}</p>
|
<p class="card-text">{{ .Body }}</p>
|
||||||
<p class="card-text">
|
<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>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form method="POST" action="/account/messages/restore" class="m-0">
|
<form method="POST" action="/account/messages/restore" class="m-0">
|
||||||
{{ $.CSRFField }}
|
<input type="hidden" name="csrf_token" value="{{ $.CSRFToken }}">
|
||||||
<input type="hidden" name="id" value="{{ .ID }}">
|
<input type="hidden" name="id" value="{{ .ID }}">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-success">Restore</button>
|
<button type="submit" class="btn btn-sm btn-outline-success">Restore</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
<!-- Pagination Controls -->
|
<!-- Pagination Controls (keep if your funcs exist) -->
|
||||||
<nav>
|
<nav>
|
||||||
<ul class="pagination">
|
<ul class="pagination">
|
||||||
{{ if gt .Page 1 }}
|
{{ if gt .CurrentPage 1 }}
|
||||||
<li class="page-item">
|
<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>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ if .HasMore }}
|
{{ if lt .CurrentPage .TotalPages }}
|
||||||
<li class="page-item">
|
<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>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,26 +1,42 @@
|
|||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
<!-- Todo lists messages but doesn't show which ones have been read and unread-->
|
|
||||||
<div class="container py-5">
|
<div class="container py-5">
|
||||||
<h2>Your Inbox</h2>
|
<h2>Your Inbox</h2>
|
||||||
|
|
||||||
{{ if .Messages }}
|
{{ if .Messages }}
|
||||||
<ul class="list-group mb-4">
|
<ul class="list-group mb-4">
|
||||||
{{ range .Messages }}
|
{{ range .Messages }}
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
<li class="list-group-item d-flex justify-content-between align-items-center {{ if .IsRead }}read{{ end }}" data-msg-id="{{ .ID }}">
|
||||||
<div>
|
<div>
|
||||||
<a href="/account/messages/read?id={{ .ID }}" class="fw-bold text-dark">{{ .Subject }}</a>
|
<a href="/account/messages/read?id={{ .ID }}" class="fw-bold text-dark">{{ .Subject }}</a><br>
|
||||||
<br>
|
|
||||||
<small class="text-muted">{{ .CreatedAt.Format "02 Jan 2006 15:04" }}</small>
|
<small class="text-muted">{{ .CreatedAt.Format "02 Jan 2006 15:04" }}</small>
|
||||||
</div>
|
</div>
|
||||||
<form method="POST" action="/account/messages/archive?id={{ .ID }}" class="m-0">
|
|
||||||
{{ $.CSRFField }}
|
<div class="d-flex gap-2 align-items-center">
|
||||||
|
|
||||||
|
{{/* 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 }}">
|
<input type="hidden" name="id" value="{{ .ID }}">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Archive</button>
|
<button type="submit" class="btn btn-sm btn-outline-secondary">Archive</button>
|
||||||
</form>
|
</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>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
<!-- Pagination -->
|
|
||||||
<nav>
|
<nav>
|
||||||
<ul class="pagination">
|
<ul class="pagination">
|
||||||
{{ if gt .CurrentPage 1 }}
|
{{ if gt .CurrentPage 1 }}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
@@ -40,16 +56,84 @@
|
|||||||
</li>
|
</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<div class="alert alert-info">No messages found.</div>
|
<div class="alert alert-info text-center">No messages found.</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<a href="/account/messages/send" class="btn btn-primary">Compose Message</a>
|
<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>
|
||||||
</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 }}
|
{{ end }}
|
||||||
|
|||||||
@@ -4,12 +4,65 @@
|
|||||||
<h2>{{ .Message.Subject }}</h2>
|
<h2>{{ .Message.Subject }}</h2>
|
||||||
<p class="text-muted">Received: {{ .Message.CreatedAt.Format "02 Jan 2006 15:04" }}</p>
|
<p class="text-muted">Received: {{ .Message.CreatedAt.Format "02 Jan 2006 15:04" }}</p>
|
||||||
<hr>
|
<hr>
|
||||||
<p>{{ .Message.Message }}</p>
|
<p>{{ .Message.Body }}</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>
|
|
||||||
|
<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 }}
|
{{ else }}
|
||||||
<div class="alert alert-danger text-center">
|
<div class="alert alert-danger text-center">
|
||||||
Message not found or access denied.
|
Message not found or access denied.
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</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 }}
|
{{ end }}
|
||||||
|
|||||||
@@ -1,24 +1,32 @@
|
|||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
<div class="container py-5">
|
<div class="container py-5">
|
||||||
<h2>Send a Message</h2>
|
<h2>Send a Message</h2>
|
||||||
|
|
||||||
{{ if .Flash }}
|
{{ if .Flash }}
|
||||||
<div class="alert alert-info">{{ .Flash }}</div>
|
<div class="alert alert-info">{{ .Flash }}</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
{{ if .Error }}
|
||||||
|
<div class="alert alert-danger">{{ .Error }}</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
<form method="POST" action="/account/messages/send">
|
<form method="POST" action="/account/messages/send">
|
||||||
{{ .CSRFField }}
|
<input type="hidden" name="csrf_token" value="{{ .CSRFToken }}">
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="recipient_id" class="form-label">Recipient User ID</label>
|
<label for="recipientId" class="form-label">Recipient User ID</label>
|
||||||
<input type="number" class="form-control" name="recipient_id" required>
|
<input type="number" class="form-control" name="recipientId" value="{{ with .Form }}{{ .RecipientID }}{{ end }}" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="subject" class="form-label">Subject</label>
|
<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>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="message" class="form-label">Message</label>
|
<label for="body" class="form-label">Message</label>
|
||||||
<textarea class="form-control" name="message" rows="5" required></textarea>
|
<textarea class="form-control" name="body" rows="5" required>{{ with .Form }}{{ .Body }}{{ end }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Send</button>
|
<button type="submit" class="btn btn-primary">Send</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<h2>Log My Ticket</h2>
|
<h2>Log My Ticket</h2>
|
||||||
|
|
||||||
<form method="POST" action="/account/tickets/add_ticket" enctype="multipart/form-data" id="ticketForm">
|
<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">
|
<div class="form-section">
|
||||||
<label>Game:
|
<label>Game:
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="ticketLinesContainer">
|
<div id="ticketLinesContainer">
|
||||||
<!-- JS will insert ticket lines here -->
|
<!-- todo, maybe ajax so it doesnt refresh?-->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-section">
|
<div class="form-section">
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
| <a href="/legal/privacy">Privacy Policy</a> |
|
| <a href="/legal/privacy">Privacy Policy</a> |
|
||||||
<a href="/legal/terms">Terms & Conditions</a> |
|
<a href="/legal/terms">Terms & Conditions</a> |
|
||||||
<a href="/contact">Contact Us</a>
|
<a href="/contact">Contact Us</a>
|
||||||
|
<br>
|
||||||
|
|
||||||
</small>
|
</small>
|
||||||
</footer>
|
</footer>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
@@ -31,7 +31,8 @@
|
|||||||
aria-expanded="false">
|
aria-expanded="false">
|
||||||
<i class="bi bi-bell fs-5 position-relative">
|
<i class="bi bi-bell fs-5 position-relative">
|
||||||
{{ if gt .NotificationCount 0 }}
|
{{ 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 }}
|
{{ if gt .NotificationCount 15 }}15+{{ else }}{{ .NotificationCount }}{{ end }}
|
||||||
</span>
|
</span>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
@@ -41,7 +42,6 @@
|
|||||||
aria-labelledby="notificationDropdown">
|
aria-labelledby="notificationDropdown">
|
||||||
<li class="dropdown-header text-center fw-bold">Notifications</li>
|
<li class="dropdown-header text-center fw-bold">Notifications</li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
|
||||||
{{ $total := len .Notifications }}
|
{{ $total := len .Notifications }}
|
||||||
{{ range $i, $n := .Notifications }}
|
{{ range $i, $n := .Notifications }}
|
||||||
<li class="px-3 py-2">
|
<li class="px-3 py-2">
|
||||||
@@ -55,15 +55,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{{ if lt (add $i 1) $total }}
|
{{ if lt (add $i 1) $total }}<li><hr class="dropdown-divider"></li>{{ end }}
|
||||||
<li><hr class="dropdown-divider"></li>
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
{{ if not .Notifications }}
|
{{ if not .Notifications }}
|
||||||
<li class="text-center text-muted py-2">No notifications</li>
|
<li class="text-center text-muted py-2">No notifications</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li class="text-center"><a href="/account/notifications" class="dropdown-item">View all notifications</a></li>
|
<li class="text-center"><a href="/account/notifications" class="dropdown-item">View all notifications</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -75,7 +71,8 @@
|
|||||||
aria-expanded="false">
|
aria-expanded="false">
|
||||||
<i class="bi bi-envelope fs-5 position-relative">
|
<i class="bi bi-envelope fs-5 position-relative">
|
||||||
{{ if gt .MessageCount 0 }}
|
{{ 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 }}
|
{{ if gt .MessageCount 15 }}15+{{ else }}{{ .MessageCount }}{{ end }}
|
||||||
</span>
|
</span>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
@@ -85,7 +82,6 @@
|
|||||||
aria-labelledby="messageDropdown">
|
aria-labelledby="messageDropdown">
|
||||||
<li class="dropdown-header text-center fw-bold">Messages</li>
|
<li class="dropdown-header text-center fw-bold">Messages</li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
|
||||||
{{ if .Messages }}
|
{{ if .Messages }}
|
||||||
{{ range $i, $m := .Messages }}
|
{{ range $i, $m := .Messages }}
|
||||||
<li class="px-3 py-2">
|
<li class="px-3 py-2">
|
||||||
@@ -94,7 +90,7 @@
|
|||||||
<i class="bi bi-person-circle me-2 fs-4 text-secondary"></i>
|
<i class="bi bi-person-circle me-2 fs-4 text-secondary"></i>
|
||||||
<div>
|
<div>
|
||||||
<div class="fw-semibold">{{ $m.Subject }}</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>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -103,15 +99,15 @@
|
|||||||
{{ else }}
|
{{ else }}
|
||||||
<li class="text-center text-muted py-2">No messages</li>
|
<li class="text-center text-muted py-2">No messages</li>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li><hr class="dropdown-divider"></li>
|
||||||
<li class="text-center"><a href="/account/messages" class="dropdown-item">View all messages</a></li>
|
<li class="text-center"><a href="/account/messages" class="dropdown-item">View all messages</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User Greeting/Dropdown -->
|
<!-- User Dropdown -->
|
||||||
<div class="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 }}
|
Hello, {{ .User.Username }}
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="userDropdown">
|
<ul class="dropdown-menu dropdown-menu-end shadow-sm" aria-labelledby="userDropdown">
|
||||||
|
|||||||
@@ -1,18 +1,39 @@
|
|||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1>Thunderball Statistics</h1>
|
<h1>Thunderball Statistics</h1>
|
||||||
|
<p>
|
||||||
|
Discover everything you need to supercharge your Thunderball picks! Explore which numbers have been drawn the most, which ones are the rarest,
|
||||||
|
and even which lucky pairs and triplets keep showing up again and again. You can also check out which numbers are long overdue for a win perfect
|
||||||
|
for anyone playing the waiting game!
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="grid">
|
<p>
|
||||||
<div class="card">
|
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.
|
||||||
<h3>Top 5 (since {{.Since}})</h3>
|
</p>
|
||||||
<table>
|
|
||||||
<thead><tr><th>Number</th><th>Frequency</th></tr></thead>
|
<p>
|
||||||
<tbody>
|
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.
|
||||||
{{range .TopSince}}
|
</p>
|
||||||
<tr><td>{{.Number}}</td><td>{{.Frequency}}</td></tr>
|
<div class="grid">
|
||||||
{{end}}
|
<div class="card">
|
||||||
</tbody>
|
<h3>Top 5 (since {{.Since}})</h3>
|
||||||
</table>
|
<table>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
Reference in New Issue
Block a user