Compare commits

...

3 Commits

Author SHA1 Message Date
db5352bc9c Add restore functionality for archived messages
- Added `RestoreMessageHandler` and route at `/account/messages/restore`
- Updated `users_messages` table to support `archived_at` reset
- Added restore button to archived messages template
- Ensures archived messages can be moved back into inbox
2025-04-02 22:18:02 +01:00
dd83081271 Added full message handling system with archive view, pagination, and send support
- Implemented message inbox and archived messages view
- Added pagination logic to both inbox and archive handlers
- Integrated message sending functionality with CSRF protection
- Updated schema to include `archived_at` timestamp
- Included archive button and logic with feedback flash messaging
- Fixed message dropdown routing and rendering in topbar
- Cleaned up template load paths and error handling
2025-04-02 21:29:54 +01:00
e3428911b9 Messages: Add archive (soft-delete) support + dropdown UI polish
- Implemented `/account/messages/archive` route for soft-archiving messages
- Added `is_archived` flag to `users_messages` schema and model
- Topbar dropdown now reflects accurate unread message count
- Fixed missing route registration for archive handler
- Improved message visibility checks to prevent access violations
- Placeholder for rate-limit (429) error page rendering identified
2025-04-02 17:15:57 +01:00
12 changed files with 378 additions and 26 deletions

View File

@@ -11,13 +11,37 @@ import (
func MessagesInboxHandler(db *sql.DB) http.HandlerFunc { func MessagesInboxHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
userID, ok := helpers.GetCurrentUserID(r)
if !ok {
helpers.RenderError(w, r, 403)
return
}
page := helpers.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
perPage := 10
totalCount := storage.GetInboxMessageCount(db, userID)
totalPages := (totalCount + perPage - 1) / perPage
if totalPages == 0 {
totalPages = 1
}
messages := storage.GetInboxMessages(db, userID, page, perPage)
data := BuildTemplateData(db, w, r) data := BuildTemplateData(db, w, r)
context := helpers.TemplateContext(w, r, data) context := helpers.TemplateContext(w, r, data)
context["Messages"] = messages
context["CurrentPage"] = page
context["TotalPages"] = totalPages
context["PageRange"] = helpers.PageRange(page, totalPages)
tmpl := helpers.LoadTemplateFiles("messages.html", "templates/account/messages/index.html") tmpl := helpers.LoadTemplateFiles("messages.html", "templates/account/messages/index.html")
err := tmpl.ExecuteTemplate(w, "layout", context) if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
if err != nil {
helpers.RenderError(w, r, 500) helpers.RenderError(w, r, 500)
} }
} }
@@ -52,3 +76,107 @@ func ReadMessageHandler(db *sql.DB) http.HandlerFunc {
tmpl.ExecuteTemplate(w, "layout", context) tmpl.ExecuteTemplate(w, "layout", context)
} }
} }
func ArchiveMessageHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := helpers.Atoi(r.URL.Query().Get("id"))
userID, ok := helpers.GetCurrentUserID(r)
if !ok {
helpers.RenderError(w, r, 403)
return
}
err := storage.ArchiveMessage(db, userID, id)
if err != nil {
helpers.SetFlash(w, r, "Failed to archive message.")
} else {
helpers.SetFlash(w, r, "Message archived.")
}
http.Redirect(w, r, "/account/messages", http.StatusSeeOther)
}
}
func ArchivedMessagesHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := helpers.GetCurrentUserID(r)
if !ok {
helpers.RenderError(w, r, 403)
return
}
page := helpers.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
perPage := 10
messages := storage.GetArchivedMessages(db, userID, page, perPage)
hasMore := len(messages) == perPage
data := BuildTemplateData(db, w, r)
context := helpers.TemplateContext(w, r, data)
context["Messages"] = messages
context["Page"] = page
context["HasMore"] = hasMore
tmpl := helpers.LoadTemplateFiles("archived.html", "templates/account/messages/archived.html")
tmpl.ExecuteTemplate(w, "layout", context)
}
}
func SendMessageHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// Display the form
data := BuildTemplateData(db, w, r)
context := helpers.TemplateContext(w, r, data)
tmpl := helpers.LoadTemplateFiles("send-message.html", "templates/account/messages/send.html")
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
helpers.RenderError(w, r, 500)
}
case http.MethodPost:
// Handle form submission
senderID, ok := helpers.GetCurrentUserID(r)
if !ok {
helpers.RenderError(w, r, 403)
return
}
recipientID := helpers.Atoi(r.FormValue("recipient_id"))
subject := r.FormValue("subject")
body := r.FormValue("message")
if err := storage.SendMessage(db, senderID, recipientID, subject, body); err != nil {
helpers.SetFlash(w, r, "Failed to send message.")
} else {
helpers.SetFlash(w, r, "Message sent.")
}
http.Redirect(w, r, "/account/messages", http.StatusSeeOther)
default:
helpers.RenderError(w, r, 405)
}
}
}
func RestoreMessageHandler(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := helpers.Atoi(r.URL.Query().Get("id"))
userID, ok := helpers.GetCurrentUserID(r)
if !ok {
helpers.RenderError(w, r, 403)
return
}
err := storage.RestoreMessage(db, userID, id)
if err != nil {
helpers.SetFlash(w, r, "Failed to restore message.")
} else {
helpers.SetFlash(w, r, "Message restored.")
}
http.Redirect(w, r, "/account/messages/archived", http.StatusSeeOther)
}
}

