diff --git a/internal/handlers/account/messages/archive.go b/internal/handlers/account/messages/archive.go new file mode 100644 index 0000000..f9705e0 --- /dev/null +++ b/internal/handlers/account/messages/archive.go @@ -0,0 +1,26 @@ +// Package accountMessageHandler +// Path: /internal/handlers/account/messages +// File: archive.go + +package accountMessageHandler + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// GET /account/messages/archived +// Renders: web/templates/account/messages/archived.html +func (h *AccountMessageHandlers) ArchivedList(c *gin.Context) { + userID := mustUserID(c) + msgs, err := h.Svc.ListArchived(userID) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to load archived messages"}) + return + } + c.HTML(http.StatusOK, "account/messages/archived.html", gin.H{ + "title": "Archived Messages", + "messages": msgs, + }) +} diff --git a/internal/handlers/account/messages/list.go b/internal/handlers/account/messages/list.go index e2d0c11..998838e 100644 --- a/internal/handlers/account/messages/list.go +++ b/internal/handlers/account/messages/list.go @@ -1,98 +1,14 @@ +// Package accountMessageHandler +// Path: /internal/handlers/account/messages +// File: list.go +// ToDo: helpers for reading getting messages shouldn't really be here. --- + package accountMessageHandler import ( - "net/http" - "github.com/gin-gonic/gin" ) -// GET /account/messages -// Renders: web/templates/account/messages/index.html -func (h *AccountMessageHandlers) List(c *gin.Context) { - userID := mustUserID(c) // replace with your auth/user extraction - msgs, err := h.Svc.ListInbox(userID) - if err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to load messages"}) - return - } - c.HTML(http.StatusOK, "account/messages/index.html", gin.H{ - "title": "Messages", - "messages": msgs, - }) -} - -// 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", - }) -} - -// POST /account/messages/add -func (h *AccountMessageHandlers) AddPost(c *gin.Context) { - userID := mustUserID(c) - var in 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, - }) - 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, - }) - return - } - // Redirect back to inbox - c.Redirect(http.StatusSeeOther, "/account/messages") -} - -// --- Optional extras since you have read.html and archived.html --- - -// GET /account/messages/:id -// Renders: web/templates/account/messages/read.html -func (h *AccountMessageHandlers) ReadGet(c *gin.Context) { - 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, - }) -} - -// GET /account/messages/archived -// Renders: web/templates/account/messages/archived.html -func (h *AccountMessageHandlers) ArchivedList(c *gin.Context) { - userID := mustUserID(c) - msgs, err := h.Svc.ListArchived(userID) - if err != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to load archived messages"}) - return - } - c.HTML(http.StatusOK, "account/messages/archived.html", gin.H{ - "title": "Archived Messages", - "messages": msgs, - }) -} - -// --- helpers --- - func mustUserID(c *gin.Context) int64 { // Pull from your auth middleware/session. Panic-unsafe alternative: if v, ok := c.Get("userID"); ok { diff --git a/internal/handlers/account/messages/read.go b/internal/handlers/account/messages/read.go new file mode 100644 index 0000000..d687463 --- /dev/null +++ b/internal/handlers/account/messages/read.go @@ -0,0 +1,46 @@ +// Package accountMessageHandler +// Path: /internal/handlers/account/messages +// File: read.go + +package accountMessageHandler + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// GET /account/messages +// Renders: web/templates/account/messages/index.html +func (h *AccountMessageHandlers) List(c *gin.Context) { + userID := mustUserID(c) + msgs, err := h.Svc.ListInbox(userID) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "failed to load messages"}) + return + } + c.HTML(http.StatusOK, "account/messages/index.html", gin.H{ + "title": "Messages", + "messages": msgs, + }) +} + +// GET /account/messages/:id +// Renders: web/templates/account/messages/read.html +func (h *AccountMessageHandlers) ReadGet(c *gin.Context) { + 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, + }) +} diff --git a/internal/handlers/account/messages/send.go b/internal/handlers/account/messages/send.go new file mode 100644 index 0000000..5790f07 --- /dev/null +++ b/internal/handlers/account/messages/send.go @@ -0,0 +1,44 @@ +// Package accountMessageHandler +// Path: /internal/handlers/account/messages +// File: send.go + +package accountMessageHandler + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// 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", + }) +} + +// POST /account/messages/add +func (h *AccountMessageHandlers) AddPost(c *gin.Context) { + userID := mustUserID(c) + var in 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, + }) + 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, + }) + return + } + // Redirect back to inbox + c.Redirect(http.StatusSeeOther, "/account/messages") +} diff --git a/internal/handlers/account/messages/types.go b/internal/handlers/account/messages/types.go index 93de74d..5b8eb01 100644 --- a/internal/handlers/account/messages/types.go +++ b/internal/handlers/account/messages/types.go @@ -1,3 +1,7 @@ +// Package accountMessageHandler +// Path: /internal/handlers/account/messages +// File: types.go + package accountMessageHandler import "time" diff --git a/internal/http/routes/accountroutes.go b/internal/http/routes/accountroutes.go index 67431e4..a85d6eb 100644 --- a/internal/http/routes/accountroutes.go +++ b/internal/http/routes/accountroutes.go @@ -21,10 +21,10 @@ package routes import ( - accountHandlers "synlotto-website/internal/handlers/account" - accountMessageHandlers "synlotto-website/internal/handlers/account/messages" - accountNotificationHandlers "synlotto-website/internal/handlers/account/notifications" - accountTicketHandlers "synlotto-website/internal/handlers/account/tickets" + accountHandler "synlotto-website/internal/handlers/account" + accoutMessageHandler "synlotto-website/internal/handlers/account/messages" + accountNotificationHandler "synlotto-website/internal/handlers/account/notifications" + accountTicketHandler "synlotto-website/internal/handlers/account/tickets" "synlotto-website/internal/http/middleware" "synlotto-website/internal/platform/bootstrap" @@ -37,45 +37,45 @@ func RegisterAccountRoutes(app *bootstrap.App) { acc := r.Group("/account") acc.Use(middleware.PublicOnly()) { - acc.GET("/login", accountHandlers.LoginGet) - acc.POST("/login", accountHandlers.LoginPost) - acc.GET("/signup", accountHandlers.SignupGet) - acc.POST("/signup", accountHandlers.SignupPost) + acc.GET("/login", accountHandler.LoginGet) + acc.POST("/login", accountHandler.LoginPost) + acc.GET("/signup", accountHandler.SignupGet) + acc.POST("/signup", accountHandler.SignupPost) } // Auth-required account actions accAuth := r.Group("/account") accAuth.Use(middleware.AuthMiddleware(), middleware.RequireAuth()) { - accAuth.POST("/logout", accountHandlers.Logout) - accAuth.GET("/logout", accountHandlers.Logout) // optional + accAuth.POST("/logout", accountHandler.Logout) + accAuth.GET("/logout", accountHandler.Logout) // optional } // Messages (auth-required) messages := r.Group("/account/messages") messages.Use(middleware.AuthMiddleware(), middleware.RequireAuth()) { - messages.GET("/", accountMessageHandlers.List) - messages.GET("/add", accountMessageHandlers.AddGet) - messages.POST("/add", accountMessageHandlers.AddPost) - messages.GET("/archived", accountMessageHandlers.ArchivedList) // renders archived.html - messages.GET("/:id", accountMessageHandlers.ReadGet) // renders read.html + 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 } // Notifications (auth-required) notifications := r.Group("/account/notifications") notifications.Use(middleware.AuthMiddleware(), middleware.RequireAuth()) { - notifications.GET("/", accountNotificationHandlers.List) - notifications.GET("/:id", accountNotificationHandlers.ReadGet) // renders read.html + notifications.GET("/", accountNotificationHandler.List) + notifications.GET("/:id", accountNotificationHandler.ReadGet) // renders read.html } // Tickets (auth-required) tickets := r.Group("/account/tickets") tickets.Use(middleware.AuthMiddleware(), middleware.RequireAuth()) { - tickets.GET("/", accountTicketHandlers.List) // GET /account/tickets - tickets.GET("/add", accountTicketHandlers.AddGet) // GET /account/tickets/add - tickets.POST("/add", accountTicketHandlers.AddPost) // POST /account/tickets/add + tickets.GET("/", accountTicketHandler.List) // GET /account/tickets + tickets.GET("/add", accountTicketHandler.AddGet) // GET /account/tickets/add + tickets.POST("/add", accountTicketHandler.AddPost) // POST /account/tickets/add } } diff --git a/internal/platform/services/messages/service.go b/internal/platform/services/messages/service.go new file mode 100644 index 0000000..6b14749 --- /dev/null +++ b/internal/platform/services/messages/service.go @@ -0,0 +1,177 @@ +// ToDo: not currently used and need to carve out sql +package messagesvc + +import ( + "context" + "database/sql" + "errors" + "time" + + accountMessageHandler "synlotto-website/internal/handlers/account/messages" +) + +// Service implements accountMessageHandler.MessageService. +type Service struct { + DB *sql.DB + Dialect string // "postgres", "mysql", "sqlite" (affects INSERT id retrieval) + Now func() time.Time + Timeout time.Duration +} + +func New(db *sql.DB, opts ...func(*Service)) *Service { + s := &Service{ + DB: db, + Dialect: "mysql", // sane default for LastInsertId (works for mysql/sqlite) + Now: time.Now, + Timeout: 5 * time.Second, + } + for _, opt := range opts { + opt(s) + } + return s +} + +// WithDialect sets SQL dialect hints: "postgres" uses RETURNING id. +func WithDialect(d string) func(*Service) { return func(s *Service) { s.Dialect = d } } + +// 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) { + ctx, cancel := context.WithTimeout(context.Background(), s.Timeout) + defer cancel() + + const q = ` + SELECT id, from_email, to_email, subject, body, is_read, is_archived, created_at + FROM 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) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []accountMessageHandler.Message + for rows.Next() { + var m accountMessageHandler.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) { + ctx, cancel := context.WithTimeout(context.Background(), s.Timeout) + defer cancel() + + const q = ` + SELECT id, from_email, to_email, subject, body, is_read, is_archived, created_at + FROM 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) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []accountMessageHandler.Message + for rows.Next() { + var m accountMessageHandler.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) GetByID(userID, id int64) (*accountMessageHandler.Message, error) { + ctx, cancel := context.WithTimeout(context.Background(), s.Timeout) + defer cancel() + + const q = ` + SELECT id, from_email, to_email, subject, body, is_read, is_archived, created_at + FROM messages + WHERE user_id = ? AND id = ?` + var m accountMessageHandler.Message + err := s.DB.QueryRowContext(s.rebind(ctx), s.bind(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 + } + if err != nil { + return nil, err + } + return &m, nil +} + +func (s *Service) Create(userID int64, in accountMessageHandler.CreateMessageInput) (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), s.Timeout) + defer cancel() + + switch s.Dialect { + case "postgres": + const q = ` + INSERT INTO messages (user_id, from_email, to_email, subject, body, is_read, is_archived, created_at) + VALUES ($1, $2, $3, $4, $5, FALSE, FALSE, NOW()) + RETURNING id` + var id int64 + if err := s.DB.QueryRowContext(ctx, q, userID, "", in.To, in.Subject, in.Body).Scan(&id); err != nil { + return 0, err + } + return id, nil + default: // mysql/sqlite + 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) + if err != nil { + return 0, err + } + return res.LastInsertId() + } +} + +// --- small helpers --- + +// bind replaces ? with $1.. for Postgres if needed. +// We keep queries written with ? for brevity and adapt here. +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) + 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) rebind(ctx context.Context) context.Context { return ctx } + +// intToStr avoids fmt for tiny helper +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:]) +} diff --git a/internal/platform/services/notifications/service.go b/internal/platform/services/notifications/service.go new file mode 100644 index 0000000..821faf6 --- /dev/null +++ b/internal/platform/services/notifications/service.go @@ -0,0 +1,80 @@ +// ToDo: carve out sql +package notifysvc + +import ( + "context" + "database/sql" + "errors" + "time" + + accountNotificationHandler "synlotto-website/internal/handlers/account/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. +func (s *Service) List(userID int64) ([]accountNotificationHandler.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 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 []accountNotificationHandler.Notification + for rows.Next() { + var n accountNotificationHandler.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) (*accountNotificationHandler.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 user_id = ? AND id = ?` + + var n accountNotificationHandler.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 +}