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:
etalon
2026-06-13 11:05:33 +02:00
parent 0d28f69320
commit e8beb7d4f3
16 changed files with 367 additions and 19 deletions
+8 -3
View File
@@ -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).
+1
View File
@@ -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"`
}
+33 -12
View File
@@ -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
+92
View File
@@ -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) {
+9
View File
@@ -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
}
+7
View File
@@ -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)
+23
View File
@@ -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},
+6 -1
View File
@@ -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
}