feat: messaging with conversations, rich text, attachments, typing, unread (phase 9)
- conversations: dm (unique pair, find-or-create), group (titled), project (customer-bound); participant-only access - messages: server-sanitized rich text, attachments referencing uploads from the generic POST /api/v1/files endpoint (rate limited, MIME sniffed) - unread counts via aggregation; mark-read pushes readBy receipts - chat files served only to conversation participants (or uploader) - @mentions in chat notify the mentioned participant - typing indicator: client WS event relayed to other participants - WS targeted delivery (SendTo) + 15s polling fallback - two-pane messages UI: conversation list with unread badges, composer (contenteditable + formatting buttons), drag & drop upload, inline image previews with lightbox, new-conversation dialog with user search Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"bountyboard/internal/chat"
|
||||
"bountyboard/internal/domain"
|
||||
"bountyboard/internal/files"
|
||||
"bountyboard/internal/store"
|
||||
"bountyboard/internal/ws"
|
||||
)
|
||||
|
||||
func (s *Server) routesMessages(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/conversations", s.requireAuth(http.HandlerFunc(s.handleListConversations)))
|
||||
mux.Handle("POST /api/v1/conversations", s.authed(s.handleCreateConversation))
|
||||
mux.Handle("GET /api/v1/conversations/{id}/messages", s.requireAuth(http.HandlerFunc(s.handleListMessages)))
|
||||
mux.Handle("POST /api/v1/conversations/{id}/messages", s.authed(s.handleSendMessage))
|
||||
mux.Handle("POST /api/v1/conversations/{id}/read", s.authed(s.handleMarkRead))
|
||||
mux.Handle("POST /api/v1/files", s.authed(s.handleUploadFile))
|
||||
mux.Handle("GET /api/v1/users", s.requireAuth(http.HandlerFunc(s.handleSearchUsers)))
|
||||
}
|
||||
|
||||
// conversation loads + authorizes participant access.
|
||||
func (s *Server) conversation(w http.ResponseWriter, r *http.Request, id string) (*store.Conversation, bool) {
|
||||
conv, err := s.store.ConversationByID(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "conversation not found")
|
||||
} else {
|
||||
s.internalError(w, r, "load conversation", err)
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
if !conv.HasParticipant(CurrentUser(r.Context()).ID) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "you are not in this conversation")
|
||||
return nil, false
|
||||
}
|
||||
return conv, true
|
||||
}
|
||||
|
||||
func (s *Server) handleListConversations(w http.ResponseWriter, r *http.Request) {
|
||||
me := CurrentUser(r.Context())
|
||||
convs, err := s.store.ListConversations(r.Context(), me.ID)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "list conversations", err)
|
||||
return
|
||||
}
|
||||
ids := make([]string, len(convs))
|
||||
for i, c := range convs {
|
||||
ids[i] = c.ID
|
||||
}
|
||||
unread, err := s.store.UnreadCounts(r.Context(), me.ID, ids)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "unread counts", err)
|
||||
return
|
||||
}
|
||||
// resolve display names for the list
|
||||
out := make([]map[string]any, len(convs))
|
||||
for i, c := range convs {
|
||||
title := c.Title
|
||||
if c.Kind == "dm" {
|
||||
for _, pid := range c.ParticipantIDs {
|
||||
if pid != me.ID {
|
||||
if u, err := s.store.UserByID(r.Context(), pid); err == nil {
|
||||
title = u.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if title == "" {
|
||||
title = "Conversation"
|
||||
}
|
||||
out[i] = map[string]any{
|
||||
"id": c.ID, "kind": c.Kind, "title": title,
|
||||
"customerId": c.CustomerID, "participantIds": c.ParticipantIDs,
|
||||
"lastMessageAt": c.LastMessageAt, "unread": unread[c.ID],
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"conversations": out})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateConversation(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
CustomerID string `json:"customerId"`
|
||||
ParticipantIDs []string `json:"participantIds"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
me := CurrentUser(r.Context())
|
||||
|
||||
// validate participants exist
|
||||
seen := map[string]bool{me.ID: true}
|
||||
participants := []string{me.ID}
|
||||
for _, id := range req.ParticipantIDs {
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
if _, err := s.store.UserByID(r.Context(), id); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_participant", "unknown user "+id)
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
participants = append(participants, id)
|
||||
}
|
||||
|
||||
switch req.Kind {
|
||||
case "dm":
|
||||
if len(participants) != 2 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "dm needs exactly one other participant")
|
||||
return
|
||||
}
|
||||
conv, err := s.store.FindOrCreateDM(r.Context(), participants[0], participants[1])
|
||||
if err != nil {
|
||||
s.internalError(w, r, "create dm", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"conversation": conv})
|
||||
return
|
||||
case "group", "project":
|
||||
if len(participants) < 2 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "need at least one other participant")
|
||||
return
|
||||
}
|
||||
conv := &store.Conversation{
|
||||
Kind: req.Kind,
|
||||
Title: strings.TrimSpace(req.Title),
|
||||
ParticipantIDs: participants,
|
||||
}
|
||||
if req.Kind == "project" {
|
||||
if req.CustomerID == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "project conversations need customerId")
|
||||
return
|
||||
}
|
||||
if _, err := s.store.CustomerByID(r.Context(), req.CustomerID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_customer", "unknown customer")
|
||||
return
|
||||
}
|
||||
conv.CustomerID = req.CustomerID
|
||||
}
|
||||
if conv.Title == "" && req.Kind == "group" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "group conversations need a title")
|
||||
return
|
||||
}
|
||||
if err := s.store.CreateConversation(r.Context(), conv); err != nil {
|
||||
s.internalError(w, r, "create conversation", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"conversation": conv})
|
||||
return
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "bad_kind", "kind must be dm, group or project")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) {
|
||||
conv, ok := s.conversation(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
msgs, err := s.store.ListMessages(r.Context(), conv.ID, q.Get("cursor"),
|
||||
atoiDefault(q.Get("limit"), 50))
|
||||
if err != nil {
|
||||
s.internalError(w, r, "list messages", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"messages": msgs})
|
||||
}
|
||||
|
||||
func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Body string `json:"body"`
|
||||
Attachments []string `json:"attachments"` // fileIds from POST /api/v1/files
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
conv, ok := s.conversation(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
me := CurrentUser(r.Context())
|
||||
body := chat.SanitizeHTML(req.Body)
|
||||
if strings.TrimSpace(chat.SanitizePlain(body)) == "" && len(req.Attachments) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "empty_message", "message needs text or attachments")
|
||||
return
|
||||
}
|
||||
|
||||
atts := []store.MessageAttachment{}
|
||||
for _, fid := range req.Attachments {
|
||||
rc, meta, err := s.files.Open(r.Context(), fid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_attachment", "unknown file "+fid)
|
||||
return
|
||||
}
|
||||
rc.Close()
|
||||
if meta.OwnerID != me.ID {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "attachment was uploaded by someone else")
|
||||
return
|
||||
}
|
||||
atts = append(atts, store.MessageAttachment{
|
||||
FileID: meta.ID, Name: meta.Name, MimeType: meta.MimeType,
|
||||
Size: meta.Size, IsImage: strings.HasPrefix(meta.MimeType, "image/"),
|
||||
})
|
||||
}
|
||||
|
||||
msg := &store.Message{ConversationID: conv.ID, SenderID: me.ID, Body: body, Attachments: atts}
|
||||
if err := s.store.InsertMessage(r.Context(), msg); err != nil {
|
||||
s.internalError(w, r, "insert message", err)
|
||||
return
|
||||
}
|
||||
s.metrics.Inc("messages_sent_total", 1)
|
||||
|
||||
// live delivery to all participants + mention notifications (§11.1)
|
||||
if s.sendTo != nil {
|
||||
s.sendTo(conv.ParticipantIDs, "chat", "message", msg)
|
||||
}
|
||||
plain := chat.SanitizePlain(body)
|
||||
for _, pid := range conv.ParticipantIDs {
|
||||
if pid == me.ID {
|
||||
continue
|
||||
}
|
||||
if u, err := s.store.UserByID(r.Context(), pid); err == nil {
|
||||
first := strings.ToLower(strings.SplitN(u.Name, " ", 2)[0])
|
||||
lower := strings.ToLower(plain)
|
||||
if strings.Contains(lower, "@"+strings.ToLower(u.Email)) || strings.Contains(lower, "@"+first) {
|
||||
s.notifyUser(r.Context(), pid, "mention",
|
||||
me.Name+" mentioned you in a chat", truncateStr(plain, 120), "/messages?c="+conv.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"message": msg})
|
||||
}
|
||||
|
||||
func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) {
|
||||
conv, ok := s.conversation(w, r, r.PathValue("id"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
n, err := s.store.MarkConversationRead(r.Context(), conv.ID, CurrentUser(r.Context()).ID)
|
||||
if err != nil {
|
||||
s.internalError(w, r, "mark read", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"marked": n})
|
||||
}
|
||||
|
||||
// handleUploadFile is the generic upload endpoint (POST /files, §6) used by
|
||||
// chat; scope=chat unless the caller passes scope=task (consultants).
|
||||
func (s *Server) handleUploadFile(w http.ResponseWriter, r *http.Request) {
|
||||
me := CurrentUser(r.Context())
|
||||
if !s.loginLimiter.Allow("upload|" + ClientIP(r.Context()).String()) {
|
||||
writeError(w, http.StatusTooManyRequests, "rate_limited", "too many uploads, slow down")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, int64(s.cfg.MaxUploadMB)<<20+1<<20)
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "multipart field 'file' is required")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
scope := files.ScopeChat
|
||||
if r.FormValue("scope") == files.ScopeTask && (me.Roles.Consultant || me.Roles.Admin) {
|
||||
scope = files.ScopeTask
|
||||
}
|
||||
meta, err := s.files.Save(r.Context(), scope, me.ID, header.Filename,
|
||||
header.Header.Get("Content-Type"), file)
|
||||
if err != nil {
|
||||
if errors.Is(err, files.ErrTooLarge) {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "too_large", err.Error())
|
||||
return
|
||||
}
|
||||
s.internalError(w, r, "save upload", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"fileId": meta.ID, "name": meta.Name, "mimeType": meta.MimeType,
|
||||
"size": meta.Size, "isImage": strings.HasPrefix(meta.MimeType, "image/"),
|
||||
})
|
||||
}
|
||||
|
||||
// handleSearchUsers is the small directory for picking conversation
|
||||
// participants (§11.10).
|
||||
func (s *Server) handleSearchUsers(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
filter := bson.M{"disabled": false}
|
||||
if q != "" {
|
||||
filter["$or"] = []bson.M{
|
||||
{"email": bson.M{"$regex": regexEscape(q), "$options": "i"}},
|
||||
{"name": bson.M{"$regex": regexEscape(q), "$options": "i"}},
|
||||
}
|
||||
}
|
||||
cur, err := s.store.DB.Collection("users").Find(r.Context(), filter,
|
||||
options.Find().SetLimit(20).SetSort(bson.D{{Key: "name", Value: 1}}))
|
||||
if err != nil {
|
||||
s.internalError(w, r, "search users", err)
|
||||
return
|
||||
}
|
||||
var users []domain.User
|
||||
if err := cur.All(r.Context(), &users); err != nil {
|
||||
s.internalError(w, r, "decode users", err)
|
||||
return
|
||||
}
|
||||
out := make([]map[string]any, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, map[string]any{
|
||||
"id": u.ID, "name": u.Name, "email": u.Email, "avatarFileId": u.AvatarFileID,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": out})
|
||||
}
|
||||
|
||||
// HandleInboundWS relays ephemeral client events (typing indicator).
|
||||
func (s *Server) HandleInboundWS(userID string, msg ws.Message) {
|
||||
if msg.Channel != "chat" || msg.Event != "typing" {
|
||||
return
|
||||
}
|
||||
var data struct {
|
||||
ConversationID string `json:"conversationId"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Data, &data); err != nil || data.ConversationID == "" {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
conv, err := s.store.ConversationByID(ctx, data.ConversationID)
|
||||
if err != nil || !conv.HasParticipant(userID) {
|
||||
return
|
||||
}
|
||||
others := []string{}
|
||||
for _, pid := range conv.ParticipantIDs {
|
||||
if pid != userID {
|
||||
others = append(others, pid)
|
||||
}
|
||||
}
|
||||
if s.sendTo != nil {
|
||||
s.sendTo(others, "chat", "typing", map[string]string{
|
||||
"conversationId": conv.ID, "userId": userID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func truncateStr(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user