feat: sidebar nav (default), group member management, per-customer ticketing identity mapping
- Navigation: new per-user setting (default "side") renders the page links as a left sidebar while keeping theme/bell/avatar/logout in the top-right; falls back to a top bar under 760px or when set to "top". Profile selector added. - Group conversations: record a creatorId; the creator gets a "Manage members" button to add/remove participants (new GET/POST/DELETE members endpoints, creator-only). Existing groups backfilled. - Customer ticketing: admin can map each assigned consultant to their username in that customer's ticketing system. Per-customer mapping takes precedence over the consultant's global ticketingIdentities during sync. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,9 @@ func (s *Server) routesMessages(mux *http.ServeMux) {
|
||||
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)))
|
||||
}
|
||||
@@ -81,6 +84,7 @@ func (s *Server) handleListConversations(w http.ResponseWriter, r *http.Request)
|
||||
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],
|
||||
}
|
||||
}
|
||||
@@ -135,6 +139,7 @@ func (s *Server) handleCreateConversation(w http.ResponseWriter, r *http.Request
|
||||
conv := &store.Conversation{
|
||||
Kind: req.Kind,
|
||||
Title: strings.TrimSpace(req.Title),
|
||||
CreatorID: me.ID,
|
||||
ParticipantIDs: participants,
|
||||
}
|
||||
if req.Kind == "project" {
|
||||
@@ -256,6 +261,93 @@ func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user