Compare commits

...

2 Commits

Author SHA1 Message Date
b630296b8c Add message system (inbox, read view, dropdown) and truncate helper
Implemented message retrieval and read logic in storage layer

Added handlers for inbox and individual message view

Integrated messages into topbar dropdown with unread badge

Added truncate helper to template functions

Created new templates: messages/index.html and messages/read.html

Fixed missing template function error in topbar rendering
2025-04-02 11:56:11 +01:00
ab1d9abc72 Refactor: Recover middleware now uses RenderError + add full notifications view
- Replaced http.Error with helpers.RenderError in Recover middleware
- Custom 500.html now rendered with layout and topbar on panic
- RenderError gracefully checks template existence and falls back to plain response
- Added /account/notifications full view page (index)
- Linked "Back to notifications" from notification read view
- Fixed typo in template path for notifications/index.html
- Improved layout consistency across error and account pages
2025-04-02 09:54:20 +01:00
13 changed files with 235 additions and 60 deletions

54
handlers/messages.go Normal file
View File

@@ -0,0 +1,54 @@
package handlers
import (
"database/sql"
"log"
"net/http"
"synlotto-website/helpers"
"synlotto-website/storage"
)
func MessagesInboxHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data := BuildTemplateData(db, w, r)
context := helpers.TemplateContext(w, r, data)
tmpl := helpers.LoadTemplateFiles("messages.html", "templates/account/messages/index.html")
err := tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
helpers.RenderError(w, r, 500)
}
}
}
func ReadMessageHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
idStr := r.URL.Query().Get("id")
messageID := helpers.Atoi(idStr)
session, _ := helpers.GetSession(w, r)
userID, ok := session.Values["user_id"].(int)
if !ok {
helpers.RenderError(w, r, 403)
return
}
message, err := storage.GetMessageByID(db, userID, messageID)
if err != nil {
log.Printf("❌ Message not found: %v", err)
message = nil
} else if !message.IsRead {
_ = storage.MarkMessageAsRead(db, messageID, userID)
}
data := BuildTemplateData(db, w, r)
context := helpers.TemplateContext(w, r, data)
context["Message"] = message
tmpl := helpers.LoadTemplateFiles("read-message.html", "templates/account/messages/read.html")
tmpl.ExecuteTemplate(w, "layout", context)
}
}

View File

@@ -15,7 +15,7 @@ func NotificationsHandler(db *sql.DB) http.HandlerFunc {
data := BuildTemplateData(db, w, r)
context := helpers.TemplateContext(w, r, data)
tmpl := helpers.LoadTemplateFiles("notifications.html", "templates/account/notifications.html")
tmpl := helpers.LoadTemplateFiles("index.html", "templates/account/notifications/index.html")
err := tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {

View File

@@ -1,24 +1,40 @@
package helpers
// ToDo should be a handler?
import (
"fmt"
"log"
"net/http"
"os"
"synlotto-website/models"
)
func RenderError(w http.ResponseWriter, r *http.Request, statusCode int) {
log.Printf("⚙️ RenderError called with status: %d", statusCode)
context := TemplateContext(w, r, models.TemplateData{})
page := fmt.Sprintf("templates/error/%d.html", statusCode)
tmpl := LoadTemplateFiles(fmt.Sprintf("%d.html", statusCode), page)
pagePath := fmt.Sprintf("templates/error/%d.html", statusCode)
log.Printf("📄 Checking for template file: %s", pagePath)
if _, err := os.Stat(pagePath); err != nil {
log.Printf("🚫 Template file missing: %s", err)
http.Error(w, http.StatusText(statusCode), statusCode)
return
}
log.Println("✅ Template file found, loading...")
tmpl := LoadTemplateFiles(fmt.Sprintf("%d.html", statusCode), pagePath)
w.WriteHeader(statusCode)
err := tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
log.Printf("❌ Failed to render error page for %d: %v", statusCode, err)
log.Printf("❌ Failed to render error page layout: %v", err)
http.Error(w, http.StatusText(statusCode), statusCode)
return
}
log.Println("✅ Successfully rendered 500 page")
}
//ToDo Pages.go /template.go to be merged?

View File

@@ -2,6 +2,7 @@ package helpers
import (
"html/template"
"log"
"net/http"
"strings"
@@ -59,6 +60,12 @@ func TemplateFuncs() template.FuncMap {
"inSlice": InSlice,
"lower": lower,
"rangeClass": rangeClass,
"truncate": func(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
},
}
}
@@ -69,6 +76,7 @@ func LoadTemplateFiles(name string, files ...string) *template.Template {
}
all := append(shared, files...)
log.Printf("📄 Loading templates: %v", all)
return template.Must(template.New(name).Funcs(TemplateFuncs()).ParseFiles(all...))
}

View File

