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] + "…" }