Files
hobokenchicken cc6ed741f0 feat(phase7): per-channel notification settings and read receipts
Phase 7.1 — Per-channel notification settings
- New notification_settings table (user_id, channel_id, level)
- GET/PUT/DELETE handlers under /channels/{channelID}/notifications
- Push dispatch and @mention loops filtered by notification level
- Frontend: bell icon per channel cycling all/mentions/none

Phase 7.4 — Read receipts
- New read_states table (user_id, channel_id, last_read_message_id)
- PUT /channels/{channelID}/read and GET /users/me/read-states
- Auto-mark-read when messages load in ChatArea
- Unread dot for channels never opened

Also: favicon/icon refresh in index.html
2026-06-30 12:39:14 -04:00

95 lines
2.5 KiB
Go

package readstate
import (
"database/sql"
"encoding/json"
"log/slog"
"net/http"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
"github.com/go-chi/chi/v5"
)
type Handler struct {
db *sql.DB
logger *slog.Logger
}
func NewHandler(db *sql.DB, logger *slog.Logger) *Handler {
return &Handler{db: db, logger: logger}
}
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Put("/{channelID}/read", h.MarkRead)
r.Get("/users/me/read-states", h.GetAll)
}
type markReadRequest struct {
LastReadMessageID string `json:"last_read_message_id"`
}
func (h *Handler) MarkRead(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.UserIDFromContext(r.Context())
channelID := chi.URLParam(r, "channelID")
var req markReadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest)
return
}
if req.LastReadMessageID == "" {
http.Error(w, `{"error":"last_read_message_id is required"}`, http.StatusBadRequest)
return
}
_, err := h.db.Exec(`
INSERT INTO read_states (user_id, channel_id, last_read_message_id, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (user_id, channel_id) DO UPDATE SET
last_read_message_id = $3,
updated_at = NOW()
`, userID, channelID, req.LastReadMessageID)
if err != nil {
h.logger.Error("failed to mark read", "error", err)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// ChannelReadState is returned per channel
type ChannelReadState struct {
ChannelID string `json:"channel_id"`
LastReadMessageID string `json:"last_read_message_id"`
}
func (h *Handler) GetAll(w http.ResponseWriter, r *http.Request) {
userID, _ := middleware.UserIDFromContext(r.Context())
rows, err := h.db.Query(`
SELECT rs.channel_id, rs.last_read_message_id
FROM read_states rs
WHERE rs.user_id = $1
`, userID)
if err != nil {
h.logger.Error("failed to query read states", "error", err)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
defer rows.Close()
states := make(map[string]string)
for rows.Next() {
var channelID, lastReadMessageID string
if err := rows.Scan(&channelID, &lastReadMessageID); err != nil {
continue
}
states[channelID] = lastReadMessageID
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(states)
}