Continued work on messages and notifications.

This commit is contained in:
2025-10-30 11:11:22 +00:00
parent b41e92629b
commit 8650b1fd63
14 changed files with 387 additions and 172 deletions

View 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)
}

View 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)
}

View File

@@ -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")
}
}

View File

@@ -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")
}
}

View File

@@ -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")
}

View File

@@ -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
}

View File

@@ -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")
}
}

View 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")
}
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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"

View File

@@ -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) {