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 {
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)
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")
err := tmpl.ExecuteTemplate(w, "layout", context)
if err != nil {
if err := tmpl.ExecuteTemplate(w, "layout", context); err != nil {
helpers.RenderError(w, r, 500)
}
}
@@ -52,3 +76,107 @@ func ReadMessageHandler(db *sql.DB) http.HandlerFunc {
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
}
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 },
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"min": func(a, b int) int {
if a < b {
return a
@@ -66,6 +67,7 @@ func TemplateFuncs() template.FuncMap {
}
return s[:max] + "..."
},
"PageRange": PageRange,
}
}
@@ -115,3 +117,11 @@ func rangeClass(n int) string {
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/messages", middleware.Auth(true)(handlers.MessagesInboxHandler(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/read", middleware.Auth(true)(handlers.MarkNotificationReadHandler(db)))
}

View File

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

View File

@@ -10,7 +10,7 @@ func GetMessageCount(db *sql.DB, userID int) (int, error) {
var count int
err := db.QueryRow(`
SELECT COUNT(*) FROM users_messages
WHERE recipientId = ? AND is_read = FALSE
WHERE recipientId = ? AND is_read = FALSE AND is_archived = FALSE
`, userID).Scan(&count)
return count, err
}
@@ -19,7 +19,7 @@ func GetRecentMessages(db *sql.DB, userID int, limit int) []models.Message {
rows, err := db.Query(`
SELECT id, senderId, recipientId, subject, message, is_read, created_at
FROM users_messages
WHERE recipientId = ?
WHERE recipientId = ? AND is_archived = FALSE
ORDER BY created_at DESC
LIMIT ?
`, userID, limit)
@@ -81,3 +81,98 @@ func MarkMessageAsRead(db *sql.DB, messageID, userID int) error {
}
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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
senderId INTEGER NOT NULL REFERENCES users(id),
recipientId int,
recipientId INTEGER NOT NULL REFERENCES users(id),
subject TEXT NOT NULL,
message TEXT,
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 = `

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" }}
<div class="container py-4">
<h2>Your Messages</h2>
<ul class="list-group mt-3">
<div class="container py-5">
<h2>Your Inbox</h2>
{{ if .Messages }}
<ul class="list-group mb-4">
{{ 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>
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<a href="/account/messages/read?id={{ .ID }}" class="fw-bold text-dark">{{ .Subject }}</a>
<br>
<small class="text-muted">{{ .CreatedAt.Format "02 Jan 2006 15:04" }}</small>
</div>
<form method="POST" action="/account/messages/archive?id={{ .ID }}" class="m-0">
{{ $.CSRFField }}
<input type="hidden" name="id" value="{{ .ID }}">
<button type="submit" class="btn btn-sm btn-outline-secondary">Archive</button>
</form>
</li>
{{ end }}
</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>
{{ end }}

View File

@@ -5,7 +5,7 @@
<p class="text-muted">Received: {{ .Message.CreatedAt.Format "02 Jan 2006 15:04" }}</p>
<hr>
<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 }}
<div class="alert alert-danger text-center">
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

@@ -59,7 +59,7 @@
<li><hr class="dropdown-divider"></li>
{{ end }}
{{ end }}
{{ if not .Notifications }}
<li class="text-center text-muted py-2">No notifications</li>
{{ end }}
@@ -100,11 +100,12 @@
</a>
</li>
{{ end }}
<li><hr class="dropdown-divider"></li>
<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 }}
<li><hr class="dropdown-divider"></li>
<li class="text-center"><a href="/account/messages" class="dropdown-item">View all messages</a></li>
</ul>
</div>