@@ -68,6 +68,9 @@ func setupAccountRoutes(mux *http.ServeMux, db *sql.DB) {
mux.HandleFunc("/signup", middleware.Auth(false)(handlers.Signup))
mux.HandleFunc("/account/tickets/add_ticket", handlers.AddTicket(db))
mux.HandleFunc("/account/tickets/my_tickets", handlers.GetMyTickets(db))
mux.HandleFunc("/account/messages", middleware.Auth(true)(handlers.MessagesInboxHandler(db)))
mux.HandleFunc("/account/messages/read", middleware.Auth(true)(handlers.ReadMessageHandler(db)))
mux.HandleFunc("/account/notifications", middleware.Auth(true)(handlers.NotificationsHandler(db)))
mux.HandleFunc("/account/notifications/read", middleware.Auth(true)(handlers.MarkNotificationReadHandler(db)))
}

View File

@@ -4,6 +4,7 @@ import (
"log"
"net/http"
"runtime/debug"
"synlotto-website/helpers"
)
func Recover(next http.Handler) http.Handler {
@@ -12,7 +13,8 @@ func Recover(next http.Handler) http.Handler {
if rec := recover(); rec != nil {
log.Printf("🔥 Recovered from panic: %v\n%s", rec, debug.Stack())
http.Error(w, "Internal server error", http.StatusInternalServerError)
// ✅ Call your custom template-based fallback
helpers.RenderError(w, r, http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)

View File

@@ -23,12 +23,13 @@ type Notification struct {
}
type Message struct {
ID int
Sender string
Subject string
Message string
IsRead bool
CreatedAt time.Time
ID int
SenderId int
RecipientId int
Subject string
Message string
IsRead bool
CreatedAt time.Time
}
var db *sql.DB

View File

@@ -2,23 +2,24 @@ package storage
import (
"database/sql"
"fmt"
"synlotto-website/models"
)
func GetMessageCount(db *sql.DB, userID int) (int, error) {
var count int
err := db.QueryRow(`
SELECT COUNT(*) FROM users_messages
WHERE user_id = ? AND is_read = FALSE
SELECT COUNT(*) FROM user_messages
WHERE recipient_id = ? AND is_read = FALSE
`, userID).Scan(&count)
return count, err
}
func GetRecentMessages(db *sql.DB, userID int, limit int) []models.Message {
rows, err := db.Query(`
SELECT id, title, message, is_read
FROM users_messages
WHERE user_id = ?
SELECT id, sender_id, recipient_id, subject, body, is_read, created_at
FROM user_messages
WHERE recipient_id = ?
ORDER BY created_at DESC
LIMIT ?
`, userID, limit)
@@ -30,9 +31,53 @@ func GetRecentMessages(db *sql.DB, userID int, limit int) []models.Message {
var messages []models.Message
for rows.Next() {
var m models.Message
rows.Scan(&m.ID, &m.Subject, &m.Message, &m.IsRead)
messages = append(messages, m)
err := rows.Scan(
&m.ID,
&m.SenderId,
&m.RecipientId,
&m.Subject,
&m.Message,
&m.IsRead,
&m.CreatedAt,
)
if err == nil {
messages = append(messages, m)
}
}
return messages
}
func GetMessageByID(db *sql.DB, userID, messageID int) (*models.Message, error) {
row := db.QueryRow(`
SELECT id, sender_id, recipient_id, subject, body, is_read, created_at
FROM user_messages
WHERE id = ? AND recipient_id = ?
`, messageID, userID)
var m models.Message
err := row.Scan(&m.ID, &m.SenderId, &m.RecipientId, &m.Subject, &m.Message, &m.IsRead, &m.CreatedAt)
if err != nil {
return nil, err
}
return &m, nil
}
func MarkMessageAsRead(db *sql.DB, messageID, userID int) error {
result, err := db.Exec(`
UPDATE user_messages
SET is_read = TRUE
WHERE id = ? AND recipient_id = ?
`, messageID, userID)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return fmt.Errorf("no matching message found for user_id=%d and message_id=%d", userID, messageID)
}
return nil
}

View File

@@ -114,8 +114,9 @@ CREATE TABLE IF NOT EXISTS my_tickets (
const SchemaUsersMessages = `
CREATE TABLE IF NOT EXISTS users_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
senderId INTEGER NOT NULL REFERENCES users(id),
recipientId int,
subject TEXT NOT NULL,
message TEXT,
is_read BOOLEAN DEFAULT FALSE,
type VARCHAR(50),

View File

@@ -0,0 +1,22 @@
{{ define "content" }}
<div class="container py-4">
<h2>Your Messages</h2>
<ul class="list-group mt-3">
{{ range .Messages }}
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<a href="/account/messages/read?id={{ .ID }}" class="{{ if not .IsRead }}fw-bold{{ end }}">
{{ .Subject }}
</a>
<div class="small text-muted">{{ .CreatedAt.Format "02 Jan 2006 15:04" }}</div>
</div>
{{ if not .IsRead }}
<span class="badge bg-primary">Unread</span>
{{ end }}
</li>
{{ else }}
<li class="list-group-item text-muted text-center">No messages</li>
{{ end }}
</ul>
</div>
{{ end }}

View File

@@ -0,0 +1,15 @@
{{ define "content" }}
<div class="container py-5">
{{ if .Message }}
<h2>{{ .Message.Subject }}</h2>
<p class="text-muted">Received: {{ .Message.CreatedAt.Format "02 Jan 2006 15:04" }}</p>
<hr>
<p>{{ .Message.Body }}</p>
<a href="/account/messages" class="btn btn-secondary mt-4">Back to Inbox</a>
{{ else }}
<div class="alert alert-danger text-center">
Message not found or access denied.
</div>
{{ end }}
</div>
{{ end }}

View File

@@ -1,23 +1,29 @@
{{ define "notifications" }}
{{ define "content" }}
<div class="container py-4">
<h2 class="mb-4">Notifications</h2>
<h2 class="mb-4">Your Notifications</h2>
{{ if .Notifications }}
<ul class="list-group">
{{ range .Notifications }}
<li class="list-group-item d-flex justify-content-between align-items-start {{ if not .IsRead }}fw-bold{{ end }}">
<div class="ms-2 me-auto">
<div class="fw-semibold">{{ .Title }}</div>
<small class="text-muted">{{ .Message }}</small>
</div>
{{ if not .IsRead }}
<a href="/account/notifications/read?id={{ .ID }}" class="badge bg-primary text-decoration-none">Mark as read</a>
<ul class="list-group">
{{ range .Notifications }}
<li class="list-group-item d-flex justify-content-between align-items-start {{ if not .IsRead }}bg-light{{ end }}">
<div class="ms-2 me-auto">
<div class="fw-bold">
<a href="/account/notifications/read?id={{ .ID }}" class="{{ if not .IsRead }}text-primary fw-bold{{ end }}">
{{ .Subject }}
</a>
</div>
<small class="text-muted">{{ .CreatedAt.Format "Jan 2, 2006 15:04" }}</small>
</div>
{{ if not .IsRead }}
<span class="badge bg-warning text-dark">New</span>
{{ end }}
</li>
{{ end }}
</li>
{{ end }}
</ul>
</ul>
{{ else }}
<div class="alert alert-info">You have no notifications.</div>
<div class="alert alert-info text-center">
You dont have any notifications.
</div>
{{ end }}
</div>
{{ end }}

View File

@@ -82,33 +82,35 @@
<a class="nav-link text-dark" href="#" id="messageDropdown" role="button" data-bs-toggle="dropdown"
aria-expanded="false">
<i class="bi bi-envelope fs-5 position-relative">
<!-- Unread badge (example: 2 messages) -->
<span
class="position-absolute top-0 start-0 translate-middle badge rounded-pill bg-danger text-dark badge-small">2</span>
{{ if gt .MessageCount 0 }}
<span class="position-absolute top-0 start-0 translate-middle badge rounded-pill bg-danger text-dark badge-small">
{{ if gt .MessageCount 15 }}15+{{ else }}{{ .MessageCount }}{{ end }}
</span>
{{ end }}
</i>
</a>
<ul class="dropdown-menu dropdown-menu-end dropdown-message-box shadow-sm dropdown-with-arrow"
aria-labelledby="messageDropdown">
<li class="dropdown-header text-center fw-bold">Messages</li>
<li>
<hr class="dropdown-divider">
</li>
<li><hr class="dropdown-divider"></li>
<!-- Example message item -->
<li class="px-3 py-2">
<div class="d-flex align-items-start">
<i class="bi bi-person-circle me-2 fs-4 text-secondary"></i>
<div>
<div class="fw-semibold">Admin</div>
<small class="text-muted">Welcome to SynLotto!</small>
{{ if .Messages }}
{{ range $i, $m := .Messages }}
<li class="px-3 py-2">
<div class="d-flex align-items-start">
<i class="bi bi-person-circle me-2 fs-4 text-secondary"></i>
<div>
<div class="fw-semibold">{{ $m.Subject }}</div>
<small class="text-muted">{{ truncate $m.Body 40 }}</small>
</div>
</div>
</div>
</li>
<li>
<hr class="dropdown-divider">
</li>
<li class="text-center"><a href="/messages" class="dropdown-item">View all messages</a></li>
</li>
<li><hr class="dropdown-divider"></li>
{{ end }}
<li class="text-center"><a href="/account/messages" class="dropdown-item">View all messages</a></li>
{{ else }}
<li class="text-center text-muted py-2">No messages</li>
{{ end }}
</ul>
</div>