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:
@@ -28,9 +28,14 @@ type Ticketing struct {
|
||||
CredentialsEnc string `bson:"credentialsEnc" json:"-"`
|
||||
ProjectKey string `bson:"projectKey" json:"projectKey"`
|
||||
PollIntervalSec int `bson:"pollIntervalSec" json:"pollIntervalSec"`
|
||||
LastSyncAt time.Time `bson:"lastSyncAt" json:"lastSyncAt"`
|
||||
LastSyncStatus string `bson:"lastSyncStatus" json:"lastSyncStatus"` // "", ok, error
|
||||
LastSyncError string `bson:"lastSyncError" json:"lastSyncError"`
|
||||
// ConsultantIdentities maps a bounty-board consultant id to that
|
||||
// consultant's username in THIS customer's ticketing system. It takes
|
||||
// precedence over the consultant's global extra.ticketingIdentities so an
|
||||
// admin can map accounts per project (§5.3).
|
||||
ConsultantIdentities map[string]string `bson:"consultantIdentities,omitempty" json:"consultantIdentities,omitempty"`
|
||||
LastSyncAt time.Time `bson:"lastSyncAt" json:"lastSyncAt"`
|
||||
LastSyncStatus string `bson:"lastSyncStatus" json:"lastSyncStatus"` // "", ok, error
|
||||
LastSyncError string `bson:"lastSyncError" json:"lastSyncError"`
|
||||
}
|
||||
|
||||
// Customer == "project" in the UI (§4.2).
|
||||
|
||||
@@ -54,6 +54,7 @@ type NotificationPrefs struct {
|
||||
|
||||
type UserSettings struct {
|
||||
Theme string `bson:"theme" json:"theme"`
|
||||
NavLayout string `bson:"navLayout" json:"navLayout"` // "side" (default) | "top"
|
||||
Notifications NotificationPrefs `bson:"notifications" json:"notifications"`
|
||||
LeaderboardOptOut bool `bson:"leaderboardOptOut" json:"leaderboardOptOut"`
|
||||
}
|
||||
|
||||
@@ -19,16 +19,17 @@ import (
|
||||
// customerPayload is the create/update body. Credentials are write-only:
|
||||
// they are encrypted at rest and never returned.
|
||||
type customerPayload struct {
|
||||
Name *string `json:"name"`
|
||||
Type *domain.TicketingType `json:"type"`
|
||||
BaseURL *string `json:"baseUrl"`
|
||||
ProjectKey *string `json:"projectKey"`
|
||||
Credentials *syncpkg.Credentials `json:"credentials"`
|
||||
PollInterval *int `json:"pollIntervalSec"`
|
||||
ConsultantIDs *[]string `json:"consultantIds"`
|
||||
DefaultBudget *float64 `json:"defaultBudget"`
|
||||
Archived *bool `json:"archived"`
|
||||
Version *int64 `json:"version"`
|
||||
Name *string `json:"name"`
|
||||
Type *domain.TicketingType `json:"type"`
|
||||
BaseURL *string `json:"baseUrl"`
|
||||
ProjectKey *string `json:"projectKey"`
|
||||
Credentials *syncpkg.Credentials `json:"credentials"`
|
||||
PollInterval *int `json:"pollIntervalSec"`
|
||||
ConsultantIDs *[]string `json:"consultantIds"`
|
||||
ConsultantIdentities *map[string]string `json:"consultantIdentities"`
|
||||
DefaultBudget *float64 `json:"defaultBudget"`
|
||||
Archived *bool `json:"archived"`
|
||||
Version *int64 `json:"version"`
|
||||
}
|
||||
|
||||
func (s *Server) sealCredentials(creds syncpkg.Credentials) (string, error) {
|
||||
@@ -63,8 +64,9 @@ func customerJSON(c *domain.Customer) map[string]any {
|
||||
"type": c.Ticketing.Type, "baseUrl": c.Ticketing.BaseURL,
|
||||
"projectKey": c.Ticketing.ProjectKey, "pollIntervalSec": c.Ticketing.PollIntervalSec,
|
||||
"lastSyncAt": c.Ticketing.LastSyncAt, "lastSyncStatus": c.Ticketing.LastSyncStatus,
|
||||
"lastSyncError": c.Ticketing.LastSyncError,
|
||||
"hasCredentials": c.Ticketing.CredentialsEnc != "",
|
||||
"lastSyncError": c.Ticketing.LastSyncError,
|
||||
"hasCredentials": c.Ticketing.CredentialsEnc != "",
|
||||
"consultantIdentities": c.Ticketing.ConsultantIdentities,
|
||||
},
|
||||
"consultantIds": c.ConsultantIDs, "defaultBudget": c.DefaultBudget,
|
||||
"archived": c.Archived, "createdAt": c.CreatedAt, "updatedAt": c.UpdatedAt,
|
||||
@@ -99,6 +101,18 @@ func (s *Server) handleAdminGetCustomer(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"customer": customerJSON(c)})
|
||||
}
|
||||
|
||||
// cleanIdentities trims values and drops empty mappings so we don't persist
|
||||
// blank usernames for consultants the admin left unmapped.
|
||||
func cleanIdentities(in map[string]string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for k, v := range in {
|
||||
if t := strings.TrimSpace(v); t != "" {
|
||||
out[k] = t
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) validateConsultants(ctx context.Context, ids []string) (string, bool) {
|
||||
for _, id := range ids {
|
||||
u, err := s.store.UserByID(ctx, id)
|
||||
@@ -156,6 +170,9 @@ func (s *Server) handleAdminCreateCustomer(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
c.ConsultantIDs = *req.ConsultantIDs
|
||||
}
|
||||
if req.ConsultantIdentities != nil {
|
||||
c.Ticketing.ConsultantIdentities = cleanIdentities(*req.ConsultantIdentities)
|
||||
}
|
||||
creds := syncpkg.Credentials{}
|
||||
if req.Credentials != nil {
|
||||
creds = *req.Credentials
|
||||
@@ -236,6 +253,10 @@ func (s *Server) handleAdminPatchCustomer(w http.ResponseWriter, r *http.Request
|
||||
set["consultantIds"] = *req.ConsultantIDs
|
||||
diff["consultantIds"] = *req.ConsultantIDs
|
||||
}
|
||||
if req.ConsultantIdentities != nil {
|
||||
set["ticketing.consultantIdentities"] = cleanIdentities(*req.ConsultantIdentities)
|
||||
diff["consultantIdentities"] = "updated"
|
||||
}
|
||||
if req.DefaultBudget != nil {
|
||||
set["defaultBudget"] = *req.DefaultBudget
|
||||
diff["defaultBudget"] = *req.DefaultBudget
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -45,6 +45,7 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
|
||||
Extra *map[string]any `json:"extra"`
|
||||
Settings *struct {
|
||||
Theme *string `json:"theme"`
|
||||
NavLayout *string `json:"navLayout"`
|
||||
Notifications *domain.NotificationPrefs `json:"notifications"`
|
||||
LeaderboardOptOut *bool `json:"leaderboardOptOut"`
|
||||
} `json:"settings"`
|
||||
@@ -83,6 +84,14 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
set["settings.theme"] = theme
|
||||
}
|
||||
if req.Settings.NavLayout != nil {
|
||||
nav := *req.Settings.NavLayout
|
||||
if nav != "side" && nav != "top" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_nav", "navLayout must be side or top")
|
||||
return
|
||||
}
|
||||
set["settings.navLayout"] = nav
|
||||
}
|
||||
if req.Settings.Notifications != nil {
|
||||
set["settings.notifications"] = *req.Settings.Notifications
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ type pageData struct {
|
||||
Active string // nav highlight key
|
||||
User *domain.User
|
||||
DefaultTheme string
|
||||
NavLayout string
|
||||
Narrow bool
|
||||
OIDCEnabled bool
|
||||
Error string
|
||||
@@ -81,6 +82,12 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, dat
|
||||
data.DefaultTheme = data.User.Settings.Theme
|
||||
}
|
||||
}
|
||||
if data.NavLayout == "" {
|
||||
data.NavLayout = "side" // sidebar by default
|
||||
if data.User != nil && data.User.Settings.NavLayout == "top" {
|
||||
data.NavLayout = "top"
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
s.log.Error("execute template", "page", page, "err", err)
|
||||
|
||||
@@ -19,6 +19,7 @@ type Conversation struct {
|
||||
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"`
|
||||
CreatorID string `bson:"creatorId,omitempty" json:"creatorId,omitempty"`
|
||||
ParticipantIDs []string `bson:"participantIds" json:"participantIds"`
|
||||
LastMessageAt time.Time `bson:"lastMessageAt" json:"lastMessageAt"`
|
||||
CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
|
||||
@@ -104,6 +105,28 @@ func (s *Store) ConversationByID(ctx context.Context, id string) (*Conversation,
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// AddParticipant adds a user to a conversation (idempotent via $addToSet).
|
||||
func (s *Store) AddParticipant(ctx context.Context, convID, userID string) error {
|
||||
_, err := s.DB.Collection("conversations").UpdateOne(ctx,
|
||||
bson.M{"_id": convID},
|
||||
bson.M{"$addToSet": bson.M{"participantIds": userID}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("add participant: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveParticipant removes a user from a conversation.
|
||||
func (s *Store) RemoveParticipant(ctx context.Context, convID, userID string) error {
|
||||
_, err := s.DB.Collection("conversations").UpdateOne(ctx,
|
||||
bson.M{"_id": convID},
|
||||
bson.M{"$pull": bson.M{"participantIds": userID}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove participant: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListConversations(ctx context.Context, userID string) ([]Conversation, error) {
|
||||
cur, err := s.DB.Collection("conversations").Find(ctx,
|
||||
bson.M{"participantIds": userID},
|
||||
|
||||
@@ -257,7 +257,12 @@ func (m *Manager) SyncCustomer(ctx context.Context, c *domain.Customer) error {
|
||||
if err != nil {
|
||||
continue // consultant deleted; reconcile will catch up
|
||||
}
|
||||
identity := ticketingIdentity(consultant, c.Ticketing.Type)
|
||||
// Per-customer mapping (set by the admin on the customer) wins over
|
||||
// the consultant's global identity; fall back to the global one.
|
||||
identity := c.Ticketing.ConsultantIdentities[consultantID]
|
||||
if identity == "" {
|
||||
identity = ticketingIdentity(consultant, c.Ticketing.Type)
|
||||
}
|
||||
if identity == "" {
|
||||
continue // §5.3: consultant has no linked account in this system
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user