Continued work on messages and notifications.
This commit is contained in:
20
internal/domain/messages/domain.go
Normal file
20
internal/domain/messages/domain.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package domainMessages
|
||||
|
||||
import (
|
||||
"synlotto-website/internal/models"
|
||||
)
|
||||
|
||||
type Message = models.Message
|
||||
|
||||
type CreateMessageInput struct {
|
||||
RecipientID int64 `form:"to" binding:"required,username"`
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -7,20 +7,46 @@ package accountMessageHandler
|
||||
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"
|
||||
)
|
||||
|
||||
// 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)
|
||||
msgs, err := h.Svc.ListArchived(userID)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to load archived messages"})
|
||||
logging.Info("❌ list archived error: %v", err)
|
||||
c.String(http.StatusInternalServerError, "Failed to load archived messages")
|
||||
return
|
||||
}
|
||||
c.HTML(http.StatusOK, "account/messages/archived.html", gin.H{
|
||||
"title": "Archived Messages",
|
||||
"messages": msgs,
|
||||
})
|
||||
|
||||
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"] = "Archived Messages"
|
||||
ctx["Messages"] = msgs
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles(
|
||||
"layout.html",
|
||||
"web/templates/account/messages/archived.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 archived messages")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,40 +7,88 @@ package accountMessageHandler
|
||||
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"
|
||||
)
|
||||
|
||||
// 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)
|
||||
|
||||
// Pull messages (via service)
|
||||
msgs, err := h.Svc.ListInbox(userID)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to load messages"})
|
||||
logging.Info("❌ list inbox error: %v", err)
|
||||
c.String(http.StatusInternalServerError, "Failed to load messages")
|
||||
return
|
||||
}
|
||||
c.HTML(http.StatusOK, "account/messages/index.html", gin.H{
|
||||
"title": "Messages",
|
||||
"messages": msgs,
|
||||
})
|
||||
|
||||
// Build template context just like LoginGet
|
||||
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"] = "Messages"
|
||||
ctx["Messages"] = msgs
|
||||
|
||||
// Use the same loader + layout pattern
|
||||
tmpl := templateHelpers.LoadTemplateFiles("layout.html", "web/templates/account/messages/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 messages page")
|
||||
}
|
||||
}
|
||||
|
||||
// GET /account/messages/:id
|
||||
// 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)
|
||||
id, err := parseIDParam(c, "id")
|
||||
if err != nil {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := h.Svc.GetByID(userID, id)
|
||||
if err != nil || msg == nil {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.HTML(http.StatusOK, "account/messages/read.html", gin.H{
|
||||
"title": msg.Subject,
|
||||
"message": msg,
|
||||
})
|
||||
|
||||
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"] = msg.Subject
|
||||
ctx["Message"] = msg
|
||||
|
||||
tmpl := templateHelpers.LoadTemplateFiles(
|
||||
"layout.html",
|
||||
"web/templates/account/messages/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 message")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,38 +7,101 @@ package accountMessageHandler
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
domain "synlotto-website/internal/domain/messages"
|
||||
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/add
|
||||
// Renders: web/templates/account/messages/send.html
|
||||
func (h *AccountMessageHandlers) AddGet(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "account/messages/send.html", gin.H{
|
||||
"title": "Send Message",
|
||||
})
|
||||
app := c.MustGet("app").(*bootstrap.App)
|
||||
sm := app.SessionManager
|
||||
|
||||
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"
|
||||
|
||||
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/add
|
||||
func (h *AccountMessageHandlers) AddPost(c *gin.Context) {
|
||||
app := c.MustGet("app").(*bootstrap.App)
|
||||
sm := app.SessionManager
|
||||
|
||||
userID := mustUserID(c)
|
||||
var in CreateMessageInput
|
||||
|
||||
var in domain.CreateMessageInput
|
||||
if err := c.ShouldBind(&in); err != nil {
|
||||
// Re-render form with validation errors
|
||||
c.HTML(http.StatusBadRequest, "account/messages/send.html", gin.H{
|
||||
"title": "Send Message",
|
||||
"error": "Please correct the errors below.",
|
||||
"form": in,
|
||||
})
|
||||
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"] = "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 {
|
||||
c.HTML(http.StatusInternalServerError, "account/messages/send.html", gin.H{
|
||||
"title": "Send Message",
|
||||
"error": "Could not send message.",
|
||||
"form": in,
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
// Optional: set a flash message for success (since you already PopString elsewhere)
|
||||
// If you're using scs/v2, Put is available:
|
||||
sm.Put(c.Request.Context(), "flash", "Message sent!")
|
||||
|
||||
// Redirect back to inbox
|
||||
c.Redirect(http.StatusSeeOther, "/account/messages")
|
||||
}
|
||||
|
||||
@@ -4,33 +4,8 @@
|
||||
|
||||
package accountMessageHandler
|
||||
|
||||
import "time"
|
||||
import domain "synlotto-website/internal/domain/messages"
|
||||
|
||||
type AccountMessageHandlers struct {
|
||||
Svc MessageService
|
||||
}
|
||||
|
||||
type CreateMessageInput struct {
|
||||
To string `form:"to" binding:"required,email"`
|
||||
Subject string `form:"subject" binding:"required,max=200"`
|
||||
Body string `form:"body" binding:"required"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
ID int64
|
||||
From string
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
IsRead bool
|
||||
IsArchived bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ToDo: Should interfaces be else where?
|
||||
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)
|
||||
Svc domain.MessageService
|
||||
}
|
||||
|
||||
@@ -3,66 +3,32 @@ 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"
|
||||
)
|
||||
|
||||
func NewAccountNotificationHandlers(svc NotificationService) *AccountNotificationHandlers {
|
||||
return &AccountNotificationHandlers{Svc: svc}
|
||||
}
|
||||
|
||||
// GET /account/notifications
|
||||
// Renders: web/templates/account/notifications/index.html
|
||||
func (h *AccountNotificationHandlers) List(c *gin.Context) {
|
||||
userID := mustUserID(c)
|
||||
items, err := h.Svc.List(userID)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to load notifications"})
|
||||
return
|
||||
}
|
||||
c.HTML(http.StatusOK, "account/notifications/index.html", gin.H{
|
||||
"title": "Notifications",
|
||||
"notifications": items,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Optional since you have read.html ---
|
||||
|
||||
// GET /account/notifications/:id
|
||||
// Renders: web/templates/account/notifications/read.html
|
||||
func (h *AccountNotificationHandlers) ReadGet(c *gin.Context) {
|
||||
userID := mustUserID(c)
|
||||
id, err := parseIDParam(c, "id")
|
||||
if err != nil {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
nt, err := h.Svc.GetByID(userID, id)
|
||||
if err != nil || nt == nil {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.HTML(http.StatusOK, "account/notifications/read.html", gin.H{
|
||||
"title": nt.Title,
|
||||
"notification": nt,
|
||||
})
|
||||
}
|
||||
|
||||
// --- helpers (same as in messages; consider sharing in a pkg) ---
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func parseIDParam(c *gin.Context, name string) (int64, error) {
|
||||
return atoi64(c.Param(name))
|
||||
}
|
||||
|
||||
// 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' {
|
||||
@@ -76,3 +42,34 @@ func atoi64(s string) (int64, error) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,7 @@
|
||||
package accountNotificationHandler
|
||||
|
||||
import "time"
|
||||
|
||||
type Notification struct {
|
||||
ID int64
|
||||
Title string
|
||||
Body string
|
||||
IsRead bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
import domain "synlotto-website/internal/domain/notifications"
|
||||
|
||||
type AccountNotificationHandlers struct {
|
||||
Svc NotificationService
|
||||
}
|
||||
|
||||
// ToDo: Should interfaces be else where?
|
||||
type NotificationService interface {
|
||||
List(userID int64) ([]Notification, error)
|
||||
GetByID(userID, id int64) (*Notification, error)
|
||||
Svc domain.NotificationService
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ package routes
|
||||
|
||||
import (
|
||||
accountHandler "synlotto-website/internal/handlers/account"
|
||||
accoutMessageHandler "synlotto-website/internal/handlers/account/messages"
|
||||
accountMsgHandlers "synlotto-website/internal/handlers/account/messages"
|
||||
accountNotificationHandler "synlotto-website/internal/handlers/account/notifications"
|
||||
accountTicketHandler "synlotto-website/internal/handlers/account/tickets"
|
||||
|
||||
@@ -33,6 +33,16 @@ import (
|
||||
func RegisterAccountRoutes(app *bootstrap.App) {
|
||||
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
|
||||
acc := r.Group("/account")
|
||||
acc.Use(middleware.PublicOnly())
|
||||
@@ -55,19 +65,19 @@ func RegisterAccountRoutes(app *bootstrap.App) {
|
||||
messages := r.Group("/account/messages")
|
||||
messages.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
|
||||
{
|
||||
messages.GET("/", accoutMessageHandler.List)
|
||||
messages.GET("/add", accoutMessageHandler.AddGet)
|
||||
messages.POST("/add", accoutMessageHandler.AddPost)
|
||||
messages.GET("/archived", accoutMessageHandler.ArchivedList) // renders archived.html
|
||||
messages.GET("/:id", accoutMessageHandler.ReadGet) // renders read.html
|
||||
messages.GET("/", msgH.List)
|
||||
messages.GET("/add", msgH.AddGet)
|
||||
messages.POST("/add", msgH.AddPost)
|
||||
messages.GET("/archived", msgH.ArchivedList) // renders archived.html
|
||||
messages.GET("/:id", msgH.ReadGet) // renders read.html
|
||||
}
|
||||
|
||||
// Notifications (auth-required)
|
||||
notifications := r.Group("/account/notifications")
|
||||
notifications.Use(middleware.AuthMiddleware(), middleware.RequireAuth())
|
||||
{
|
||||
notifications.GET("/", accountNotificationHandler.List)
|
||||
notifications.GET("/:id", accountNotificationHandler.ReadGet) // renders read.html
|
||||
notifications.GET("/", notifH.List)
|
||||
notifications.GET("/:id", notifH.ReadGet) // renders read.html
|
||||
}
|
||||
|
||||
// Tickets (auth-required)
|
||||
|
||||
@@ -18,7 +18,7 @@ type User struct {
|
||||
type Notification struct {
|
||||
ID int
|
||||
UserId int
|
||||
Subject string
|
||||
Title string
|
||||
Body string
|
||||
IsRead bool
|
||||
CreatedAt time.Time
|
||||
@@ -30,8 +30,9 @@ type Message struct {
|
||||
SenderId int
|
||||
RecipientId int
|
||||
Subject string
|
||||
Message string
|
||||
Body string
|
||||
IsRead bool
|
||||
IsArchived bool
|
||||
CreatedAt time.Time
|
||||
ArchivedAt *time.Time
|
||||
}
|
||||
|
||||
@@ -58,8 +58,12 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
domainMsgs "synlotto-website/internal/domain/messages"
|
||||
domainNotifs "synlotto-website/internal/domain/notifications"
|
||||
weberr "synlotto-website/internal/http/error"
|
||||
databasePlatform "synlotto-website/internal/platform/database"
|
||||
messagesvc "synlotto-website/internal/platform/services/messages"
|
||||
notifysvc "synlotto-website/internal/platform/services/notifications"
|
||||
|
||||
"synlotto-website/internal/platform/config"
|
||||
"synlotto-website/internal/platform/csrf"
|
||||
@@ -78,6 +82,11 @@ type App struct {
|
||||
Router *gin.Engine
|
||||
Handler http.Handler
|
||||
Server *http.Server
|
||||
|
||||
Services struct {
|
||||
Messages domainMsgs.MessageService
|
||||
Notifications domainNotifs.NotificationService
|
||||
}
|
||||
}
|
||||
|
||||
func Load(configPath string) (*App, error) {
|
||||
@@ -119,6 +128,9 @@ func Load(configPath string) (*App, error) {
|
||||
Router: router,
|
||||
}
|
||||
|
||||
app.Services.Messages = messagesvc.New(db)
|
||||
app.Services.Notifications = notifysvc.New(db)
|
||||
|
||||
// Inject *App into Gin context for handler access
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("app", app)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
// ToDo: not currently used and need to carve out sql
|
||||
// Package messagesvc
|
||||
// Path: /internal/platform/services/messages
|
||||
// File: service.go
|
||||
|
||||
package messagesvc
|
||||
|
||||
import (
|
||||
@@ -7,13 +10,13 @@ import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
accountMessageHandler "synlotto-website/internal/handlers/account/messages"
|
||||
domain "synlotto-website/internal/domain/messages"
|
||||
)
|
||||
|
||||
// Service implements accountMessageHandler.MessageService.
|
||||
// Service implements domain.Service.
|
||||
type Service struct {
|
||||
DB *sql.DB
|
||||
Dialect string // "postgres", "mysql", "sqlite" (affects INSERT id retrieval)
|
||||
Dialect string // "postgres", "mysql", "sqlite"
|
||||
Now func() time.Time
|
||||
Timeout time.Duration
|
||||
}
|
||||
@@ -21,7 +24,7 @@ type Service struct {
|
||||
func New(db *sql.DB, opts ...func(*Service)) *Service {
|
||||
s := &Service{
|
||||
DB: db,
|
||||
Dialect: "mysql", // sane default for LastInsertId (works for mysql/sqlite)
|
||||
Dialect: "mysql", // default; works with LastInsertId
|
||||
Now: time.Now,
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
@@ -31,56 +34,58 @@ func New(db *sql.DB, opts ...func(*Service)) *Service {
|
||||
return s
|
||||
}
|
||||
|
||||
// WithDialect sets SQL dialect hints: "postgres" uses RETURNING id.
|
||||
func WithDialect(d string) func(*Service) { return func(s *Service) { s.Dialect = d } }
|
||||
// Ensure *Service satisfies the domain interface.
|
||||
var _ domain.MessageService = (*Service)(nil)
|
||||
|
||||
// WithTimeout overrides per-call context timeout.
|
||||
func WithTimeout(d time.Duration) func(*Service) { return func(s *Service) { s.Timeout = d } }
|
||||
|
||||
func (s *Service) ListInbox(userID int64) ([]accountMessageHandler.Message, error) {
|
||||
func (s *Service) ListInbox(userID int64) ([]domain.Message, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
|
||||
defer cancel()
|
||||
|
||||
const q = `
|
||||
q := `
|
||||
SELECT id, from_email, to_email, subject, body, is_read, is_archived, created_at
|
||||
FROM messages
|
||||
FROM users_messages
|
||||
WHERE user_id = ? AND is_archived = FALSE
|
||||
ORDER BY created_at DESC`
|
||||
rows, err := s.DB.QueryContext(s.rebind(ctx), s.bind(q), userID)
|
||||
q = s.bind(q)
|
||||
|
||||
rows, err := s.DB.QueryContext(ctx, q, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []accountMessageHandler.Message
|
||||
var out []domain.Message
|
||||
for rows.Next() {
|
||||
var m accountMessageHandler.Message
|
||||
var m domain.Message
|
||||
if err := rows.Scan(&m.ID, &m.From, &m.To, &m.Subject, &m.Body, &m.IsRead, &m.IsArchived, &m.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) ListArchived(userID int64) ([]accountMessageHandler.Message, error) {
|
||||
func (s *Service) ListArchived(userID int64) ([]domain.Message, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
|
||||
defer cancel()
|
||||
|
||||
const q = `
|
||||
q := `
|
||||
SELECT id, from_email, to_email, subject, body, is_read, is_archived, created_at
|
||||
FROM messages
|
||||
FROM users_messages
|
||||
WHERE user_id = ? AND is_archived = TRUE
|
||||
ORDER BY created_at DESC`
|
||||
rows, err := s.DB.QueryContext(s.rebind(ctx), s.bind(q), userID)
|
||||
q = s.bind(q)
|
||||
|
||||
rows, err := s.DB.QueryContext(ctx, q, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []accountMessageHandler.Message
|
||||
var out []domain.Message
|
||||
for rows.Next() {
|
||||
var m accountMessageHandler.Message
|
||||
var m domain.Message
|
||||
if err := rows.Scan(&m.ID, &m.From, &m.To, &m.Subject, &m.Body, &m.IsRead, &m.IsArchived, &m.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -89,16 +94,18 @@ func (s *Service) ListArchived(userID int64) ([]accountMessageHandler.Message, e
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) GetByID(userID, id int64) (*accountMessageHandler.Message, error) {
|
||||
func (s *Service) GetByID(userID, id int64) (*domain.Message, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
|
||||
defer cancel()
|
||||
|
||||
const q = `
|
||||
q := `
|
||||
SELECT id, from_email, to_email, subject, body, is_read, is_archived, created_at
|
||||
FROM messages
|
||||
FROM users_messages
|
||||
WHERE user_id = ? AND id = ?`
|
||||
var m accountMessageHandler.Message
|
||||
err := s.DB.QueryRowContext(s.rebind(ctx), s.bind(q), userID, id).
|
||||
q = s.bind(q)
|
||||
|
||||
var m domain.Message
|
||||
err := s.DB.QueryRowContext(ctx, q, userID, id).
|
||||
Scan(&m.ID, &m.From, &m.To, &m.Subject, &m.Body, &m.IsRead, &m.IsArchived, &m.CreatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
@@ -109,7 +116,7 @@ func (s *Service) GetByID(userID, id int64) (*accountMessageHandler.Message, err
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (s *Service) Create(userID int64, in accountMessageHandler.CreateMessageInput) (int64, error) {
|
||||
func (s *Service) Create(userID int64, in domain.CreateMessageInput) (int64, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -128,7 +135,7 @@ func (s *Service) Create(userID int64, in accountMessageHandler.CreateMessageInp
|
||||
const q = `
|
||||
INSERT INTO messages (user_id, from_email, to_email, subject, body, is_read, is_archived, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, FALSE, FALSE, CURRENT_TIMESTAMP)`
|
||||
res, err := s.DB.ExecContext(s.rebind(ctx), q, userID, "", in.To, in.Subject, in.Body)
|
||||
res, err := s.DB.ExecContext(ctx, q, userID, "", in.To, in.Subject, in.Body)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -136,17 +143,13 @@ func (s *Service) Create(userID int64, in accountMessageHandler.CreateMessageInp
|
||||
}
|
||||
}
|
||||
|
||||
// --- small helpers ---
|
||||
|
||||
// bind replaces ? with $1.. for Postgres if needed.
|
||||
// We keep queries written with ? for brevity and adapt here.
|
||||
// bind replaces '?' with '$1..' only for Postgres. For MySQL/SQLite it returns q unchanged.
|
||||
func (s *Service) bind(q string) string {
|
||||
if s.Dialect != "postgres" {
|
||||
return q
|
||||
}
|
||||
// cheap replacer for positional params:
|
||||
n := 0
|
||||
out := make([]byte, 0, len(q)+10)
|
||||
out := make([]byte, 0, len(q)+8)
|
||||
for i := 0; i < len(q); i++ {
|
||||
if q[i] == '?' {
|
||||
n++
|
||||
@@ -159,9 +162,7 @@ func (s *Service) bind(q string) string {
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func (s *Service) rebind(ctx context.Context) context.Context { return ctx }
|
||||
|
||||
// intToStr avoids fmt for tiny helper
|
||||
// ToDo: helper dont think it should be here.
|
||||
func intToStr(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
// Package notifysvc
|
||||
// Path: /internal/platform/services/notifications
|
||||
// File: service.go
|
||||
// ToDo: carve out sql
|
||||
|
||||
package notifysvc
|
||||
|
||||
import (
|
||||
@@ -7,7 +11,7 @@ import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
accountNotificationHandler "synlotto-website/internal/handlers/account/notifications"
|
||||
domain "synlotto-website/internal/domain/notifications"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -31,7 +35,7 @@ func New(db *sql.DB, opts ...func(*Service)) *Service {
|
||||
func WithTimeout(d time.Duration) func(*Service) { return func(s *Service) { s.Timeout = d } }
|
||||
|
||||
// List returns newest-first notifications for a user.
|
||||
func (s *Service) List(userID int64) ([]accountNotificationHandler.Notification, error) {
|
||||
func (s *Service) List(userID int64) ([]domain.Notification, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -47,9 +51,9 @@ ORDER BY created_at DESC`
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []accountNotificationHandler.Notification
|
||||
var out []domain.Notification
|
||||
for rows.Next() {
|
||||
var n accountNotificationHandler.Notification
|
||||
var n domain.Notification
|
||||
if err := rows.Scan(&n.ID, &n.Title, &n.Body, &n.IsRead, &n.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -58,7 +62,7 @@ ORDER BY created_at DESC`
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) GetByID(userID, id int64) (*accountNotificationHandler.Notification, error) {
|
||||
func (s *Service) GetByID(userID, id int64) (*domain.Notification, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), s.Timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -67,7 +71,7 @@ SELECT id, title, body, is_read, created_at
|
||||
FROM notifications
|
||||
WHERE user_id = ? AND id = ?`
|
||||
|
||||
var n accountNotificationHandler.Notification
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user