b232b4b3d3
- Mentions now insert a span carrying the user id (handles names with spaces): the sanitizer permits <span class="mention" data-user-card="ULID">, comment and chat notifications resolve by id and only fire for users with access, and mentions render as clickable chips with the shared hover user-card. - Messages composer inserts an atomic, non-editable mention chip so the trailing space no longer collapses while typing. - Links inside chat messages and task comments are underlined/accented so they read as clickable. - Atomization: hold Ctrl/Cmd while dragging an effort slider to rebalance the sibling coefficients proportionally (sum stays ~1.00); added a hint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
567 lines
17 KiB
Go
567 lines
17 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("GET /api/v1/conversations/{id}/members", s.requireAuth(http.HandlerFunc(s.handleListMembers)))
|
|
mux.Handle("POST /api/v1/conversations/{id}/members", s.authed(s.handleAddMember))
|
|
mux.Handle("DELETE /api/v1/conversations/{id}/members/{userId}", s.authed(s.handleRemoveMember))
|
|
mux.Handle("POST /api/v1/files", s.authed(s.handleUploadFile))
|
|
mux.Handle("GET /api/v1/users", s.requireAuth(http.HandlerFunc(s.handleSearchUsers)))
|
|
mux.Handle("GET /api/v1/messages/search", s.requireAuth(http.HandlerFunc(s.handleSearchMessages)))
|
|
}
|
|
|
|
// 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,
|
|
"creatorId": c.CreatorID,
|
|
"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),
|
|
CreatorID: me.ID,
|
|
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)
|
|
}
|
|
// Notify mentioned users who are participants (i.e. have access §11.1).
|
|
plain := chat.SanitizePlain(body)
|
|
participants := map[string]bool{}
|
|
for _, pid := range conv.ParticipantIDs {
|
|
participants[pid] = true
|
|
}
|
|
notified := map[string]bool{}
|
|
for _, m := range mentionUIDRe.FindAllStringSubmatch(body, 20) {
|
|
uid := m[1]
|
|
if uid == me.ID || notified[uid] || !participants[uid] {
|
|
continue
|
|
}
|
|
notified[uid] = true
|
|
s.notifyUser(r.Context(), uid, "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})
|
|
}
|
|
|
|
// groupOwner loads a group/project conversation and authorizes the creator.
|
|
func (s *Server) groupOwner(w http.ResponseWriter, r *http.Request, id string) (*store.Conversation, bool) {
|
|
conv, ok := s.conversation(w, r, id)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
if conv.Kind == "dm" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "direct messages have no managed members")
|
|
return nil, false
|
|
}
|
|
if conv.CreatorID != CurrentUser(r.Context()).ID {
|
|
writeError(w, http.StatusForbidden, "forbidden", "only the group creator can manage members")
|
|
return nil, false
|
|
}
|
|
return conv, true
|
|
}
|
|
|
|
// handleListMembers returns the participants; any member may view.
|
|
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
|
conv, ok := s.conversation(w, r, r.PathValue("id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
me := CurrentUser(r.Context())
|
|
out := make([]map[string]any, 0, len(conv.ParticipantIDs))
|
|
for _, id := range conv.ParticipantIDs {
|
|
u, err := s.store.UserByID(r.Context(), id)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out = append(out, map[string]any{
|
|
"id": u.ID, "name": u.Name, "email": u.Email,
|
|
"avatarFileId": u.AvatarFileID, "isCreator": id == conv.CreatorID,
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"members": out,
|
|
"creatorId": conv.CreatorID,
|
|
"canManage": conv.Kind != "dm" && conv.CreatorID == me.ID,
|
|
})
|
|
}
|
|
|
|
// handleAddMember adds a participant to a group (creator only).
|
|
func (s *Server) handleAddMember(w http.ResponseWriter, r *http.Request) {
|
|
conv, ok := s.groupOwner(w, r, r.PathValue("id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
UserID string `json:"userId"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if req.UserID == "" {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "userId is required")
|
|
return
|
|
}
|
|
if _, err := s.store.UserByID(r.Context(), req.UserID); err != nil {
|
|
writeError(w, http.StatusBadRequest, "bad_user", "unknown user")
|
|
return
|
|
}
|
|
if err := s.store.AddParticipant(r.Context(), conv.ID, req.UserID); err != nil {
|
|
s.internalError(w, r, "add member", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
// handleRemoveMember removes a participant from a group (creator only).
|
|
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
|
conv, ok := s.groupOwner(w, r, r.PathValue("id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
target := r.PathValue("userId")
|
|
if target == conv.CreatorID {
|
|
writeError(w, http.StatusBadRequest, "bad_request", "the creator cannot be removed")
|
|
return
|
|
}
|
|
if err := s.store.RemoveParticipant(r.Context(), conv.ID, target); err != nil {
|
|
s.internalError(w, r, "remove member", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
}
|
|
|
|
// 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})
|
|
}
|
|
|
|
// handleSearchMessages finds messages containing the query within the caller's
|
|
// own conversations, newest first.
|
|
func (s *Server) handleSearchMessages(w http.ResponseWriter, r *http.Request) {
|
|
me := CurrentUser(r.Context())
|
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
if len([]rune(q)) < 2 {
|
|
writeJSON(w, http.StatusOK, map[string]any{"results": []any{}})
|
|
return
|
|
}
|
|
convs, err := s.store.ListConversations(r.Context(), me.ID)
|
|
if err != nil {
|
|
s.internalError(w, r, "search: conversations", err)
|
|
return
|
|
}
|
|
if len(convs) == 0 {
|
|
writeJSON(w, http.StatusOK, map[string]any{"results": []any{}})
|
|
return
|
|
}
|
|
ids := make([]string, 0, len(convs))
|
|
titles := make(map[string]string, len(convs))
|
|
for _, c := range convs {
|
|
ids = append(ids, c.ID)
|
|
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"
|
|
}
|
|
titles[c.ID] = title
|
|
}
|
|
cur, err := s.store.DB.Collection("messages").Find(r.Context(), bson.M{
|
|
"conversationId": bson.M{"$in": ids},
|
|
"deletedAt": nil,
|
|
"body": bson.M{"$regex": regexEscape(q), "$options": "i"},
|
|
}, options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(30))
|
|
if err != nil {
|
|
s.internalError(w, r, "search messages", err)
|
|
return
|
|
}
|
|
var msgs []store.Message
|
|
if err := cur.All(r.Context(), &msgs); err != nil {
|
|
s.internalError(w, r, "decode search", err)
|
|
return
|
|
}
|
|
results := make([]map[string]any, 0, len(msgs))
|
|
for _, m := range msgs {
|
|
results = append(results, map[string]any{
|
|
"conversationId": m.ConversationID,
|
|
"conversationTitle": titles[m.ConversationID],
|
|
"messageId": m.ID,
|
|
"senderId": m.SenderID,
|
|
"snippet": snippetAround(stripTags(m.Body), q),
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"results": results})
|
|
}
|
|
|
|
// stripTags removes HTML tags and collapses whitespace for plain-text snippets.
|
|
func stripTags(html string) string {
|
|
var b strings.Builder
|
|
inTag := false
|
|
for _, r := range html {
|
|
switch {
|
|
case r == '<':
|
|
inTag = true
|
|
case r == '>':
|
|
inTag = false
|
|
case !inTag:
|
|
b.WriteRune(r)
|
|
}
|
|
}
|
|
return strings.Join(strings.Fields(b.String()), " ")
|
|
}
|
|
|
|
// snippetAround returns a short text window centered on the first
|
|
// case-insensitive match of q.
|
|
func snippetAround(text, q string) string {
|
|
runes := []rune(text)
|
|
idx := strings.Index(strings.ToLower(text), strings.ToLower(q))
|
|
if idx < 0 {
|
|
if len(runes) > 100 {
|
|
return string(runes[:100]) + "…"
|
|
}
|
|
return text
|
|
}
|
|
start := len([]rune(text[:idx]))
|
|
from := start - 30
|
|
if from < 0 {
|
|
from = 0
|
|
}
|
|
to := start + len([]rune(q)) + 50
|
|
if to > len(runes) {
|
|
to = len(runes)
|
|
}
|
|
out := string(runes[from:to])
|
|
if from > 0 {
|
|
out = "…" + out
|
|
}
|
|
if to < len(runes) {
|
|
out += "…"
|
|
}
|
|
return 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] + "…"
|
|
}
|