7d3140334e
- 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>
361 lines
11 KiB
Go
361 lines
11 KiB
Go
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] + "…"
|
|
}
|