View File

@@ -16,3 +16,11 @@ func GetTotalPages(db *sql.DB, tableName, whereClause string, args []interface{}
} }
return totalPages, totalCount return totalPages, totalCount
} }
func MakePageRange(current, total int) []int {
var pages []int
for i := 1; i <= total; i++ {
pages = append(pages, i)
}
return pages
}

View File

@@ -44,6 +44,7 @@ func TemplateFuncs() template.FuncMap {
}, },
"mul": func(a, b int) int { return a * b }, "mul": func(a, b int) int { return a * b },
"add": func(a, b int) int { return a + b }, "add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"min": func(a, b int) int { "min": func(a, b int) int {
if a < b { if a < b {
return a return a
@@ -66,6 +67,7 @@ func TemplateFuncs() template.FuncMap {
} }
return s[:max] + "..." return s[:max] + "..."
}, },
"PageRange": PageRange,
} }
} }
@@ -115,3 +117,11 @@ func rangeClass(n int) string {
return "50-plus" return "50-plus"
} }
} }
func PageRange(current, total int) []int {
var pages []int
for i := 1; i <= total; i++ {
pages = append(pages, i)
}
return pages
}

View File

@@ -70,6 +70,10 @@ func setupAccountRoutes(mux *http.ServeMux, db *sql.DB) {
mux.HandleFunc("/account/tickets/my_tickets", handlers.GetMyTickets(db)) mux.HandleFunc("/account/tickets/my_tickets", handlers.GetMyTickets(db))
mux.HandleFunc("/account/messages", middleware.Auth(true)(handlers.MessagesInboxHandler(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/messages/read", middleware.Auth(true)(handlers.ReadMessageHandler(db)))
mux.HandleFunc("/account/messages/archive", middleware.Auth(true)(handlers.ArchiveMessageHandler(db)))
mux.HandleFunc("/account/messages/archived", middleware.Auth(true)(handlers.ArchivedMessagesHandler(db)))
mux.HandleFunc("/account/messages/restore", middleware.Auth(true)(handlers.RestoreMessageHandler(db)))
mux.HandleFunc("/account/messages/send", middleware.Auth(true)(handlers.SendMessageHandler(db)))
mux.HandleFunc("/account/notifications", middleware.Auth(true)(handlers.NotificationsHandler(db))) mux.HandleFunc("/account/notifications", middleware.Auth(true)(handlers.NotificationsHandler(db)))
mux.HandleFunc("/account/notifications/read", middleware.Auth(true)(handlers.MarkNotificationReadHandler(db))) mux.HandleFunc("/account/notifications/read", middleware.Auth(true)(handlers.MarkNotificationReadHandler(db)))
} }

View File

@@ -30,6 +30,7 @@ type Message struct {
Message string Message string
IsRead bool IsRead bool
CreatedAt time.Time CreatedAt time.Time
ArchivedAt *time.Time
} }
var db *sql.DB var db *sql.DB

View File

@@ -10,7 +10,7 @@ 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 users_messages
WHERE recipientId = ? AND is_read = FALSE WHERE recipientId = ? AND is_read = FALSE AND is_archived = FALSE
`, userID).Scan(&count) `, userID).Scan(&count)
return count, err return count, err
} }
@@ -19,7 +19,7 @@ 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, message, is_read, created_at
FROM users_messages FROM users_messages
WHERE recipientId = ? WHERE recipientId = ? AND is_archived = FALSE
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ? LIMIT ?
`, userID, limit) `, userID, limit)
@@ -81,3 +81,98 @@ func MarkMessageAsRead(db *sql.DB, messageID, userID int) error {
} }
return nil return nil
} }
func ArchiveMessage(db *sql.DB, userID, messageID int) error {
_, err := db.Exec(`
UPDATE users_messages
SET is_archived = TRUE, archived_at = CURRENT_TIMESTAMP
WHERE id = ? AND recipientId = ?
`, messageID, userID)
return err
}
func SendMessage(db *sql.DB, senderID, recipientID int, subject, message string) error {
_, err := db.Exec(`
INSERT INTO users_messages (senderId, recipientId, subject, message)
VALUES (?, ?, ?, ?)
`, senderID, recipientID, subject, message)
return err
}
func GetArchivedMessages(db *sql.DB, userID int, page, perPage int) []models.Message {
offset := (page - 1) * perPage
rows, err := db.Query(`
SELECT id, senderId, recipientId, subject, message, is_read, created_at, archived_at
FROM users_messages
WHERE recipientId = ? AND is_archived = TRUE
ORDER BY archived_at DESC
LIMIT ? OFFSET ?
`, userID, perPage, offset)
if err != nil {
return nil
}
defer rows.Close()
var messages []models.Message
for rows.Next() {
var m models.Message
err := rows.Scan(
&m.ID, &m.SenderId, &m.RecipientId,
&m.Subject, &m.Message, &m.IsRead,
&m.CreatedAt, &m.ArchivedAt,
)
if err == nil {
messages = append(messages, m)
}
}
return messages
}
func GetInboxMessages(db *sql.DB, userID int, page, perPage int) []models.Message {
offset := (page - 1) * perPage
rows, err := db.Query(`
SELECT id, senderId, recipientId, subject, message, is_read, created_at
FROM users_messages
WHERE recipientId = ? AND is_archived = FALSE
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`, userID, perPage, offset)
if err != nil {
return nil
}
defer rows.Close()
var messages []models.Message
for rows.Next() {
var m models.Message
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 GetInboxMessageCount(db *sql.DB, userID int) int {
var count int
err := db.QueryRow(`
SELECT COUNT(*) FROM users_messages
WHERE recipientId = ? AND is_archived = FALSE
`, userID).Scan(&count)
if err != nil {
return 0
}
return count
}
func RestoreMessage(db *sql.DB, userID, messageID int) error {
_, err := db.Exec(`
UPDATE users_messages
SET is_archived = FALSE, archived_at = NULL
WHERE id = ? AND recipientId = ?
`, messageID, userID)
return err
}

View File

@@ -115,11 +115,13 @@ const SchemaUsersMessages = `
CREATE TABLE IF NOT EXISTS users_messages ( CREATE TABLE IF NOT EXISTS users_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
senderId INTEGER NOT NULL REFERENCES users(id), senderId INTEGER NOT NULL REFERENCES users(id),
recipientId int, recipientId INTEGER NOT NULL REFERENCES users(id),
subject TEXT NOT NULL, subject TEXT NOT NULL,
message TEXT, message TEXT,
is_read BOOLEAN DEFAULT FALSE, is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP is_archived BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
archived_at TIMESTAMP
);` );`
const SchemaUsersNotifications = ` const SchemaUsersNotifications = `

View File

@@ -0,0 +1,44 @@
{{ define "content" }}
<div class="container py-4">
<h2>Archived Messages</h2>
{{ if .Messages }}
{{ range .Messages }}
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">{{ .Subject }}</h5>
<p class="card-text">{{ .Message }}</p>
<p class="card-text">
<small class="text-muted">Archived: {{ .ArchivedAt.Format "02 Jan 2006 15:04" }}</small>
</p>
</div>
</div>
<form method="POST" action="/account/messages/restore" class="m-0">
{{ $.CSRFField }}
<input type="hidden" name="id" value="{{ .ID }}">
<button type="submit" class="btn btn-sm btn-outline-success">Restore</button>
</form>
{{ end }}
<!-- Pagination Controls -->
<nav>
<ul class="pagination">
{{ if gt .Page 1 }}
<li class="page-item">
<a class="page-link" href="?page={{ minus1 .Page }}">Previous</a>
</li>
{{ end }}
{{ if .HasMore }}
<li class="page-item">
<a class="page-link" href="?page={{ plus1 .Page }}">Next</a>
</li>
{{ end }}
</ul>
</nav>
{{ else }}
<div class="alert alert-info text-center">No archived messages.</div>
{{ end }}
<a href="/account/messages" class="btn btn-secondary mt-3">Back to Inbox</a>
</div>
{{ end }}

View File

@@ -1,22 +1,54 @@
{{ define "content" }} {{ define "content" }}
<div class="container py-4"> <div class="container py-5">
<h2>Your Messages</h2> <h2>Your Inbox</h2>
<ul class="list-group mt-3"> {{ if .Messages }}
<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">
<div> <div>
<a href="/account/messages/read?id={{ .ID }}" class="{{ if not .IsRead }}fw-bold{{ end }}"> <a href="/account/messages/read?id={{ .ID }}" class="fw-bold text-dark">{{ .Subject }}</a>
{{ .Subject }} <br>
</a> <small class="text-muted">{{ .CreatedAt.Format "02 Jan 2006 15:04" }}</small>
<div class="small text-muted">{{ .CreatedAt.Format "02 Jan 2006 15:04" }}</div>
</div> </div>
{{ if not .IsRead }} <form method="POST" action="/account/messages/archive?id={{ .ID }}" class="m-0">
<span class="badge bg-primary">Unread</span> {{ $.CSRFField }}
{{ end }} <input type="hidden" name="id" value="{{ .ID }}">
<button type="submit" class="btn btn-sm btn-outline-secondary">Archive</button>
</form>
</li> </li>
{{ else }}
<li class="list-group-item text-muted text-center">No messages</li>
{{ end }} {{ end }}
</ul> </ul>
<!-- Pagination -->
<nav>
<ul class="pagination">
{{ if gt .CurrentPage 1 }}
<li class="page-item">
<a class="page-link" href="?page={{ sub .CurrentPage 1 }}">Previous</a>
</li>
{{ end }}
{{ range $i := .PageRange }}
<li class="page-item {{ if eq $i $.CurrentPage }}active{{ end }}">
<a class="page-link" href="?page={{ $i }}">{{ $i }}</a>
</li>
{{ end }}
{{ if lt .CurrentPage .TotalPages }}
<li class="page-item">
<a class="page-link" href="?page={{ add .CurrentPage 1 }}">Next</a>
</li>
{{ end }}
</ul>
</nav>
{{ else }}
<div class="alert alert-info">No messages found.</div>
{{ end }}
<div class="mt-3">
<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>
</div>
</div> </div>
{{ end }} {{ end }}

View File

@@ -5,7 +5,7 @@
<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.Message }}</p>
<a href="/account/messages" class="btn btn-secondary mt-4">Back to Inbox</a> <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>
{{ 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.

View File

@@ -0,0 +1,27 @@
{{ define "content" }}
<div class="container py-5">
<h2>Send a Message</h2>
{{ if .Flash }}
<div class="alert alert-info">{{ .Flash }}</div>
{{ end }}
<form method="POST" action="/account/messages/send">
{{ .CSRFField }}
<div class="mb-3">
<label for="recipient_id" class="form-label">Recipient User ID</label>
<input type="number" class="form-control" name="recipient_id" required>
</div>
<div class="mb-3">
<label for="subject" class="form-label">Subject</label>
<input type="text" class="form-control" name="subject" required>
</div>
<div class="mb-3">
<label for="message" class="form-label">Message</label>
<textarea class="form-control" name="message" rows="5" required></textarea>
</div>
<button type="submit" class="btn btn-primary">Send</button>
</form>
<a href="/account/messages" class="btn btn-secondary mt-3">Back to Inbox</a>
</div>
{{ end }}

View File

@@ -100,11 +100,12 @@
</a> </a>
</li> </li>
{{ end }} {{ end }}
<li><hr class="dropdown-divider"></li>
<li class="text-center"><a href="/account/messages" class="dropdown-item">View all messages</a></li>
{{ 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 class="text-center"><a href="/account/messages" class="dropdown-item">View all messages</a></li>
</ul> </ul>
</div> </div>