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:"-"`
|
CredentialsEnc string `bson:"credentialsEnc" json:"-"`
|
||||||
ProjectKey string `bson:"projectKey" json:"projectKey"`
|
ProjectKey string `bson:"projectKey" json:"projectKey"`
|
||||||
PollIntervalSec int `bson:"pollIntervalSec" json:"pollIntervalSec"`
|
PollIntervalSec int `bson:"pollIntervalSec" json:"pollIntervalSec"`
|
||||||
LastSyncAt time.Time `bson:"lastSyncAt" json:"lastSyncAt"`
|
// ConsultantIdentities maps a bounty-board consultant id to that
|
||||||
LastSyncStatus string `bson:"lastSyncStatus" json:"lastSyncStatus"` // "", ok, error
|
// consultant's username in THIS customer's ticketing system. It takes
|
||||||
LastSyncError string `bson:"lastSyncError" json:"lastSyncError"`
|
// 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).
|
// Customer == "project" in the UI (§4.2).
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ type NotificationPrefs struct {
|
|||||||
|
|
||||||
type UserSettings struct {
|
type UserSettings struct {
|
||||||
Theme string `bson:"theme" json:"theme"`
|
Theme string `bson:"theme" json:"theme"`
|
||||||
|
NavLayout string `bson:"navLayout" json:"navLayout"` // "side" (default) | "top"
|
||||||
Notifications NotificationPrefs `bson:"notifications" json:"notifications"`
|
Notifications NotificationPrefs `bson:"notifications" json:"notifications"`
|
||||||
LeaderboardOptOut bool `bson:"leaderboardOptOut" json:"leaderboardOptOut"`
|
LeaderboardOptOut bool `bson:"leaderboardOptOut" json:"leaderboardOptOut"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,16 +19,17 @@ import (
|
|||||||
// customerPayload is the create/update body. Credentials are write-only:
|
// customerPayload is the create/update body. Credentials are write-only:
|
||||||
// they are encrypted at rest and never returned.
|
// they are encrypted at rest and never returned.
|
||||||
type customerPayload struct {
|
type customerPayload struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Type *domain.TicketingType `json:"type"`
|
Type *domain.TicketingType `json:"type"`
|
||||||
BaseURL *string `json:"baseUrl"`
|
BaseURL *string `json:"baseUrl"`
|
||||||
ProjectKey *string `json:"projectKey"`
|
ProjectKey *string `json:"projectKey"`
|
||||||
Credentials *syncpkg.Credentials `json:"credentials"`
|
Credentials *syncpkg.Credentials `json:"credentials"`
|
||||||
PollInterval *int `json:"pollIntervalSec"`
|
PollInterval *int `json:"pollIntervalSec"`
|
||||||
ConsultantIDs *[]string `json:"consultantIds"`
|
ConsultantIDs *[]string `json:"consultantIds"`
|
||||||
DefaultBudget *float64 `json:"defaultBudget"`
|
ConsultantIdentities *map[string]string `json:"consultantIdentities"`
|
||||||
Archived *bool `json:"archived"`
|
DefaultBudget *float64 `json:"defaultBudget"`
|
||||||
Version *int64 `json:"version"`
|
Archived *bool `json:"archived"`
|
||||||
|
Version *int64 `json:"version"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) sealCredentials(creds syncpkg.Credentials) (string, error) {
|
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,
|
"type": c.Ticketing.Type, "baseUrl": c.Ticketing.BaseURL,
|
||||||
"projectKey": c.Ticketing.ProjectKey, "pollIntervalSec": c.Ticketing.PollIntervalSec,
|
"projectKey": c.Ticketing.ProjectKey, "pollIntervalSec": c.Ticketing.PollIntervalSec,
|
||||||
"lastSyncAt": c.Ticketing.LastSyncAt, "lastSyncStatus": c.Ticketing.LastSyncStatus,
|
"lastSyncAt": c.Ticketing.LastSyncAt, "lastSyncStatus": c.Ticketing.LastSyncStatus,
|
||||||
"lastSyncError": c.Ticketing.LastSyncError,
|
"lastSyncError": c.Ticketing.LastSyncError,
|
||||||
"hasCredentials": c.Ticketing.CredentialsEnc != "",
|
"hasCredentials": c.Ticketing.CredentialsEnc != "",
|
||||||
|
"consultantIdentities": c.Ticketing.ConsultantIdentities,
|
||||||
},
|
},
|
||||||
"consultantIds": c.ConsultantIDs, "defaultBudget": c.DefaultBudget,
|
"consultantIds": c.ConsultantIDs, "defaultBudget": c.DefaultBudget,
|
||||||
"archived": c.Archived, "createdAt": c.CreatedAt, "updatedAt": c.UpdatedAt,
|
"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)})
|
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) {
|
func (s *Server) validateConsultants(ctx context.Context, ids []string) (string, bool) {
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
u, err := s.store.UserByID(ctx, id)
|
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
|
c.ConsultantIDs = *req.ConsultantIDs
|
||||||
}
|
}
|
||||||
|
if req.ConsultantIdentities != nil {
|
||||||
|
c.Ticketing.ConsultantIdentities = cleanIdentities(*req.ConsultantIdentities)
|
||||||
|
}
|
||||||
creds := syncpkg.Credentials{}
|
creds := syncpkg.Credentials{}
|
||||||
if req.Credentials != nil {
|
if req.Credentials != nil {
|
||||||
creds = *req.Credentials
|
creds = *req.Credentials
|
||||||
@@ -236,6 +253,10 @@ func (s *Server) handleAdminPatchCustomer(w http.ResponseWriter, r *http.Request
|
|||||||
set["consultantIds"] = *req.ConsultantIDs
|
set["consultantIds"] = *req.ConsultantIDs
|
||||||
diff["consultantIds"] = *req.ConsultantIDs
|
diff["consultantIds"] = *req.ConsultantIDs
|
||||||
}
|
}
|
||||||
|
if req.ConsultantIdentities != nil {
|
||||||
|
set["ticketing.consultantIdentities"] = cleanIdentities(*req.ConsultantIdentities)
|
||||||
|
diff["consultantIdentities"] = "updated"
|
||||||
|
}
|
||||||
if req.DefaultBudget != nil {
|
if req.DefaultBudget != nil {
|
||||||
set["defaultBudget"] = *req.DefaultBudget
|
set["defaultBudget"] = *req.DefaultBudget
|
||||||
diff["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("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}/messages", s.authed(s.handleSendMessage))
|
||||||
mux.Handle("POST /api/v1/conversations/{id}/read", s.authed(s.handleMarkRead))
|
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("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/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{
|
out[i] = map[string]any{
|
||||||
"id": c.ID, "kind": c.Kind, "title": title,
|
"id": c.ID, "kind": c.Kind, "title": title,
|
||||||
"customerId": c.CustomerID, "participantIds": c.ParticipantIDs,
|
"customerId": c.CustomerID, "participantIds": c.ParticipantIDs,
|
||||||
|
"creatorId": c.CreatorID,
|
||||||
"lastMessageAt": c.LastMessageAt, "unread": unread[c.ID],
|
"lastMessageAt": c.LastMessageAt, "unread": unread[c.ID],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,6 +139,7 @@ func (s *Server) handleCreateConversation(w http.ResponseWriter, r *http.Request
|
|||||||
conv := &store.Conversation{
|
conv := &store.Conversation{
|
||||||
Kind: req.Kind,
|
Kind: req.Kind,
|
||||||
Title: strings.TrimSpace(req.Title),
|
Title: strings.TrimSpace(req.Title),
|
||||||
|
CreatorID: me.ID,
|
||||||
ParticipantIDs: participants,
|
ParticipantIDs: participants,
|
||||||
}
|
}
|
||||||
if req.Kind == "project" {
|
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})
|
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
|
// handleUploadFile is the generic upload endpoint (POST /files, §6) used by
|
||||||
// chat; scope=chat unless the caller passes scope=task (consultants).
|
// chat; scope=chat unless the caller passes scope=task (consultants).
|
||||||
func (s *Server) handleUploadFile(w http.ResponseWriter, r *http.Request) {
|
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"`
|
Extra *map[string]any `json:"extra"`
|
||||||
Settings *struct {
|
Settings *struct {
|
||||||
Theme *string `json:"theme"`
|
Theme *string `json:"theme"`
|
||||||
|
NavLayout *string `json:"navLayout"`
|
||||||
Notifications *domain.NotificationPrefs `json:"notifications"`
|
Notifications *domain.NotificationPrefs `json:"notifications"`
|
||||||
LeaderboardOptOut *bool `json:"leaderboardOptOut"`
|
LeaderboardOptOut *bool `json:"leaderboardOptOut"`
|
||||||
} `json:"settings"`
|
} `json:"settings"`
|
||||||
@@ -83,6 +84,14 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
set["settings.theme"] = theme
|
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 {
|
if req.Settings.Notifications != nil {
|
||||||
set["settings.notifications"] = *req.Settings.Notifications
|
set["settings.notifications"] = *req.Settings.Notifications
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type pageData struct {
|
|||||||
Active string // nav highlight key
|
Active string // nav highlight key
|
||||||
User *domain.User
|
User *domain.User
|
||||||
DefaultTheme string
|
DefaultTheme string
|
||||||
|
NavLayout string
|
||||||
Narrow bool
|
Narrow bool
|
||||||
OIDCEnabled bool
|
OIDCEnabled bool
|
||||||
Error string
|
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
|
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")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
||||||
s.log.Error("execute template", "page", page, "err", err)
|
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
|
Kind string `bson:"kind" json:"kind"` // dm | group | project
|
||||||
CustomerID string `bson:"customerId,omitempty" json:"customerId,omitempty"`
|
CustomerID string `bson:"customerId,omitempty" json:"customerId,omitempty"`
|
||||||
Title string `bson:"title,omitempty" json:"title,omitempty"`
|
Title string `bson:"title,omitempty" json:"title,omitempty"`
|
||||||
|
CreatorID string `bson:"creatorId,omitempty" json:"creatorId,omitempty"`
|
||||||
ParticipantIDs []string `bson:"participantIds" json:"participantIds"`
|
ParticipantIDs []string `bson:"participantIds" json:"participantIds"`
|
||||||
LastMessageAt time.Time `bson:"lastMessageAt" json:"lastMessageAt"`
|
LastMessageAt time.Time `bson:"lastMessageAt" json:"lastMessageAt"`
|
||||||
CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
|
CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
|
||||||
@@ -104,6 +105,28 @@ func (s *Store) ConversationByID(ctx context.Context, id string) (*Conversation,
|
|||||||
return &c, nil
|
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) {
|
func (s *Store) ListConversations(ctx context.Context, userID string) ([]Conversation, error) {
|
||||||
cur, err := s.DB.Collection("conversations").Find(ctx,
|
cur, err := s.DB.Collection("conversations").Find(ctx,
|
||||||
bson.M{"participantIds": userID},
|
bson.M{"participantIds": userID},
|
||||||
|
|||||||
@@ -257,7 +257,12 @@ func (m *Manager) SyncCustomer(ctx context.Context, c *domain.Customer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue // consultant deleted; reconcile will catch up
|
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 == "" {
|
if identity == "" {
|
||||||
continue // §5.3: consultant has no linked account in this system
|
continue // §5.3: consultant has no linked account in this system
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,6 +303,52 @@ table, .badge, input[type=number] { font-variant-numeric: tabular-nums; }
|
|||||||
}
|
}
|
||||||
.topnav .right { display: flex; align-items: center; gap: 8px; }
|
.topnav .right { display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
/* ---- sidebar navigation (default; user can switch to top bar) ---- */
|
||||||
|
body[data-nav=side] .topnav {
|
||||||
|
justify-content: flex-end; /* only the right controls remain in the flow */
|
||||||
|
padding-left: 232px;
|
||||||
|
}
|
||||||
|
body[data-nav=side] .topnav .brand {
|
||||||
|
position: fixed; top: 14px; left: 18px; z-index: 41;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
body[data-nav=side] .topnav .links {
|
||||||
|
position: fixed; top: 0; left: 0; bottom: 0;
|
||||||
|
width: 214px;
|
||||||
|
flex: none; flex-direction: column; flex-wrap: nowrap; align-items: stretch;
|
||||||
|
gap: 4px;
|
||||||
|
background-color: var(--surface2);
|
||||||
|
background-image: var(--dither);
|
||||||
|
border-right: 2px solid var(--border);
|
||||||
|
padding: 58px 12px 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
:root[data-theme=light] body[data-nav=side] .topnav .links { background-image: none; }
|
||||||
|
body[data-nav=side] .topnav .links a {
|
||||||
|
border-bottom: none;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 9px 12px;
|
||||||
|
}
|
||||||
|
body[data-nav=side] .topnav .links a[aria-current=page] {
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
body[data-nav=side] main.container { margin-left: 214px; }
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
/* collapse the sidebar back into a normal top bar on small screens */
|
||||||
|
body[data-nav=side] .topnav { padding-left: 20px; justify-content: flex-start; flex-wrap: wrap; }
|
||||||
|
body[data-nav=side] .topnav .brand { position: static; }
|
||||||
|
body[data-nav=side] .topnav .links {
|
||||||
|
position: static; width: auto; flex: 1; flex-direction: row; flex-wrap: wrap;
|
||||||
|
background: none; border-right: none; padding: 0; overflow: visible;
|
||||||
|
}
|
||||||
|
body[data-nav=side] .topnav .links a { border-left: none; border-bottom: 2px solid transparent; }
|
||||||
|
body[data-nav=side] .topnav .links a[aria-current=page] { border-bottom-color: var(--accent); background: none; }
|
||||||
|
body[data-nav=side] main.container { margin-left: auto; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- cards: printed stock with hard Y2K shadows ---- */
|
/* ---- cards: printed stock with hard Y2K shadows ---- */
|
||||||
.card {
|
.card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
|||||||
@@ -57,6 +57,36 @@ async function loadConsultants() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render one username input per selected consultant, prefilled from the
|
||||||
|
// customer's saved per-consultant ticketing identity map.
|
||||||
|
function renderIdentities() {
|
||||||
|
const wrap = document.getElementById('c-identities-wrap');
|
||||||
|
const host = document.getElementById('c-identities');
|
||||||
|
const selectedIds = [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value);
|
||||||
|
const existing = (editingCustomer && editingCustomer.ticketing.consultantIdentities) || {};
|
||||||
|
if (!selectedIds.length) { wrap.hidden = true; host.replaceChildren(); return; }
|
||||||
|
wrap.hidden = false;
|
||||||
|
host.replaceChildren(...selectedIds.map((id) => {
|
||||||
|
const u = consultants.find((c) => c.id === id) || { name: id, email: '' };
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'spread';
|
||||||
|
row.style.marginTop = '6px';
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'muted';
|
||||||
|
label.style.flex = '1';
|
||||||
|
label.textContent = u.name;
|
||||||
|
const inp = document.createElement('input');
|
||||||
|
inp.type = 'text';
|
||||||
|
inp.dataset.identity = id;
|
||||||
|
inp.placeholder = 'username in ticketing system';
|
||||||
|
inp.value = existing[id] || '';
|
||||||
|
inp.style.flex = '1';
|
||||||
|
row.append(label, inp);
|
||||||
|
return row;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
document.getElementById('c-consultants').addEventListener('change', renderIdentities);
|
||||||
|
|
||||||
function openCustomerDialog(customer) {
|
function openCustomerDialog(customer) {
|
||||||
editingCustomer = customer || null;
|
editingCustomer = customer || null;
|
||||||
document.getElementById('customer-dialog-title').textContent =
|
document.getElementById('customer-dialog-title').textContent =
|
||||||
@@ -73,6 +103,7 @@ function openCustomerDialog(customer) {
|
|||||||
[...sel.options].forEach((o) => {
|
[...sel.options].forEach((o) => {
|
||||||
o.selected = customer ? customer.consultantIds.includes(o.value) : false;
|
o.selected = customer ? customer.consultantIds.includes(o.value) : false;
|
||||||
});
|
});
|
||||||
|
renderIdentities();
|
||||||
document.getElementById('c-test-result').textContent =
|
document.getElementById('c-test-result').textContent =
|
||||||
customer && customer.ticketing.hasCredentials ? 'Stored credentials are kept unless you enter new ones.' : '';
|
customer && customer.ticketing.hasCredentials ? 'Stored credentials are kept unless you enter new ones.' : '';
|
||||||
dlg.showModal();
|
dlg.showModal();
|
||||||
@@ -114,6 +145,11 @@ document.getElementById('customer-form').addEventListener('submit', async (e) =>
|
|||||||
defaultBudget: parseFloat(val('c-budget')) || 0,
|
defaultBudget: parseFloat(val('c-budget')) || 0,
|
||||||
consultantIds: [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value),
|
consultantIds: [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value),
|
||||||
};
|
};
|
||||||
|
const identities = {};
|
||||||
|
document.querySelectorAll('#c-identities input[data-identity]').forEach((inp) => {
|
||||||
|
if (inp.value.trim()) identities[inp.dataset.identity] = inp.value.trim();
|
||||||
|
});
|
||||||
|
body.consultantIdentities = identities;
|
||||||
if (!editingCustomer || credsEntered(type)) body.credentials = credsFromForm(type);
|
if (!editingCustomer || credsEntered(type)) body.credentials = credsFromForm(type);
|
||||||
try {
|
try {
|
||||||
if (editingCustomer) {
|
if (editingCustomer) {
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ async function openConversation(c) {
|
|||||||
current = c;
|
current = c;
|
||||||
oldestCursor = '';
|
oldestCursor = '';
|
||||||
document.getElementById('chat-title').textContent = c.title;
|
document.getElementById('chat-title').textContent = c.title;
|
||||||
|
const mbtn = document.getElementById('chat-members');
|
||||||
|
mbtn.hidden = !(c.kind !== 'dm' && c.creatorId && c.creatorId === meId);
|
||||||
form.hidden = false;
|
form.hidden = false;
|
||||||
msgHost.replaceChildren();
|
msgHost.replaceChildren();
|
||||||
renderConvList();
|
renderConvList();
|
||||||
@@ -297,6 +299,73 @@ document.getElementById('newconv-form').addEventListener('submit', async (e) =>
|
|||||||
} catch (err) { toast(err.message, 'err'); }
|
} catch (err) { toast(err.message, 'err'); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- manage members (group creator only) ----
|
||||||
|
const membersDlg = document.getElementById('members-dialog');
|
||||||
|
let mmMemberIds = new Set();
|
||||||
|
|
||||||
|
async function renderMembers() {
|
||||||
|
const res = await api('GET', `/api/v1/conversations/${current.id}/members`);
|
||||||
|
mmMemberIds = new Set(res.members.map((u) => u.id));
|
||||||
|
const host = document.getElementById('mm-list');
|
||||||
|
host.replaceChildren(...res.members.map((u) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'conv-item';
|
||||||
|
li.style.justifyContent = 'space-between';
|
||||||
|
li.innerHTML = `<span>${esc(u.name)} ${u.isCreator ? '<span class="badge accent">creator</span>' : ''}
|
||||||
|
<br><span class="muted">${esc(u.email)}</span></span>`;
|
||||||
|
if (res.canManage && !u.isCreator) {
|
||||||
|
const rm = document.createElement('button');
|
||||||
|
rm.className = 'btn small';
|
||||||
|
rm.textContent = 'Remove';
|
||||||
|
rm.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await api('DELETE', `/api/v1/conversations/${current.id}/members/${u.id}`);
|
||||||
|
await renderMembers();
|
||||||
|
} catch (e) { toast(e.message, 'err'); }
|
||||||
|
});
|
||||||
|
li.appendChild(rm);
|
||||||
|
}
|
||||||
|
return li;
|
||||||
|
}));
|
||||||
|
document.getElementById('mm-add-wrap').hidden = !res.canManage;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mmTimer = null;
|
||||||
|
async function mmSearch() {
|
||||||
|
const q = document.getElementById('mm-search').value.trim();
|
||||||
|
const res = await api('GET', `/api/v1/users?q=${encodeURIComponent(q)}`);
|
||||||
|
const host = document.getElementById('mm-results');
|
||||||
|
const rows = res.users.filter((u) => !mmMemberIds.has(u.id)).map((u) => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.className = 'nc-row';
|
||||||
|
b.textContent = `${u.name} — ${u.email}`;
|
||||||
|
b.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await api('POST', `/api/v1/conversations/${current.id}/members`, { userId: u.id });
|
||||||
|
document.getElementById('mm-search').value = '';
|
||||||
|
host.replaceChildren();
|
||||||
|
await renderMembers();
|
||||||
|
} catch (e) { toast(e.message, 'err'); }
|
||||||
|
});
|
||||||
|
return b;
|
||||||
|
});
|
||||||
|
host.replaceChildren(...rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('chat-members').addEventListener('click', async () => {
|
||||||
|
if (!current) return;
|
||||||
|
document.getElementById('mm-search').value = '';
|
||||||
|
document.getElementById('mm-results').replaceChildren();
|
||||||
|
try { await renderMembers(); membersDlg.showModal(); }
|
||||||
|
catch (e) { toast(e.message, 'err'); }
|
||||||
|
});
|
||||||
|
document.getElementById('mm-close').addEventListener('click', () => membersDlg.close());
|
||||||
|
document.getElementById('mm-search').addEventListener('input', () => {
|
||||||
|
clearTimeout(mmTimer);
|
||||||
|
mmTimer = setTimeout(mmSearch, 250);
|
||||||
|
});
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const me = await api('GET', '/api/v1/auth/me');
|
const me = await api('GET', '/api/v1/auth/me');
|
||||||
|
|||||||
@@ -56,12 +56,16 @@ form.addEventListener('submit', async (e) => {
|
|||||||
links,
|
links,
|
||||||
},
|
},
|
||||||
extra,
|
extra,
|
||||||
settings: { theme: document.getElementById('p-theme').value },
|
settings: {
|
||||||
|
theme: document.getElementById('p-theme').value,
|
||||||
|
navLayout: document.getElementById('p-nav').value,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
version = res.user.version;
|
version = res.user.version;
|
||||||
const theme = res.user.settings.theme;
|
const theme = res.user.settings.theme;
|
||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
try { localStorage.setItem('theme', theme); } catch (err) { /* ignore */ }
|
try { localStorage.setItem('theme', theme); } catch (err) { /* ignore */ }
|
||||||
|
document.body.dataset.nav = res.user.settings.navLayout || 'side';
|
||||||
okBox.textContent = 'Profile saved.';
|
okBox.textContent = 'Profile saved.';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 409) {
|
if (err.status === 409) {
|
||||||
|
|||||||
@@ -84,6 +84,11 @@
|
|||||||
<select id="c-consultants" multiple size="4"></select>
|
<select id="c-consultants" multiple size="4"></select>
|
||||||
<p class="hint">Hold Ctrl/Cmd to select multiple. At least one is needed for syncing.</p>
|
<p class="hint">Hold Ctrl/Cmd to select multiple. At least one is needed for syncing.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field" id="c-identities-wrap" hidden>
|
||||||
|
<label>Ticketing account mapping</label>
|
||||||
|
<p class="hint">Username in this project's ticketing system for each assigned consultant. Overrides the consultant's global identity. Leave blank to use their global one.</p>
|
||||||
|
<div id="c-identities"></div>
|
||||||
|
</div>
|
||||||
<div class="spread">
|
<div class="spread">
|
||||||
<span id="c-test-result" class="hint" aria-live="polite"></span>
|
<span id="c-test-result" class="hint" aria-live="polite"></span>
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<link rel="stylesheet" href="/static/css/app.css">
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
<script src="/static/js/theme.js"></script>
|
<script src="/static/js/theme.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body {{if .User}}data-logged-in="1"{{end}}>
|
<body {{if .User}}data-logged-in="1"{{end}} data-nav="{{if .User}}{{.NavLayout}}{{else}}top{{end}}">
|
||||||
<nav class="topnav">
|
<nav class="topnav">
|
||||||
<a class="brand" href="/">Bounty Board</a>
|
<a class="brand" href="/">Bounty Board</a>
|
||||||
<div class="links">
|
<div class="links">
|
||||||
|
|||||||
@@ -11,7 +11,10 @@
|
|||||||
<section class="chat-main card">
|
<section class="chat-main card">
|
||||||
<div class="spread">
|
<div class="spread">
|
||||||
<h2 id="chat-title">Select a conversation</h2>
|
<h2 id="chat-title">Select a conversation</h2>
|
||||||
<span class="muted" id="typing-indicator" aria-live="polite"></span>
|
<span style="display:flex;align-items:center;gap:10px">
|
||||||
|
<span class="muted" id="typing-indicator" aria-live="polite"></span>
|
||||||
|
<button class="btn small" id="chat-members" type="button" hidden>Manage members</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="chat-messages" class="chat-messages" aria-live="polite"></div>
|
<div id="chat-messages" class="chat-messages" aria-live="polite"></div>
|
||||||
<form id="chat-form" hidden>
|
<form id="chat-form" hidden>
|
||||||
@@ -66,6 +69,21 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="members-dialog">
|
||||||
|
<div class="stack" style="min-width:420px">
|
||||||
|
<div class="spread">
|
||||||
|
<h2 style="margin:0">Group members</h2>
|
||||||
|
<button class="btn small" type="button" id="mm-close">Close</button>
|
||||||
|
</div>
|
||||||
|
<ul id="mm-list" class="conv-list"></ul>
|
||||||
|
<div class="field" id="mm-add-wrap">
|
||||||
|
<label for="mm-search">Add someone</label>
|
||||||
|
<input type="search" id="mm-search" placeholder="Search people…">
|
||||||
|
<div id="mm-results" role="listbox" aria-label="Search results"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<dialog id="lightbox" class="lightbox">
|
<dialog id="lightbox" class="lightbox">
|
||||||
<img id="lightbox-img" alt="">
|
<img id="lightbox-img" alt="">
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|||||||
@@ -65,6 +65,13 @@
|
|||||||
<option value="sunset" {{if eq .User.Settings.Theme "sunset"}}selected{{end}}>Sunset (vaporwave)</option>
|
<option value="sunset" {{if eq .User.Settings.Theme "sunset"}}selected{{end}}>Sunset (vaporwave)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="p-nav">Navigation</label>
|
||||||
|
<select id="p-nav">
|
||||||
|
<option value="side" {{if ne .User.Settings.NavLayout "top"}}selected{{end}}>Sidebar (left)</option>
|
||||||
|
<option value="top" {{if eq .User.Settings.NavLayout "top"}}selected{{end}}>Top bar</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="btn primary" type="submit">Save profile</button>
|
<button class="btn primary" type="submit">Save profile</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user