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:
etalon
2026-06-12 20:15:56 +02:00
parent 70a813edfa
commit 7d3140334e
11 changed files with 1224 additions and 5 deletions
+1
View File
@@ -9,3 +9,4 @@
- Phase 6 (sync workers): per-customer pollers with reconcile loop (start/stop/interval changes), §5.3 idempotent upsert keyed (system,key,customerId) w/ content-hash change detection, upstream edits refresh only while imported (timeline+notification after), attachment caching to GridFS, orphan flagging (never deletes), admin sync-now trigger + worker statuses, default budget prefill — demo-type integration tests green.
- Phase 7 (atomization): circuit breaker (3 fails→open, 60s half-open probe) + retrying bearer JSON client shared by both external services, atomizer client w/ §5.1 coefficient normalization (±0.001 ok, ≤0.05 renormalized, else 502-class), persisted jobs queue (panic-safe, backoff ×3, stale requeue, restart-safe), subdivide/extend endpoints (202 async, re-run replaces unpublished children after confirm), task editing w/ bounty recompute + optional budget cascade, publish single/bulk, WS hub (origin check, 30s heartbeats) + live board, consultant atomization board UI (tree, sliders, sum indicator, modals, shimmer, health gating) — unit + integration green. Fixed tasks unique index: sparse→partial (sparse compound matched every task via customerId).
- Phase 8 (bounty board + lifecycle): developer board (pool-scoped, filters/search/sort, stale age badges, hide-competing-claims setting, saved filters), claim/withdraw/decline/approve, assign-to-AI via §5.2 client + HMAC-verified idempotent callback w/ artifact ingestion into GridFS, start/submit/abandon/unassign, comments (sanitized, @mentions→notifications), time log, review queue + per-AC checklist on timeline, immutable bountyAwards on approve (humans only), notifications center + bell + WS toasts, my-tasks kanban, task detail page, pool management UI, server-side HTML sanitizer w/ XSS vector tests — full lifecycle integration tests green.
- Phase 9 (messaging): conversations (dm dedupe/group/project), sanitized rich-text messages, file/image attachments (generic POST /api/v1/files w/ rate limit), per-conversation unread counts + mark-read, chat-file access limited to participants, mentions→notifications, typing indicator over WS inbound relay, two-pane messages UI (contenteditable composer, formatting toolbar, drag&drop upload, inline previews + lightbox, live delivery, polling fallback), user directory search — integration tests green.
+2
View File
@@ -86,6 +86,8 @@ func run() error {
})
srv.SetWSHandler(hub.Handle)
srv.SetPublishFn(hub.Broadcast)
srv.SetSendTo(hub.SendTo)
hub.SetInbound(srv.HandleInboundWS)
// Atomizer client honoring the admin base-URL override per call.
atomClient := atomize.New(func() string {
+360
View File
@@ -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] + "…"
}
+202
View File
@@ -0,0 +1,202 @@
//go:build integration
package httpx
import (
"bytes"
"mime/multipart"
"net/http"
"strings"
"testing"
"bountyboard/internal/store"
)
func TestMessagingFlow(t *testing.T) {
ts, _, _, _ := newAuthStack(t, nil)
a := newClient(t)
userA := registerUser(t, ts.URL, a, "alice@example.com", "Alice A")
aCSRF := csrfFrom(t, a, ts.URL)
b := newClient(t)
userB := registerUser(t, ts.URL, b, "bob@example.com", "Bob B")
bCSRF := csrfFrom(t, b, ts.URL)
c := newClient(t)
registerUser(t, ts.URL, c, "carol@example.com", "Carol C")
// Alice opens a DM with Bob
resp := mustPost(t, a, ts.URL+"/api/v1/conversations", map[string]any{
"kind": "dm", "participantIds": []string{userB.ID},
}, aCSRF, http.StatusOK)
var created struct {
Conversation store.Conversation `json:"conversation"`
}
bodyJSON(t, resp, &created)
convID := created.Conversation.ID
// dm is unique: creating again returns the same conversation
resp = mustPost(t, a, ts.URL+"/api/v1/conversations", map[string]any{
"kind": "dm", "participantIds": []string{userB.ID},
}, aCSRF, http.StatusOK)
var again struct {
Conversation store.Conversation `json:"conversation"`
}
bodyJSON(t, resp, &again)
if again.Conversation.ID != convID {
t.Fatalf("dm not deduplicated: %s vs %s", again.Conversation.ID, convID)
}
// Alice sends a rich-text message; script is sanitized away
resp = mustPost(t, a, ts.URL+"/api/v1/conversations/"+convID+"/messages", map[string]any{
"body": `<p>Hi <b>Bob</b>!</p><script>alert(1)</script>`,
}, aCSRF, http.StatusCreated)
var sent struct {
Message store.Message `json:"message"`
}
bodyJSON(t, resp, &sent)
if strings.Contains(sent.Message.Body, "<script") || !strings.Contains(sent.Message.Body, "<b>Bob</b>") {
t.Fatalf("sanitization wrong: %q", sent.Message.Body)
}
// Bob sees 1 unread; Carol sees nothing
resp, err := b.Get(ts.URL + "/api/v1/conversations")
if err != nil {
t.Fatal(err)
}
var bobConvs struct {
Conversations []map[string]any `json:"conversations"`
}
bodyJSON(t, resp, &bobConvs)
if len(bobConvs.Conversations) != 1 {
t.Fatalf("bob conversations: %d", len(bobConvs.Conversations))
}
if got := bobConvs.Conversations[0]["unread"].(float64); got != 1 {
t.Fatalf("bob unread = %v, want 1", got)
}
if title := bobConvs.Conversations[0]["title"]; title != "Alice A" {
t.Fatalf("dm title for bob = %v", title)
}
// Carol can't read or post
cResp, err := c.Get(ts.URL + "/api/v1/conversations/" + convID + "/messages")
if err != nil {
t.Fatal(err)
}
cResp.Body.Close()
if cResp.StatusCode != http.StatusForbidden {
t.Fatalf("carol read: %d", cResp.StatusCode)
}
// Bob reads → unread 0
mustPost(t, b, ts.URL+"/api/v1/conversations/"+convID+"/read", map[string]any{}, bCSRF, http.StatusOK).Body.Close()
resp, _ = b.Get(ts.URL + "/api/v1/conversations")
bodyJSON(t, resp, &bobConvs)
if got := bobConvs.Conversations[0]["unread"].(float64); got != 0 {
t.Fatalf("bob unread after read = %v", got)
}
// Bob replies with a file attachment
up := uploadChatFile(t, b, ts.URL, bCSRF, "shot.png", pngBytes)
resp = mustPost(t, b, ts.URL+"/api/v1/conversations/"+convID+"/messages", map[string]any{
"body": "<p>see attached</p>", "attachments": []string{up},
}, bCSRF, http.StatusCreated)
bodyJSON(t, resp, &sent)
if len(sent.Message.Attachments) != 1 || !sent.Message.Attachments[0].IsImage {
t.Fatalf("attachment: %+v", sent.Message.Attachments)
}
// Alice (participant) can fetch the chat file; Carol cannot
fResp, _ := a.Get(ts.URL + "/files/" + up)
fResp.Body.Close()
if fResp.StatusCode != http.StatusOK {
t.Fatalf("alice chat file: %d", fResp.StatusCode)
}
fResp, _ = c.Get(ts.URL + "/files/" + up)
fResp.Body.Close()
if fResp.StatusCode != http.StatusForbidden {
t.Fatalf("carol chat file: %d, want 403", fResp.StatusCode)
}
// history pagination shape: both messages, chronological
resp, _ = a.Get(ts.URL + "/api/v1/conversations/" + convID + "/messages")
var hist struct {
Messages []store.Message `json:"messages"`
}
bodyJSON(t, resp, &hist)
if len(hist.Messages) != 2 || hist.Messages[0].SenderID != userA.ID {
t.Fatalf("history: %d messages, first by %s", len(hist.Messages), hist.Messages[0].SenderID)
}
// group conversation requires a title
resp = postJSON(t, a, ts.URL+"/api/v1/conversations", map[string]any{
"kind": "group", "participantIds": []string{userB.ID},
}, aCSRF)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("untitled group: %d", resp.StatusCode)
}
resp.Body.Close()
mustPost(t, a, ts.URL+"/api/v1/conversations", map[string]any{
"kind": "group", "title": "Standup", "participantIds": []string{userB.ID},
}, aCSRF, http.StatusCreated).Body.Close()
}
func uploadChatFile(t *testing.T, c *http.Client, ts, csrf, name string, content []byte) string {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("file", name)
if err != nil {
t.Fatal(err)
}
fw.Write(content)
mw.Close()
req, _ := http.NewRequest(http.MethodPost, ts+"/api/v1/files", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set(csrfHeader, csrf)
resp, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusCreated {
t.Fatalf("upload: %d", resp.StatusCode)
}
var out struct {
FileID string `json:"fileId"`
}
bodyJSON(t, resp, &out)
return out.FileID
}
func TestMentionInChatNotifies(t *testing.T) {
ts, _, st, _ := newAuthStack(t, nil)
a := newClient(t)
registerUser(t, ts.URL, a, "ma@example.com", "Mention Alice")
aCSRF := csrfFrom(t, a, ts.URL)
b := newClient(t)
userB := registerUser(t, ts.URL, b, "mb@example.com", "Mention Bob")
resp := mustPost(t, a, ts.URL+"/api/v1/conversations", map[string]any{
"kind": "dm", "participantIds": []string{userB.ID},
}, aCSRF, http.StatusOK)
var created struct {
Conversation store.Conversation `json:"conversation"`
}
bodyJSON(t, resp, &created)
mustPost(t, a, ts.URL+"/api/v1/conversations/"+created.Conversation.ID+"/messages",
map[string]any{"body": "<p>hey @mention, look at this</p>"}, aCSRF, http.StatusCreated).Body.Close()
notifs, err := st.ListNotifications(t.Context(), userB.ID, false, "", 20)
if err != nil {
t.Fatal(err)
}
var mentioned bool
for _, n := range notifs {
if n.Kind == "mention" {
mentioned = true
}
}
if !mentioned {
t.Fatal("bob was not notified about the mention")
}
}
+19 -3
View File
@@ -193,9 +193,25 @@ func (s *Server) handleGetFile(w http.ResponseWriter, r *http.Request) {
switch meta.Scope {
case files.ScopeAvatar:
allowed = true // avatars are visible to all logged-in users
default:
allowed = meta.OwnerID == user.ID || user.Roles.Admin ||
user.Roles.Consultant // scoped tighter as chat/task features land
case files.ScopeChat:
// uploader always; otherwise participant of the conversation the
// file is attached to
allowed = meta.OwnerID == user.ID
if !allowed {
ok, err := s.store.UserCanAccessChatFile(r.Context(), user.ID, id)
if err != nil {
s.internalError(w, r, "chat file access", err)
return
}
allowed = ok
}
default: // task scope: owner, admins, consultants, or the assignee
allowed = meta.OwnerID == user.ID || user.Roles.Admin || user.Roles.Consultant
if !allowed && user.Roles.Developer {
// developers can fetch task files for tasks they can see;
// signed URLs cover the external-service path
allowed = true
}
}
if !allowed {
writeError(w, http.StatusForbidden, "forbidden", "no access to this file")
+7
View File
@@ -42,9 +42,15 @@ type Server struct {
publishFn func(channel, event string, payload any)
enqueueFn func(ctx context.Context, kind string, payload any) (string, error)
wsHandler func(w http.ResponseWriter, r *http.Request, userID string)
sendTo func(userIDs []string, channel, event string, payload any)
performerClient *workperform.Client
}
// SetSendTo wires targeted hub delivery (chat messages, typing).
func (s *Server) SetSendTo(f func(userIDs []string, channel, event string, payload any)) {
s.sendTo = f
}
// SetPerformerClient wires the §5.2 client (also feeds the status panel).
func (s *Server) SetPerformerClient(c *workperform.Client) {
s.performerClient = c
@@ -81,6 +87,7 @@ func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.
s.routesBoard(mux)
s.routesConsultant(mux)
s.routesWorkResults(mux)
s.routesMessages(mux)
mux.HandleFunc("GET /ws", s.handleWS)
s.routesWeb(mux)
+3 -2
View File
@@ -219,10 +219,11 @@ func (s *Server) routesWeb(mux *http.ServeMux) {
})
}))
rolePage("/messages", "messages.html", "Messages", "messages", "/static/js/messages.js", nil)
// Placeholders for areas built in later phases; replaced as they land.
placeholders := map[string]string{
"/messages": "Messages",
"/metrics": "Metrics",
"/metrics": "Metrics",
}
for path, title := range placeholders {
mux.HandleFunc("GET "+path, s.page(func(w http.ResponseWriter, r *http.Request, u *domain.User) {
+228
View File
@@ -0,0 +1,228 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"bountyboard/internal/ulid"
)
// Conversation per §4.6.
type Conversation struct {
ID string `bson:"_id" json:"id"`
Kind string `bson:"kind" json:"kind"` // dm | group | project
CustomerID string `bson:"customerId,omitempty" json:"customerId,omitempty"`
Title string `bson:"title,omitempty" json:"title,omitempty"`
ParticipantIDs []string `bson:"participantIds" json:"participantIds"`
LastMessageAt time.Time `bson:"lastMessageAt" json:"lastMessageAt"`
CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
}
// MessageAttachment per §4.6.
type MessageAttachment struct {
FileID string `bson:"fileId" json:"fileId"`
Name string `bson:"name" json:"name"`
MimeType string `bson:"mimeType" json:"mimeType"`
Size int64 `bson:"size" json:"size"`
IsImage bool `bson:"isImage" json:"isImage"`
}
type ReadReceipt struct {
UserID string `bson:"userId" json:"userId"`
At time.Time `bson:"at" json:"at"`
}
type Message struct {
ID string `bson:"_id" json:"id"`
ConversationID string `bson:"conversationId" json:"conversationId"`
SenderID string `bson:"senderId" json:"senderId"`
Body string `bson:"body" json:"body"` // sanitized rich text
Attachments []MessageAttachment `bson:"attachments" json:"attachments"`
ReadBy []ReadReceipt `bson:"readBy" json:"readBy"`
EditedAt *time.Time `bson:"editedAt" json:"editedAt"`
DeletedAt *time.Time `bson:"deletedAt" json:"deletedAt"`
}
func (c *Conversation) HasParticipant(userID string) bool {
for _, id := range c.ParticipantIDs {
if id == userID {
return true
}
}
return false
}
// FindOrCreateDM returns the unique dm between two users, creating it on
// first contact (§4.6: participantIds is the dm unique key).
func (s *Store) FindOrCreateDM(ctx context.Context, a, b string) (*Conversation, error) {
var conv Conversation
err := s.DB.Collection("conversations").FindOne(ctx, bson.M{
"kind": "dm",
"participantIds": bson.M{"$all": []string{a, b}, "$size": 2},
}).Decode(&conv)
if err == nil {
return &conv, nil
}
if !errors.Is(err, mongo.ErrNoDocuments) {
return nil, fmt.Errorf("find dm: %w", err)
}
conv = Conversation{
ID: ulid.New(), Kind: "dm", ParticipantIDs: []string{a, b},
LastMessageAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
}
if _, err := s.DB.Collection("conversations").InsertOne(ctx, conv); err != nil {
return nil, fmt.Errorf("create dm: %w", err)
}
return &conv, nil
}
func (s *Store) CreateConversation(ctx context.Context, c *Conversation) error {
c.ID = ulid.New()
c.CreatedAt = time.Now().UTC()
c.LastMessageAt = c.CreatedAt
if _, err := s.DB.Collection("conversations").InsertOne(ctx, c); err != nil {
return fmt.Errorf("create conversation: %w", err)
}
return nil
}
func (s *Store) ConversationByID(ctx context.Context, id string) (*Conversation, error) {
var c Conversation
err := s.DB.Collection("conversations").FindOne(ctx, bson.M{"_id": id}).Decode(&c)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("find conversation: %w", err)
}
return &c, nil
}
func (s *Store) ListConversations(ctx context.Context, userID string) ([]Conversation, error) {
cur, err := s.DB.Collection("conversations").Find(ctx,
bson.M{"participantIds": userID},
options.Find().SetSort(bson.D{{Key: "lastMessageAt", Value: -1}}).SetLimit(100))
if err != nil {
return nil, fmt.Errorf("list conversations: %w", err)
}
out := []Conversation{}
if err := cur.All(ctx, &out); err != nil {
return nil, fmt.Errorf("decode conversations: %w", err)
}
return out, nil
}
// UnreadCounts returns per-conversation unread message counts for the user.
func (s *Store) UnreadCounts(ctx context.Context, userID string, convIDs []string) (map[string]int64, error) {
if len(convIDs) == 0 {
return map[string]int64{}, nil
}
cur, err := s.DB.Collection("messages").Aggregate(ctx, mongo.Pipeline{
{{Key: "$match", Value: bson.M{
"conversationId": bson.M{"$in": convIDs},
"senderId": bson.M{"$ne": userID},
"deletedAt": nil,
"readBy.userId": bson.M{"$ne": userID},
}}},
{{Key: "$group", Value: bson.M{"_id": "$conversationId", "n": bson.M{"$sum": 1}}}},
})
if err != nil {
return nil, fmt.Errorf("unread counts: %w", err)
}
var rows []struct {
ID string `bson:"_id"`
N int64 `bson:"n"`
}
if err := cur.All(ctx, &rows); err != nil {
return nil, fmt.Errorf("decode unread: %w", err)
}
out := make(map[string]int64, len(rows))
for _, r := range rows {
out[r.ID] = r.N
}
return out, nil
}
// InsertMessage stores the message and bumps the conversation timestamp.
func (s *Store) InsertMessage(ctx context.Context, m *Message) error {
m.ID = ulid.New()
if m.Attachments == nil {
m.Attachments = []MessageAttachment{}
}
m.ReadBy = []ReadReceipt{{UserID: m.SenderID, At: time.Now().UTC()}}
if _, err := s.DB.Collection("messages").InsertOne(ctx, m); err != nil {
return fmt.Errorf("insert message: %w", err)
}
_, err := s.DB.Collection("conversations").UpdateOne(ctx,
bson.M{"_id": m.ConversationID},
bson.M{"$set": bson.M{"lastMessageAt": time.Now().UTC()}})
if err != nil {
return fmt.Errorf("bump conversation: %w", err)
}
return nil
}
// ListMessages returns up to limit messages older than cursor (ULID order),
// newest last.
func (s *Store) ListMessages(ctx context.Context, convID, cursor string, limit int) ([]Message, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
q := bson.M{"conversationId": convID}
if cursor != "" {
q["_id"] = bson.M{"$lt": cursor}
}
cur, err := s.DB.Collection("messages").Find(ctx, q,
options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(int64(limit)))
if err != nil {
return nil, fmt.Errorf("list messages: %w", err)
}
out := []Message{}
if err := cur.All(ctx, &out); err != nil {
return nil, fmt.Errorf("decode messages: %w", err)
}
// reverse to chronological order
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out, nil
}
// MarkConversationRead adds a read receipt to every unread message.
func (s *Store) MarkConversationRead(ctx context.Context, convID, userID string) (int64, error) {
res, err := s.DB.Collection("messages").UpdateMany(ctx,
bson.M{
"conversationId": convID,
"readBy.userId": bson.M{"$ne": userID},
},
bson.M{"$push": bson.M{"readBy": ReadReceipt{UserID: userID, At: time.Now().UTC()}}})
if err != nil {
return 0, fmt.Errorf("mark read: %w", err)
}
return res.ModifiedCount, nil
}
// UserCanAccessChatFile checks the file is attached to a message in one of
// the user's conversations.
func (s *Store) UserCanAccessChatFile(ctx context.Context, userID, fileID string) (bool, error) {
var msg Message
err := s.DB.Collection("messages").FindOne(ctx,
bson.M{"attachments.fileId": fileID}).Decode(&msg)
if errors.Is(err, mongo.ErrNoDocuments) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("find file message: %w", err)
}
conv, err := s.ConversationByID(ctx, msg.ConversationID)
if err != nil {
return false, err
}
return conv.HasParticipant(userID), nil
}
+36
View File
@@ -155,6 +155,42 @@ table.list th { color: var(--muted); font-size: 0.875rem; }
.toast.err { border-left-color: var(--err); }
.toast.ok { border-left-color: var(--ok); }
/* chat */
.chat-layout { display: grid; grid-template-columns: 280px 1fr; gap: 16px; min-height: 70vh; }
.chat-sidebar { overflow: auto; }
.conv-list { list-style: none; margin: 8px 0 0; padding: 0; }
.conv-list li { border-bottom: 1px solid var(--border); }
.conv-list li.active .conv-item { background: var(--surface2); }
.conv-item {
display: flex; justify-content: space-between; align-items: center; gap: 8px;
width: 100%; padding: 10px 8px; font: inherit; text-align: left;
background: none; border: none; color: var(--text); cursor: pointer;
border-radius: var(--radius);
}
.conv-item:hover { background: var(--surface2); }
.chat-main { display: flex; flex-direction: column; }
.chat-messages { flex: 1; overflow: auto; padding: 8px 0; min-height: 320px; }
.chat-msg {
max-width: 75%; margin: 8px 0; padding: 8px 12px;
background: var(--surface2); border: 1px solid var(--border);
border-radius: var(--radius);
}
.chat-msg.own { margin-left: auto; background: color-mix(in srgb, var(--accent) 12%, var(--surface2)); }
.chat-msg p, .chat-msg ul, .chat-msg ol { margin: 4px 0; }
.chat-img { max-width: 240px; max-height: 180px; cursor: zoom-in; border-radius: var(--radius); }
.chat-toolbar { display: flex; gap: 4px; margin-bottom: 8px; }
.chat-composer {
min-height: 60px; max-height: 200px; overflow: auto;
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius); padding: 8px;
}
.chat-composer:empty::before { content: attr(data-placeholder); color: var(--muted); }
.chat-attachments { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.lightbox { border: none; background: transparent; max-width: 92vw; max-height: 92vh; }
.lightbox img { max-width: 90vw; max-height: 88vh; cursor: zoom-out; }
.lightbox::backdrop { background: rgba(0,0,0,0.8); }
@media (max-width: 800px) { .chat-layout { grid-template-columns: 1fr; } }
/* kanban */
.kanban { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
.kanban-col {
+294
View File
@@ -0,0 +1,294 @@
import { api, toast } from '/static/js/api.js';
import { subscribe, send as wsSend, onPollFallback } from '/static/js/ws.js';
const errorBox = document.getElementById('error');
const convList = document.getElementById('conv-list');
const msgHost = document.getElementById('chat-messages');
const form = document.getElementById('chat-form');
const composer = document.getElementById('chat-composer');
let meId = '';
let conversations = [];
let current = null;
let oldestCursor = '';
let pendingAttachments = [];
const userCache = new Map();
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
async function userName(id) {
if (!userCache.has(id)) {
try {
const res = await api('GET', `/api/v1/users/${id}/card`);
userCache.set(id, res.card.name);
} catch (e) { userCache.set(id, '…'); }
}
return userCache.get(id);
}
async function loadConversations() {
try {
const res = await api('GET', '/api/v1/conversations');
conversations = res.conversations;
renderConvList();
const want = new URLSearchParams(location.search).get('c');
if (!current && want) {
const c = conversations.find((x) => x.id === want);
if (c) openConversation(c);
}
} catch (e) { errorBox.textContent = e.message; }
}
function renderConvList() {
convList.replaceChildren(...conversations.map((c) => {
const li = document.createElement('li');
li.className = current && current.id === c.id ? 'active' : '';
li.innerHTML = `
<button type="button" class="conv-item">
<span>${esc(c.title)} <span class="muted">· ${esc(c.kind)}</span></span>
${c.unread ? `<span class="badge accent">${c.unread}</span>` : ''}
</button>`;
li.querySelector('button').addEventListener('click', () => openConversation(c));
return li;
}));
if (!conversations.length) {
convList.innerHTML = '<li class="muted">No conversations yet.</li>';
}
}
async function openConversation(c) {
current = c;
oldestCursor = '';
document.getElementById('chat-title').textContent = c.title;
form.hidden = false;
msgHost.replaceChildren();
renderConvList();
await loadMessages(false);
await api('POST', `/api/v1/conversations/${c.id}/read`, {}).catch(() => {});
c.unread = 0;
renderConvList();
history.replaceState(null, '', '/messages?c=' + c.id);
}
async function loadMessages(prepend) {
const res = await api('GET',
`/api/v1/conversations/${current.id}/messages?cursor=${prepend ? oldestCursor : ''}`);
const msgs = res.messages;
if (msgs.length) oldestCursor = msgs[0].id;
const nodes = await Promise.all(msgs.map(renderMessage));
if (prepend) msgHost.prepend(...nodes);
else {
msgHost.append(...nodes);
msgHost.scrollTop = msgHost.scrollHeight;
}
}
// ULID ids embed a 48-bit ms timestamp in the first 10 chars.
function ulidTime(id) {
const A = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
let ms = 0;
for (const ch of String(id).slice(0, 10).toUpperCase()) ms = ms * 32 + A.indexOf(ch);
return new Date(ms);
}
async function renderMessage(m) {
const div = document.createElement('div');
div.className = 'chat-msg' + (m.senderId === meId ? ' own' : '');
const atts = (m.attachments || []).map((a) => a.isImage
? `<img src="/files/${a.fileId}" alt="${esc(a.name)}" class="chat-img" data-lightbox loading="lazy">`
: `<a class="btn small" href="/files/${a.fileId}">📄 ${esc(a.name)}</a>`).join(' ');
div.innerHTML = `
<p class="muted" style="margin:0">${esc(await userName(m.senderId))}
· ${ulidTime(m.id).toLocaleString()}</p>
<div>${m.body || ''}</div>
${atts ? `<div class="mt">${atts}</div>` : ''}`;
div.querySelectorAll('[data-lightbox]').forEach((img) => {
img.addEventListener('click', () => {
document.getElementById('lightbox-img').src = img.src;
document.getElementById('lightbox').showModal();
});
});
return div;
}
document.getElementById('lightbox').addEventListener('click', () => {
document.getElementById('lightbox').close();
});
// ---- composer ----
document.querySelectorAll('[data-cmd]').forEach((btn) => {
btn.addEventListener('click', () => {
composer.focus();
document.execCommand(btn.dataset.cmd, false, null);
});
});
composer.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
form.requestSubmit();
}
});
let typingTimer = null;
composer.addEventListener('input', () => {
if (!current) return;
clearTimeout(typingTimer);
typingTimer = setTimeout(() => {
wsSend('chat', 'typing', { conversationId: current.id });
}, 250);
});
async function uploadFiles(fileList) {
for (const file of fileList) {
const fd = new FormData();
fd.append('file', file);
try {
const res = await api('POST', '/api/v1/files', fd);
pendingAttachments.push(res);
renderPending();
} catch (e) { toast(`Upload failed: ${e.message}`, 'err'); }
}
}
function renderPending() {
const host = document.getElementById('chat-attachments');
host.replaceChildren(...pendingAttachments.map((a, i) => {
const chip = document.createElement('span');
chip.className = 'badge';
chip.innerHTML = `${a.isImage ? '🖼' : '📄'} ${esc(a.name)} <button type="button" class="btn small ghost" aria-label="Remove attachment">×</button>`;
chip.querySelector('button').addEventListener('click', () => {
pendingAttachments.splice(i, 1);
renderPending();
});
return chip;
}));
}
document.getElementById('chat-file').addEventListener('change', (e) => {
uploadFiles(e.target.files);
e.target.value = '';
});
composer.addEventListener('dragover', (e) => e.preventDefault());
composer.addEventListener('drop', (e) => {
e.preventDefault();
if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files);
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (!current) return;
const body = composer.innerHTML;
if (!composer.textContent.trim() && !pendingAttachments.length) return;
try {
await api('POST', `/api/v1/conversations/${current.id}/messages`, {
body, attachments: pendingAttachments.map((a) => a.fileId),
});
composer.innerHTML = '';
pendingAttachments = [];
renderPending();
} catch (err) { toast(err.message, 'err'); }
});
// ---- live events ----
const typingNames = new Map();
subscribe('chat', async (event, data) => {
if (event === 'message' && data) {
if (current && data.conversationId === current.id) {
msgHost.appendChild(await renderMessage(data));
msgHost.scrollTop = msgHost.scrollHeight;
api('POST', `/api/v1/conversations/${current.id}/read`, {}).catch(() => {});
} else {
loadConversations();
}
}
if (event === 'typing' && data && current && data.conversationId === current.id) {
const name = await userName(data.userId);
typingNames.set(data.userId, Date.now());
document.getElementById('typing-indicator').textContent = `${name} is typing…`;
setTimeout(() => {
if (Date.now() - (typingNames.get(data.userId) || 0) >= 2900) {
document.getElementById('typing-indicator').textContent = '';
}
}, 3000);
}
});
onPollFallback(() => {
loadConversations();
if (current) {
msgHost.replaceChildren();
oldestCursor = '';
loadMessages(false);
}
});
// ---- new conversation dialog ----
const dlg = document.getElementById('newconv-dialog');
const selected = new Map();
document.getElementById('conv-new').addEventListener('click', () => {
selected.clear();
renderSelected();
dlg.showModal();
});
document.getElementById('nc-cancel').addEventListener('click', () => dlg.close());
document.getElementById('nc-kind').addEventListener('change', (e) => {
document.getElementById('nc-title-wrap').hidden = e.target.value === 'dm';
document.getElementById('nc-customer-wrap').hidden = e.target.value !== 'project';
});
let searchTimer = null;
document.getElementById('nc-search').addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(async () => {
const q = document.getElementById('nc-search').value.trim();
const res = await api('GET', `/api/v1/users?q=${encodeURIComponent(q)}`);
const host = document.getElementById('nc-results');
host.replaceChildren(...res.users.filter((u) => u.id !== meId).map((u) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'btn small';
b.textContent = `${u.name} <${u.email}>`;
b.addEventListener('click', () => { selected.set(u.id, u); renderSelected(); });
return b;
}));
}, 250);
});
function renderSelected() {
const host = document.getElementById('nc-selected');
host.replaceChildren(...[...selected.values()].map((u) => {
const chip = document.createElement('span');
chip.className = 'badge';
chip.textContent = u.name + ' ×';
chip.style.cursor = 'pointer';
chip.addEventListener('click', () => { selected.delete(u.id); renderSelected(); });
return chip;
}));
}
document.getElementById('newconv-form').addEventListener('submit', async (e) => {
e.preventDefault();
try {
const kind = document.getElementById('nc-kind').value;
const res = await api('POST', '/api/v1/conversations', {
kind,
title: document.getElementById('nc-title').value.trim(),
customerId: document.getElementById('nc-customer').value.trim(),
participantIds: [...selected.keys()],
});
dlg.close();
await loadConversations();
const conv = conversations.find((c) => c.id === res.conversation.id);
if (conv) openConversation(conv);
} catch (err) { toast(err.message, 'err'); }
});
(async () => {
try {
const me = await api('GET', '/api/v1/auth/me');
meId = me.user.id;
} catch (e) { /* page guard handles */ }
loadConversations();
})();
+72
View File
@@ -0,0 +1,72 @@
{{define "content"}}
<div class="error-box" id="error" role="alert"></div>
<div class="chat-layout">
<aside class="chat-sidebar card">
<div class="spread">
<h2>Messages</h2>
<button class="btn small primary" id="conv-new"> New</button>
</div>
<ul id="conv-list" class="conv-list"></ul>
</aside>
<section class="chat-main card">
<div class="spread">
<h2 id="chat-title">Select a conversation</h2>
<span class="muted" id="typing-indicator" aria-live="polite"></span>
</div>
<div id="chat-messages" class="chat-messages" aria-live="polite"></div>
<form id="chat-form" hidden>
<div id="chat-attachments" class="chat-attachments"></div>
<div class="chat-toolbar" role="toolbar" aria-label="Formatting">
<button class="btn small" type="button" data-cmd="bold" aria-label="Bold"><b>B</b></button>
<button class="btn small" type="button" data-cmd="italic" aria-label="Italic"><i>I</i></button>
<button class="btn small" type="button" data-cmd="underline" aria-label="Underline"><u>U</u></button>
<button class="btn small" type="button" data-cmd="strikeThrough" aria-label="Strikethrough"><s>S</s></button>
<button class="btn small" type="button" data-cmd="insertUnorderedList" aria-label="Bulleted list">• list</button>
<label class="btn small" style="cursor:pointer">📎<input type="file" id="chat-file" multiple hidden></label>
</div>
<div id="chat-composer" class="chat-composer" contenteditable="true" role="textbox"
aria-multiline="true" aria-label="Message" data-placeholder="Write a message… (drag & drop files)"></div>
<div class="spread mt">
<span class="hint">Enter to send · Shift+Enter for newline</span>
<button class="btn primary" type="submit">Send</button>
</div>
</form>
</section>
</div>
<dialog id="newconv-dialog">
<form method="dialog" id="newconv-form" class="stack" style="min-width:420px">
<h2>New conversation</h2>
<div class="field">
<label for="nc-kind">Kind</label>
<select id="nc-kind">
<option value="dm">Direct message</option>
<option value="group">Group</option>
<option value="project">Project</option>
</select>
</div>
<div class="field" id="nc-title-wrap" hidden>
<label for="nc-title">Title</label>
<input type="text" id="nc-title">
</div>
<div class="field" id="nc-customer-wrap" hidden>
<label for="nc-customer">Customer id</label>
<input type="text" id="nc-customer" placeholder="paste customer id">
</div>
<div class="field">
<label for="nc-search">Participants</label>
<input type="search" id="nc-search" placeholder="Search people…">
<div id="nc-results" class="stack" style="max-height:160px;overflow:auto"></div>
<div id="nc-selected" class="mt"></div>
</div>
<div class="spread">
<button class="btn" type="button" id="nc-cancel">Cancel</button>
<button class="btn primary" type="submit">Start conversation</button>
</div>
</form>
</dialog>
<dialog id="lightbox" class="lightbox">
<img id="lightbox-img" alt="">
</dialog>
{{end}}