diff --git a/.secrets.baseline b/.secrets.baseline index 68002a77..1e72a2a1 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "go.mod|go.sum|^.secrets.baseline$", "lines": null }, - "generated_at": "2026-05-26T06:43:32Z", + "generated_at": "2026-07-09T04:46:34Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -332,7 +332,7 @@ "hashed_secret": "6947818ac409551f11fbaa78f0ea6391960aa5b8", "is_secret": false, "is_verified": false, - "line_number": 121, + "line_number": 124, "type": "Secret Keyword", "verified_result": null } diff --git a/api/cmd/pac-go-server/main.go b/api/cmd/pac-go-server/main.go index df3845a6..f37ed82f 100644 --- a/api/cmd/pac-go-server/main.go +++ b/api/cmd/pac-go-server/main.go @@ -1,6 +1,11 @@ package main import ( + "context" + "net/http" + "os" + "os/signal" + "syscall" "time" flag "github.com/spf13/pflag" @@ -20,6 +25,12 @@ var ( servicePort = "8000" ) +// shutdownGrace is the time the server gives active WebSocket sessions and +// background workers to finish before the process exits. Long enough for +// in-flight DB writes and a clean WS close frame; short enough to satisfy +// most orchestrator restart budgets (Kubernetes default terminationGracePeriodSeconds=30s). +const shutdownGrace = 10 * time.Second + func initFlags() { flag.StringVar(&servicePort, "port", "8000", "port to run the service on") flag.StringSliceVar(&models.ExcludeGroups, "exclude-groups", []string{"admin"}, "comma separated list of groups to exclude") @@ -42,7 +53,7 @@ func main() { defer func() { if err := db.Disconnect(); err != nil { - panic(err) + logger.Error("failed to disconnect from MongoDB", zap.Error(err)) } }() services.SetDB(db) @@ -54,7 +65,43 @@ func main() { logger.Info("Starting service expiry notifier") go services.ExpiryNotification() - var appRouter = router.CreateRouter() - logger.Info("PAC server is up and running", zap.String("port", servicePort)) - logger.Fatal("Error encountered while routing", zap.Error(appRouter.Run(":"+servicePort))) + // workerCtx is cancelled on shutdown to signal the background worker to + // drain its queue and exit. + workerCtx, workerCancel := context.WithCancel(context.Background()) + go services.StartAdminReplyWorker(workerCtx) + + srv := &http.Server{ + Addr: ":" + servicePort, + Handler: router.CreateRouter(), + } + + // Start serving in a background goroutine so the main goroutine can block + // on the OS signal channel instead. + go func() { + logger.Info("PAC server is up and running", zap.String("port", servicePort)) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Fatal("server error", zap.Error(err)) + } + }() + + // Block until SIGINT or SIGTERM is received. + quit := make(chan os.Signal, 1) + signal.Notify(quit, os.Interrupt, syscall.SIGTERM) + sig := <-quit + logger.Info("shutdown signal received, draining active connections", + zap.String("signal", sig.String()), + zap.Duration("grace", shutdownGrace), + ) + + // Stop accepting new connections; give existing sessions the grace period. + shutCtx, shutCancel := context.WithTimeout(context.Background(), shutdownGrace) + defer shutCancel() + if err := srv.Shutdown(shutCtx); err != nil { + logger.Error("server forced to shut down before grace period expired", zap.Error(err)) + } + + // Cancel the worker context so StartAdminReplyWorker drains and exits. + workerCancel() + + logger.Info("PAC server exited cleanly") } diff --git a/api/go.mod b/api/go.mod index 4f2e4aef..183d6d5a 100644 --- a/api/go.mod +++ b/api/go.mod @@ -8,6 +8,7 @@ require ( github.com/IBM/platform-services-go-sdk v0.101.0 github.com/IBM/vpc-go-sdk v0.87.1 github.com/Nerzal/gocloak/v13 v13.9.0 + github.com/coder/websocket v1.8.15 github.com/gin-gonic/gin v1.12.0 github.com/go-logr/logr v1.4.4 github.com/go-playground/validator/v10 v10.30.3 diff --git a/api/go.sum b/api/go.sum index 472d0fbc..61add2b1 100644 --- a/api/go.sum +++ b/api/go.sum @@ -55,6 +55,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coredns/caddy v1.1.0 h1:ezvsPrT/tA/7pYDBZxu0cT0VmWk75AfIaf6GSYCNMf0= github.com/coredns/caddy v1.1.0/go.mod h1:A6ntJQlAWuQfFlsd9hvigKbo2WS0VUs2l1e2F+BawD4= github.com/coredns/corefile-migration v1.0.21 h1:W/DCETrHDiFo0Wj03EyMkaQ9fwsmSgqTCQDHpceaSsE= diff --git a/api/internal/pkg/pac-go-server/db/interface.go b/api/internal/pkg/pac-go-server/db/interface.go index 534f2342..ff683e0f 100644 --- a/api/internal/pkg/pac-go-server/db/interface.go +++ b/api/internal/pkg/pac-go-server/db/interface.go @@ -56,4 +56,15 @@ type DB interface { CreateMaintenanceWindow(window *models.MaintenanceWindow) error UpdateMaintenanceWindow(window *models.MaintenanceWindow) error DeleteMaintenanceWindow(id string, deletedBy string, deletedAt *time.Time) error + + // Chat support operations + InsertChatMessage(msg *models.ChatMessage) error + MarkConversationEnded(ctx context.Context, userID, username string, conversationID int64) error + IsConversationEnded(ctx context.Context, userID string, conversationID int64) (bool, error) + GetCurrentConversationID(ctx context.Context, userID string) (int64, error) + GetNextConversationID(ctx context.Context, userID string) (int64, error) + GetChatMessages(ctx context.Context, userID string, conversationID int64) ([]models.ChatMessage, error) + GetUserConversations(ctx context.Context, userID string) ([]models.ConversationSummary, error) + GetAllConversations(ctx context.Context) ([]models.ConversationSummary, error) + AdminReplyToConversation(ctx context.Context, msg *models.ChatMessage) error } diff --git a/api/internal/pkg/pac-go-server/db/mock_db_client.go b/api/internal/pkg/pac-go-server/db/mock_db_client.go index c06638ed..b1ea3b4f 100644 --- a/api/internal/pkg/pac-go-server/db/mock_db_client.go +++ b/api/internal/pkg/pac-go-server/db/mock_db_client.go @@ -587,3 +587,136 @@ func (mr *MockDBMockRecorder) WatchEvents(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WatchEvents", reflect.TypeOf((*MockDB)(nil).WatchEvents), arg0) } + +// InsertChatMessage mocks base method. +func (m *MockDB) InsertChatMessage(arg0 *models.ChatMessage) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InsertChatMessage", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// InsertChatMessage indicates an expected call of InsertChatMessage. +func (mr *MockDBMockRecorder) InsertChatMessage(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InsertChatMessage", reflect.TypeOf((*MockDB)(nil).InsertChatMessage), arg0) +} + +// MarkConversationEnded mocks base method. +func (m *MockDB) MarkConversationEnded(arg0 context.Context, arg1 string, arg2 string, arg3 int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "MarkConversationEnded", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(error) + return ret0 +} + +// MarkConversationEnded indicates an expected call of MarkConversationEnded. +func (mr *MockDBMockRecorder) MarkConversationEnded(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MarkConversationEnded", reflect.TypeOf((*MockDB)(nil).MarkConversationEnded), arg0, arg1, arg2, arg3) +} + +// IsConversationEnded mocks base method. +func (m *MockDB) IsConversationEnded(arg0 context.Context, arg1 string, arg2 int64) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsConversationEnded", arg0, arg1, arg2) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsConversationEnded indicates an expected call of IsConversationEnded. +func (mr *MockDBMockRecorder) IsConversationEnded(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsConversationEnded", reflect.TypeOf((*MockDB)(nil).IsConversationEnded), arg0, arg1, arg2) +} + +// GetCurrentConversationID mocks base method. +func (m *MockDB) GetCurrentConversationID(arg0 context.Context, arg1 string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCurrentConversationID", arg0, arg1) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCurrentConversationID indicates an expected call of GetCurrentConversationID. +func (mr *MockDBMockRecorder) GetCurrentConversationID(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCurrentConversationID", reflect.TypeOf((*MockDB)(nil).GetCurrentConversationID), arg0, arg1) +} + + +// GetNextConversationID mocks base method. +func (m *MockDB) GetNextConversationID(arg0 context.Context, arg1 string) (int64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNextConversationID", arg0, arg1) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNextConversationID indicates an expected call of GetNextConversationID. +func (mr *MockDBMockRecorder) GetNextConversationID(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNextConversationID", reflect.TypeOf((*MockDB)(nil).GetNextConversationID), arg0, arg1) +} + +// GetChatMessages mocks base method. +func (m *MockDB) GetChatMessages(arg0 context.Context, arg1 string, arg2 int64) ([]models.ChatMessage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetChatMessages", arg0, arg1, arg2) + ret0, _ := ret[0].([]models.ChatMessage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetChatMessages indicates an expected call of GetChatMessages. +func (mr *MockDBMockRecorder) GetChatMessages(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatMessages", reflect.TypeOf((*MockDB)(nil).GetChatMessages), arg0, arg1, arg2) +} + +// GetUserConversations mocks base method. +func (m *MockDB) GetUserConversations(arg0 context.Context, arg1 string) ([]models.ConversationSummary, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserConversations", arg0, arg1) + ret0, _ := ret[0].([]models.ConversationSummary) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserConversations indicates an expected call of GetUserConversations. +func (mr *MockDBMockRecorder) GetUserConversations(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserConversations", reflect.TypeOf((*MockDB)(nil).GetUserConversations), arg0, arg1) +} + +// GetAllConversations mocks base method. +func (m *MockDB) GetAllConversations(arg0 context.Context) ([]models.ConversationSummary, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAllConversations", arg0) + ret0, _ := ret[0].([]models.ConversationSummary) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAllConversations indicates an expected call of GetAllConversations. +func (mr *MockDBMockRecorder) GetAllConversations(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAllConversations", reflect.TypeOf((*MockDB)(nil).GetAllConversations), arg0) +} + +// AdminReplyToConversation mocks base method. +func (m *MockDB) AdminReplyToConversation(arg0 context.Context, arg1 *models.ChatMessage) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AdminReplyToConversation", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// AdminReplyToConversation indicates an expected call of AdminReplyToConversation. +func (mr *MockDBMockRecorder) AdminReplyToConversation(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AdminReplyToConversation", reflect.TypeOf((*MockDB)(nil).AdminReplyToConversation), arg0, arg1) +} diff --git a/api/internal/pkg/pac-go-server/db/mongodb/chat.go b/api/internal/pkg/pac-go-server/db/mongodb/chat.go new file mode 100644 index 00000000..4756c8af --- /dev/null +++ b/api/internal/pkg/pac-go-server/db/mongodb/chat.go @@ -0,0 +1,295 @@ +package mongodb + +import ( + "context" + "fmt" + "time" + + "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +const chatCollection = "chat_messages" + +// BSON field name constants for the chat_messages collection. +// Using constants prevents silent query failures from typos in field name strings. +const ( + fieldUserID = "user_id" + fieldConversationID = "conversation_id" + fieldSender = "sender" + fieldMessage = "message" + fieldTimestamp = "timestamp" + fieldUsername = "username" +) + +// MarkConversationEnded writes a system sentinel message so the ended state +// survives server restarts and page navigations. +func (db *MongoDB) MarkConversationEnded(ctx context.Context, userID, username string, conversationID int64) error { + sentinel := &models.ChatMessage{ + ConversationID: conversationID, + UserID: userID, + Username: username, + Message: EndedSentinel, + Sender: models.SenderSystem, + Timestamp: time.Now(), + } + return db.InsertChatMessage(sentinel) +} + +// IsConversationEnded returns true if the given conversation has an ended sentinel. +func (db *MongoDB) IsConversationEnded(ctx context.Context, userID string, conversationID int64) (bool, error) { + collection := db.Database.Collection(chatCollection) + filter := bson.M{ + fieldUserID: userID, + fieldConversationID: conversationID, + fieldSender: models.SenderSystem, + fieldMessage: EndedSentinel, + } + count, err := collection.CountDocuments(ctx, filter) + if err != nil { + return false, fmt.Errorf("error checking conversation ended: %w", err) + } + return count > 0, nil +} + +// InsertChatMessage saves a single chat message to the DB. +func (db *MongoDB) InsertChatMessage(msg *models.ChatMessage) error { + collection := db.Database.Collection(chatCollection) + ctx, cancel := context.WithTimeout(context.Background(), dbContextTimeout) + defer cancel() + + if _, err := collection.InsertOne(ctx, msg); err != nil { + return fmt.Errorf("error inserting chat message: %w", err) + } + return nil +} + +// GetCurrentConversationID returns the latest conversation ID for a user, +// or 1 if the user has no messages yet. Unlike GetNextConversationID it does +// NOT increment — reconnecting always resumes the same conversation. +func (db *MongoDB) GetCurrentConversationID(ctx context.Context, userID string) (int64, error) { + collection := db.Database.Collection(chatCollection) + + opts := options.FindOne().SetSort(bson.D{{Key: fieldConversationID, Value: -1}}) + filter := bson.M{fieldUserID: userID, fieldSender: bson.M{"$ne": models.SenderSystem}} + + var result models.ChatMessage + err := collection.FindOne(ctx, filter, opts).Decode(&result) + if err != nil { + if err == mongo.ErrNoDocuments { + return 1, nil + } + return 0, fmt.Errorf("error finding current conversation_id: %w", err) + } + return result.ConversationID, nil +} + +// GetNextConversationID returns a brand-new conversation ID (latest + 1). +// Use this only when the user explicitly starts a new conversation. +func (db *MongoDB) GetNextConversationID(ctx context.Context, userID string) (int64, error) { + collection := db.Database.Collection(chatCollection) + + opts := options.FindOne().SetSort(bson.D{{Key: fieldConversationID, Value: -1}}) + filter := bson.M{fieldUserID: userID} + + var result models.ChatMessage + err := collection.FindOne(ctx, filter, opts).Decode(&result) + if err != nil { + if err == mongo.ErrNoDocuments { + return 1, nil + } + return 0, fmt.Errorf("error finding max conversation_id: %w", err) + } + return result.ConversationID + 1, nil +} + +// GetChatMessages returns all displayable messages for a user's conversation, oldest first. +// System sentinel messages (conversation_ended) are excluded; other system messages (e.g. auto-reply) are included. +func (db *MongoDB) GetChatMessages(ctx context.Context, userID string, conversationID int64) ([]models.ChatMessage, error) { + collection := db.Database.Collection(chatCollection) + + filter := bson.M{ + fieldUserID: userID, + fieldConversationID: conversationID, + // Exclude only the ended sentinel; keep system auto-reply messages. + "$nor": bson.A{ + bson.M{fieldSender: models.SenderSystem, fieldMessage: EndedSentinel}, + }, + } + opts := options.Find().SetSort(bson.D{{Key: fieldTimestamp, Value: 1}}) + + cursor, err := collection.Find(ctx, filter, opts) + if err != nil { + return nil, fmt.Errorf("error fetching chat messages: %w", err) + } + defer cursor.Close(ctx) + + var messages []models.ChatMessage + if err := cursor.All(ctx, &messages); err != nil { + return nil, fmt.Errorf("error decoding chat messages: %w", err) + } + return messages, nil +} + +// EndedSentinel is the message text written to the DB when a conversation ends. +// It is used to detect ended conversations without a separate collection. +const EndedSentinel = "conversation_ended" + +// GetUserConversations returns conversation summaries for a single user, ordered by most recent. +// Each summary includes Ended (true if a system sentinel exists) and FirstMessage (first user text). +func (db *MongoDB) GetUserConversations(ctx context.Context, userID string) ([]models.ConversationSummary, error) { + collection := db.Database.Collection(chatCollection) + + pipeline := mongo.Pipeline{ + {{Key: "$match", Value: bson.M{fieldUserID: userID}}}, + { + {Key: "$sort", Value: bson.D{{Key: fieldTimestamp, Value: 1}}}, + }, + { + {Key: "$group", Value: bson.D{ + {Key: "_id", Value: "$" + fieldConversationID}, + {Key: "message_count", Value: bson.D{{Key: "$sum", Value: 1}}}, + {Key: "last_message_at", Value: bson.D{{Key: "$max", Value: "$" + fieldTimestamp}}}, + {Key: fieldUsername, Value: bson.D{{Key: "$first", Value: "$" + fieldUsername}}}, + // Collect all (sender, message) pairs to derive ended + first_message. + {Key: "msgs", Value: bson.D{{Key: "$push", Value: bson.D{ + {Key: fieldSender, Value: "$" + fieldSender}, + {Key: fieldMessage, Value: "$" + fieldMessage}, + }}}}, + }}, + }, + {{Key: "$sort", Value: bson.D{{Key: "last_message_at", Value: -1}}}}, + } + + cursor, err := collection.Aggregate(ctx, pipeline) + if err != nil { + return nil, fmt.Errorf("error aggregating user conversations: %w", err) + } + defer cursor.Close(ctx) + + type msgPair struct { + Sender string `bson:"sender"` + Message string `bson:"message"` + } + type rawSummary struct { + ConversationID int64 `bson:"_id"` + MessageCount int64 `bson:"message_count"` + LastMessageAt time.Time `bson:"last_message_at"` + Username string `bson:"username"` + Msgs []msgPair `bson:"msgs"` + } + + var raw []rawSummary + if err := cursor.All(ctx, &raw); err != nil { + return nil, fmt.Errorf("error decoding user conversations: %w", err) + } + + summaries := make([]models.ConversationSummary, 0, len(raw)) + for _, r := range raw { + ended := false + firstMsg := "" + for _, m := range r.Msgs { + if m.Sender == models.SenderSystem && m.Message == EndedSentinel { + ended = true + } + if firstMsg == "" && m.Sender == models.SenderUser { + firstMsg = m.Message + } + } + summaries = append(summaries, models.ConversationSummary{ + ConversationID: r.ConversationID, + UserID: userID, + Username: r.Username, + MessageCount: r.MessageCount, + LastMessageAt: r.LastMessageAt, + Ended: ended, + FirstMessage: firstMsg, + }) + } + return summaries, nil +} + +// GetAllConversations returns one summary row per unique (user_id, conversation_id) pair, +// ordered by the most recent activity, including ended status. +func (db *MongoDB) GetAllConversations(ctx context.Context) ([]models.ConversationSummary, error) { + collection := db.Database.Collection(chatCollection) + + pipeline := mongo.Pipeline{ + { + {Key: "$group", Value: bson.D{ + {Key: "_id", Value: bson.D{ + {Key: fieldUserID, Value: "$" + fieldUserID}, + {Key: fieldConversationID, Value: "$" + fieldConversationID}, + }}, + {Key: "message_count", Value: bson.D{{Key: "$sum", Value: 1}}}, + {Key: "last_message_at", Value: bson.D{{Key: "$max", Value: "$" + fieldTimestamp}}}, + {Key: fieldUsername, Value: bson.D{{Key: "$first", Value: "$" + fieldUsername}}}, + {Key: "senders", Value: bson.D{{Key: "$addToSet", Value: bson.D{ + {Key: fieldSender, Value: "$" + fieldSender}, + {Key: fieldMessage, Value: "$" + fieldMessage}, + }}}}, + }}, + }, + {{Key: "$sort", Value: bson.D{{Key: "last_message_at", Value: -1}}}}, + } + + cursor, err := collection.Aggregate(ctx, pipeline) + if err != nil { + return nil, fmt.Errorf("error aggregating conversations: %w", err) + } + defer cursor.Close(ctx) + + type senderPair struct { + Sender string `bson:"sender"` + Message string `bson:"message"` + } + type rawSummary struct { + ID struct { + UserID string `bson:"user_id"` + ConversationID int64 `bson:"conversation_id"` + } `bson:"_id"` + MessageCount int64 `bson:"message_count"` + LastMessageAt time.Time `bson:"last_message_at"` + Username string `bson:"username"` + Senders []senderPair `bson:"senders"` + } + + var raw []rawSummary + if err := cursor.All(ctx, &raw); err != nil { + return nil, fmt.Errorf("error decoding conversations: %w", err) + } + + summaries := make([]models.ConversationSummary, 0, len(raw)) + for _, r := range raw { + ended := false + for _, s := range r.Senders { + if s.Sender == models.SenderSystem && s.Message == EndedSentinel { + ended = true + break + } + } + summaries = append(summaries, models.ConversationSummary{ + ConversationID: r.ID.ConversationID, + UserID: r.ID.UserID, + Username: r.Username, + MessageCount: r.MessageCount, + LastMessageAt: r.LastMessageAt, + Ended: ended, + }) + } + return summaries, nil +} + +// AdminReplyToConversation inserts an admin message into an existing conversation. +func (db *MongoDB) AdminReplyToConversation(ctx context.Context, msg *models.ChatMessage) error { + collection := db.Database.Collection(chatCollection) + ctxT, cancel := context.WithTimeout(ctx, dbContextTimeout) + defer cancel() + + if _, err := collection.InsertOne(ctxT, msg); err != nil { + return fmt.Errorf("error inserting admin reply: %w", err) + } + return nil +} diff --git a/api/internal/pkg/pac-go-server/models/chat.go b/api/internal/pkg/pac-go-server/models/chat.go new file mode 100644 index 00000000..393904c8 --- /dev/null +++ b/api/internal/pkg/pac-go-server/models/chat.go @@ -0,0 +1,37 @@ +package models + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +// Sender constants for ChatMessage.Sender. +const ( + SenderUser = "user" + SenderAdmin = "admin" + SenderSystem = "system" +) + +// ChatMessage represents a single message in a support conversation. +type ChatMessage struct { + ID bson.ObjectID `json:"id" bson:"_id,omitempty"` + ConversationID int64 `json:"conversation_id" bson:"conversation_id"` + UserID string `json:"user_id" bson:"user_id"` + Username string `json:"username" bson:"username"` // preferred_username from Keycloak + Message string `json:"message" bson:"message"` + // Sender is one of SenderUser, SenderAdmin, or SenderSystem. + Sender string `json:"sender" bson:"sender"` + Timestamp time.Time `json:"timestamp" bson:"timestamp"` +} + +// ConversationSummary is returned to list conversations. +type ConversationSummary struct { + ConversationID int64 `json:"conversation_id" bson:"conversation_id"` + UserID string `json:"user_id" bson:"user_id"` + Username string `json:"username"` // preferred_username, for display + MessageCount int64 `json:"message_count"` + LastMessageAt time.Time `json:"last_message_at" bson:"last_message_at"` + Ended bool `json:"ended"` + FirstMessage string `json:"first_message"` // preview label for the user sidebar +} diff --git a/api/internal/pkg/pac-go-server/router/middleware.go b/api/internal/pkg/pac-go-server/router/middleware.go index 3eddaeb6..5e0ce3ad 100644 --- a/api/internal/pkg/pac-go-server/router/middleware.go +++ b/api/internal/pkg/pac-go-server/router/middleware.go @@ -7,12 +7,35 @@ import ( "strings" "github.com/gin-gonic/gin" + "go.uber.org/zap" pacClient "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/client" + log "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/logger" "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/models" "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/utils" ) +// InjectTokenFromQuery promotes a ?token= query parameter to an +// "Authorization: Bearer " header. This is needed for WebSocket +// connections because browsers cannot set custom headers on the WS upgrade +// request. Must be placed before the Keycloak auth middlewares. +func InjectTokenFromQuery(c *gin.Context) { + token := c.Query("token") + if token != "" && c.GetHeader("Authorization") == "" { + c.Request.Header.Set("Authorization", "Bearer "+token) + log.GetLogger().Debug("InjectTokenFromQuery: set Authorization header from ?token=", + zap.String("path", c.Request.URL.Path), + ) + } else { + log.GetLogger().Warn("InjectTokenFromQuery: skipped", + zap.String("path", c.Request.URL.Path), + zap.Bool("token_present", token != ""), + zap.Bool("auth_header_present", c.GetHeader("Authorization") != ""), + ) + } + c.Next() +} + func AllowAdminOnly(c *gin.Context) { config := pacClient.GetConfigFromContext(c.Request.Context()) kc := pacClient.NewKeyCloakClient(config, c) diff --git a/api/internal/pkg/pac-go-server/router/router.go b/api/internal/pkg/pac-go-server/router/router.go index 89cc4e89..c81ccd20 100644 --- a/api/internal/pkg/pac-go-server/router/router.go +++ b/api/internal/pkg/pac-go-server/router/router.go @@ -42,6 +42,7 @@ func CreateRouter() *gin.Engine { } authorized := router.Group("/api/v1") + authorized.Use(gin.Logger()) authorized.Use(ginkeycloak.Auth(ginkeycloak.AuthCheck(), ginkeycloak.KeycloakConfig{ Url: hostname, Realm: realm, @@ -133,5 +134,36 @@ func CreateRouter() *gin.Engine { // maintenance notification related endpoints authorized.GET("/maintenance", services.GetMaintenanceWindows) + // chat support — user-facing REST endpoints (own conversations + messages) + authorized.GET("/conversations", services.GetUserConversations) + authorized.GET("/conversations/:conv_id/messages", services.GetUserConversationMessages) + + // chat support — WebSocket endpoint for users. + // InjectTokenFromQuery MUST be first: browsers cannot set custom headers on + // a WS upgrade request, so the JWT arrives as ?token= and must be promoted + // to an Authorization header before the Keycloak middlewares run. + // All three middlewares run before HandleChatWebSocket is entered, so they + // only ever write HTTP error responses — never after the connection is + // hijacked. + wsGroup := router.Group("/api/v1") + wsGroup.Use(InjectTokenFromQuery) + wsGroup.Use(ginkeycloak.Auth(ginkeycloak.AuthCheck(), ginkeycloak.KeycloakConfig{ + Url: hostname, + Realm: realm, + })) + wsGroup.Use(RetrospectKeycloakToken) + wsGroup.GET("/chat", services.HandleChatWebSocket) + // Admin watch WebSockets: reuse the same InjectTokenFromQuery + auth chain. + wsGroup.Use(AllowAdminOnly) + wsGroup.GET("/admin/conversations/:user_id/:conv_id/ws", services.AdminWatchConversation) + // Single broadcast WS — admin subscribes once to get unread indicators for all convs. + wsGroup.GET("/admin/watch", services.AdminWatchAll) + + // chat support — REST endpoints for admins + authorizedAdmin.GET("/admin/conversations", services.GetAdminConversations) + authorizedAdmin.GET("/admin/conversations/:user_id/:conv_id/messages", services.GetAdminConversationMessages) + authorizedAdmin.POST("/admin/conversations/:user_id/:conv_id/reply", services.AdminReply) + authorizedAdmin.POST("/admin/conversations/:user_id/:conv_id/end", services.AdminEndConversation) + return router } diff --git a/api/internal/pkg/pac-go-server/services/chat.go b/api/internal/pkg/pac-go-server/services/chat.go new file mode 100644 index 00000000..0a24e5ed --- /dev/null +++ b/api/internal/pkg/pac-go-server/services/chat.go @@ -0,0 +1,813 @@ +package services + +import ( + "context" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + pacClient "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/client" + log "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/logger" + "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/models" +) + +// wsWriteTimeout is the maximum time allowed for a single WebSocket write. +// Without a deadline, a zombie client (network dropped but TCP not yet closed) +// will cause wsjson.Write to block indefinitely, leaking the goroutine until +// the OS TCP keepalive eventually fires (minutes to hours). +const wsWriteTimeout = 10 * time.Second + +// wsWrite wraps wsjson.Write with a per-call timeout derived from the +// session context. Using the session ctx (not a fresh Background ctx) means +// that if the session is already cancelled the write returns immediately. +func wsWrite(ctx context.Context, conn *websocket.Conn, v interface{}) error { + wctx, cancel := context.WithTimeout(ctx, wsWriteTimeout) + defer cancel() + return wsjson.Write(wctx, conn, v) +} + +// HandleChatWebSocket upgrades the connection to WebSocket and handles real-time +// chat messages for the authenticated user. +func HandleChatWebSocket(c *gin.Context) { + logger := log.GetLogger() + + // Auth is already done by the middleware chain (InjectTokenFromQuery → + // ginkeycloak.Auth → RetrospectKeycloakToken). Read userid before the + // upgrade since c.Request.Context() is only valid until the handler returns. + userID, _ := c.Request.Context().Value("userid").(string) + username, _ := c.Request.Context().Value("username").(string) + + // coder/websocket calls WriteHeaderNow() on Gin's writer before Hijack(), + // which sets Written()=true and causes Gin's Hijack() guard to reject it. + // Unwrap one level to get the raw net/http ResponseWriter, bypassing that + // check. coder/websocket's own hijacker() loop still finds the socket. + type unwrapper interface{ Unwrap() http.ResponseWriter } + rw := http.ResponseWriter(c.Writer) + if u, ok := c.Writer.(unwrapper); ok { + rw = u.Unwrap() + } + conn, err := websocket.Accept(rw, c.Request, &websocket.AcceptOptions{ + OriginPatterns: []string{"*"}, + }) + if err != nil { + logger.Error("websocket upgrade failed", zap.Error(err)) + return + } + + // Mark Gin's writer as written so Recovery/WriteHeaderNow do not attempt + // to write on the hijacked connection when the middleware chain unwinds. + c.Writer.WriteHeaderNow() + + // Run the WebSocket loop in a goroutine and return immediately from the + // handler. This is the key fix for the EOF-on-first-read bug: + // + // RetrospectKeycloakToken calls c.Next() which runs this handler inline. + // If the handler blocks in the read loop, everything is fine — until the + // loop exits and the middleware chain unwinds. net/http then calls + // finishRequest() which closes the TCP socket, killing the connection. + // + // By returning immediately, the middleware chain unwinds while the + // goroutine owns the already-hijacked conn. net/http sees Written()=true + // and does not attempt to close or write to the connection. + go func() { + defer conn.Close(websocket.StatusNormalClosure, "") + serveChat(conn, userID, username, logger) + }() +} + +// chatSession holds the mutable per-connection state shared across the helper +// functions that make up the WebSocket serve loop. +type chatSession struct { + conn *websocket.Conn + ctx context.Context + userID string + username string + logger *zap.Logger + + conversationID int64 + convEnded bool + // history tracks whether the current conversation already has messages. + // It is non-nil (even if empty) after initChatSession, and is set to a + // non-empty sentinel after the first user message so the auto-reply is + // never sent twice for the same conversation. + history []models.ChatMessage + + // incomingFromAdminCh receives hub messages pushed to this user. + // unsubFromAdmin must be called on session teardown. + incomingFromAdminCh chan hubMessage + unsubFromAdmin func() +} + +// initChatSession loads conversation state from the DB, sends the hello frame, +// and subscribes to the hub. Returns an error if the session cannot start +// (e.g. DB failure or failed hello write); the caller should return immediately. +func initChatSession(ctx context.Context, conn *websocket.Conn, userID, username string, logger *zap.Logger) (*chatSession, error) { + conversationID, err := dbCon.GetCurrentConversationID(ctx, userID) + if err != nil { + logger.Error("failed to get conversation ID", zap.Error(err)) + _ = wsWrite(ctx, conn, gin.H{"error": "failed to initialize conversation"}) + return nil, err + } + + convEnded, err := dbCon.IsConversationEnded(ctx, userID, conversationID) + if err != nil { + logger.Warn("failed to check conversation ended state", zap.Error(err)) + // non-fatal: treat as not ended + } + + history, err := dbCon.GetChatMessages(ctx, userID, conversationID) + if err != nil { + logger.Error("failed to load chat history", zap.Error(err)) + history = nil + } + + type msgFrame struct { + ID string `json:"id"` + ConversationID int64 `json:"conversation_id"` + Sender string `json:"sender"` + Message string `json:"message"` + Timestamp string `json:"timestamp"` + } + historyFrames := make([]msgFrame, 0, len(history)) + for _, m := range history { + historyFrames = append(historyFrames, msgFrame{ + ID: m.ID.Hex(), + ConversationID: m.ConversationID, + Sender: m.Sender, + Message: m.Message, + Timestamp: m.Timestamp.Format(time.RFC3339), + }) + } + + hello := map[string]interface{}{ + "type": "hello", + "conversation_id": conversationID, + "history": historyFrames, + } + if err := wsWrite(ctx, conn, hello); err != nil { + logger.Error("failed to send hello frame", zap.Error(err)) + return nil, err + } + + logger.Info("conversation resumed", + zap.String("userID", userID), + zap.Int64("conversationID", conversationID), + zap.Int("history_len", len(history))) + + incomingFromAdminCh := make(chan hubMessage, 16) + unsubFromAdmin := hub.subscribe(userID, incomingFromAdminCh) + + return &chatSession{ + conn: conn, + ctx: ctx, + userID: userID, + username: username, + logger: logger, + conversationID: conversationID, + convEnded: convEnded, + history: history, + incomingFromAdminCh: incomingFromAdminCh, + unsubFromAdmin: unsubFromAdmin, + }, nil +} + +// handleEndConversation processes the "end_conversation" action frame. +// Returns true if the WebSocket connection must be closed (fatal write error). +func (s *chatSession) handleEndConversation() (fatal bool) { + if s.convEnded { + return false // already ended — ignore duplicate + } + s.convEnded = true + + // Persist the ended sentinel so the status survives reconnects. + if err := dbCon.MarkConversationEnded(s.ctx, s.userID, s.username, s.conversationID); err != nil { + s.logger.Error("failed to mark conversation ended", zap.Error(err)) + } + + // Notify any admin watching this conversation. + hub.publish(userToAdminKey(s.userID, s.conversationID), hubMessage{ + ConversationID: s.conversationID, + Sender: models.SenderSystem, + Message: "conversation_ended", + }) + + frame := map[string]interface{}{ + "type": "ended", + "conversation_id": s.conversationID, + } + if err := wsWrite(s.ctx, s.conn, frame); err != nil { + s.logger.Error("failed to send ended frame", zap.Error(err)) + return true + } + s.logger.Info("conversation ended by user", + zap.String("userID", s.userID), + zap.Int64("conversationID", s.conversationID)) + return false +} + +// handleUserMessage saves the user message, publishes it to the hub, echoes it +// back, and fires the one-time auto-reply on the first message of each conversation. +// Returns true if the WebSocket connection must be closed (fatal write error). +// Returns (false, true) as (fatal, skip) when a non-fatal error means the +// message should be skipped (e.g. DB failure getting next conv ID). +func (s *chatSession) handleUserMessage(msg string) (fatal bool, skip bool) { + // If the previous conversation was ended, lazily promote to a new conv ID + // now that there is real content to save. + isNewConv := false + if s.convEnded { + newID, err := dbCon.GetNextConversationID(s.ctx, s.userID) + if err != nil { + s.logger.Error("failed to get next conversation ID", zap.Error(err)) + _ = wsWrite(s.ctx, s.conn, gin.H{"error": "failed to start new conversation"}) + return false, true + } + s.conversationID = newID + s.convEnded = false + isNewConv = true + + // Re-subscribe so hub messages for the new conv reach this session. + s.unsubFromAdmin() + s.incomingFromAdminCh = make(chan hubMessage, 16) + s.unsubFromAdmin = hub.subscribe(s.userID, s.incomingFromAdminCh) + } + + // isFirstMessage is true for the very first message of each conversation: + // either a brand-new conv (isNewConv) or the opening message of conv 1. + isFirstMessage := isNewConv || len(s.history) == 0 + + userMsg := &models.ChatMessage{ + ConversationID: s.conversationID, + UserID: s.userID, + Username: s.username, + Message: msg, + Sender: models.SenderUser, + Timestamp: time.Now(), + } + // DB insert is synchronous: we echo only after persistence is confirmed. + if err := dbCon.InsertChatMessage(userMsg); err != nil { + s.logger.Error("failed to save user message", zap.Error(err)) + _ = wsWrite(s.ctx, s.conn, gin.H{"error": "failed to save message"}) + return false, true + } + // Mark history non-empty so the auto-reply is not re-triggered. + if isFirstMessage { + s.history = []models.ChatMessage{{}} + } + + // Publish to any admin watching this conversation and to the broadcast key. + hubMsg := hubMessage{ + ConversationID: s.conversationID, + UserID: s.userID, + Sender: models.SenderUser, + Message: userMsg.Message, + Timestamp: userMsg.Timestamp.Format(time.RFC3339), + } + hub.publish(userToAdminKey(s.userID, s.conversationID), hubMsg) + hub.publish(adminBroadcastKey, hubMsg) + + echo := map[string]interface{}{ + "conversation_id": s.conversationID, + "message": userMsg.Message, + "sender": models.SenderUser, + "timestamp": userMsg.Timestamp.Format(time.RFC3339), + } + if err := wsWrite(s.ctx, s.conn, echo); err != nil { + s.logger.Error("failed to send echo", zap.Error(err)) + return true, false + } + + if isFirstMessage { + if fatal := s.sendAutoReply(); fatal { + return true, false + } + } + return false, false +} + +// sendAutoReply saves and echoes the one-time automated system reply sent at +// the start of every new conversation. +// Returns true if the WebSocket connection must be closed (fatal write error). +func (s *chatSession) sendAutoReply() (fatal bool) { + const autoReplyText = "Thanks for reaching out! An admin will reply within 48 hours. " + + "In the meantime, feel free to browse our FAQ for quick answers: " + + "https://github.com/IBM/power-access-cloud/blob/main/support/docs/FAQ.md" + + autoReply := &models.ChatMessage{ + ConversationID: s.conversationID, + UserID: s.userID, + Username: s.username, + Message: autoReplyText, + Sender: models.SenderSystem, + Timestamp: time.Now(), + } + if err := dbCon.InsertChatMessage(autoReply); err != nil { + s.logger.Error("failed to save auto-reply", zap.Error(err)) + return false // non-fatal: user message was already saved and echoed + } + frame := map[string]interface{}{ + "conversation_id": s.conversationID, + "message": autoReply.Message, + "sender": models.SenderSystem, + "timestamp": autoReply.Timestamp.Format(time.RFC3339), + } + if err := wsWrite(s.ctx, s.conn, frame); err != nil { + s.logger.Error("failed to send auto-reply frame", zap.Error(err)) + return true + } + return false +} + +// handleAdminMessage pushes an incoming hub message (admin reply or remote end) +// to the connected user. +// Returns true if the WebSocket connection must be closed (fatal write error). +func (s *chatSession) handleAdminMessage(adminMsg hubMessage) (fatal bool) { + // "conversation_ended" is a system signal, not a display message. + if adminMsg.Message == "conversation_ended" { + frame := map[string]interface{}{ + "type": "ended", + "conversation_id": adminMsg.ConversationID, + } + if err := wsWrite(s.ctx, s.conn, frame); err != nil { + s.logger.Error("failed to send ended frame to user", zap.Error(err)) + return true + } + return false + } + frame := map[string]interface{}{ + "conversation_id": adminMsg.ConversationID, + "sender": adminMsg.Sender, + "message": adminMsg.Message, + "timestamp": adminMsg.Timestamp, + } + if err := wsWrite(s.ctx, s.conn, frame); err != nil { + s.logger.Error("failed to send admin message", zap.Error(err)) + return true + } + return false +} + +// serveChat runs the full WebSocket session for one connected user. +func serveChat(conn *websocket.Conn, userID, username string, logger *zap.Logger) { + // Use a cancellable context tied to the connection lifetime. + // When the client disconnects (or the server closes the conn), cancel + // unblocks any in-flight wsjson.Read/Write immediately — no goroutine leak. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + session, err := initChatSession(ctx, conn, userID, username, logger) + if err != nil { + return + } + defer session.unsubFromAdmin() + + // A single persistent reader goroutine feeds all incoming client frames + // into readCh. Starting it once (rather than once per loop iteration) + // means there is never more than one outstanding wsjson.Read at a time, + // so no goroutines are leaked when the adminCh or ctx.Done case fires. + type readResult struct { + msg string + action string + err error + } + readCh := make(chan readResult, 1) + go func() { + for { + var incoming struct { + Message string `json:"message"` + Action string `json:"action"` + } + err := wsjson.Read(ctx, conn, &incoming) + readCh <- readResult{msg: incoming.Message, action: incoming.Action, err: err} + if err != nil { + return + } + } + }() + + for { + select { + case res := <-readCh: + if res.err != nil { + status := websocket.CloseStatus(res.err) + if status == websocket.StatusNormalClosure || status == websocket.StatusGoingAway { + logger.Info("websocket closed", zap.String("userID", userID)) + } else { + logger.Error("websocket read error", zap.Error(res.err)) + } + return + } + + if res.action == "end_conversation" { + if fatal := session.handleEndConversation(); fatal { + return + } + continue + } + + if res.msg == "" { + continue + } + + if fatal, skip := session.handleUserMessage(res.msg); fatal || skip { + if fatal { + return + } + continue + } + + case adminMsg := <-session.incomingFromAdminCh: + if fatal := session.handleAdminMessage(adminMsg); fatal { + return + } + + case <-ctx.Done(): + return + } + } +} + +// GetUserConversations returns the list of the calling user's own conversations. +func GetUserConversations(c *gin.Context) { + logger := log.GetLogger() + userID, _ := c.Request.Context().Value("userid").(string) + summaries, err := dbCon.GetUserConversations(c.Request.Context(), userID) + if err != nil { + logger.Error("failed to get user conversations", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"conversations": summaries}) +} + +// GetUserConversationMessages returns messages for one of the calling user's own conversations. +func GetUserConversationMessages(c *gin.Context) { + logger := log.GetLogger() + userID, _ := c.Request.Context().Value("userid").(string) + convIDStr := c.Param("conv_id") + + convID, err := strconv.ParseInt(convIDStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid conversation id"}) + return + } + + messages, err := dbCon.GetChatMessages(c.Request.Context(), userID, convID) + if err != nil { + logger.Error("failed to get user conversation messages", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"messages": messages}) +} + +// GetAdminConversations returns a list of all user conversations for the admin panel. +// For any conversation whose username was not stored in the DB (legacy data), the +// username is resolved from Keycloak by userID and backfilled in the response. +func GetAdminConversations(c *gin.Context) { + logger := log.GetLogger() + summaries, err := dbCon.GetAllConversations(c.Request.Context()) + if err != nil { + logger.Error("failed to get conversations", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Collect unique userIDs that are missing a username (legacy messages). + missing := map[string]struct{}{} + for _, s := range summaries { + if s.Username == "" { + missing[s.UserID] = struct{}{} + } + } + + if len(missing) > 0 { + config := pacClient.GetConfigFromContext(c.Request.Context()) + kc := pacClient.NewKeyCloakClient(config, c.Request.Context()) + resolved := make(map[string]string, len(missing)) + for uid := range missing { + user, err := kc.GetUser(uid) + if err != nil { + logger.Warn("could not resolve username from Keycloak", zap.String("userID", uid), zap.Error(err)) + resolved[uid] = uid // fall back to raw UUID + continue + } + if user.Username != nil { + resolved[uid] = *user.Username + } else { + resolved[uid] = uid + } + } + for i := range summaries { + if summaries[i].Username == "" { + summaries[i].Username = resolved[summaries[i].UserID] + } + } + } + + c.JSON(http.StatusOK, gin.H{"conversations": summaries}) +} + +// GetAdminConversationMessages returns all messages for a specific conversation. +func GetAdminConversationMessages(c *gin.Context) { + logger := log.GetLogger() + userID := c.Param("user_id") + convIDStr := c.Param("conv_id") + + convID, err := strconv.ParseInt(convIDStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid conversation id"}) + return + } + + messages, err := dbCon.GetChatMessages(c.Request.Context(), userID, convID) + if err != nil { + logger.Error("failed to get messages", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"messages": messages}) +} + +// adminReplyWorker is a package-level buffered channel that decouples the +// AdminReply HTTP handler from the MongoDB write. The handler enqueues the +// fully-constructed ChatMessage and returns HTTP 202 immediately; a single +// background goroutine drains the queue and persists each message. +// +// Buffer size 256: in the worst case (DB temporarily slow) this absorbs a +// burst of 256 admin replies before the handler starts blocking callers. +// If the channel is full the handler falls back to a synchronous write so +// that no reply is ever silently lost. +var adminReplyQueue = make(chan *models.ChatMessage, 256) + +// StartAdminReplyWorker drains adminReplyQueue and persists each message to +// MongoDB. It must be started once from main() before the HTTP server begins +// accepting requests. It exits when ctx is cancelled (server shutdown). +func StartAdminReplyWorker(ctx context.Context) { + logger := log.GetLogger() + for { + select { + case msg := <-adminReplyQueue: + if err := dbCon.AdminReplyToConversation(ctx, msg); err != nil { + logger.Error("adminReplyWorker: failed to persist reply", + zap.String("userID", msg.UserID), + zap.Int64("conversationID", msg.ConversationID), + zap.Error(err), + ) + } + case <-ctx.Done(): + // Drain any remaining items before exiting so in-flight replies + // are not lost during a graceful shutdown. + for { + select { + case msg := <-adminReplyQueue: + if err := dbCon.AdminReplyToConversation(context.Background(), msg); err != nil { + logger.Error("adminReplyWorker: shutdown drain failed", + zap.String("userID", msg.UserID), + zap.Error(err), + ) + } + default: + logger.Info("adminReplyWorker: queue drained, exiting") + return + } + } + } + } +} + +// AdminReply posts an admin reply into an existing conversation. +func AdminReply(c *gin.Context) { + logger := log.GetLogger() + + adminID, ok := c.Request.Context().Value("userid").(string) + if !ok || adminID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + userID := c.Param("user_id") + convIDStr := c.Param("conv_id") + convID, err := strconv.ParseInt(convIDStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid conversation id"}) + return + } + + var body struct { + Message string `json:"message" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid body: %s", err.Error())}) + return + } + + msg := &models.ChatMessage{ + ConversationID: convID, + UserID: userID, + Message: body.Message, + Sender: models.SenderAdmin, + Timestamp: time.Now(), + } + + // Attempt to enqueue the DB write for the background worker. + // If the queue is full (worker backlogged), fall back to a synchronous + // write so the reply is never silently dropped. + select { + case adminReplyQueue <- msg: + // enqueued — worker will persist asynchronously + default: + logger.Warn("adminReplyQueue full, falling back to synchronous write", + zap.String("userID", userID), + zap.Int64("conversationID", convID), + ) + if err := dbCon.AdminReplyToConversation(c.Request.Context(), msg); err != nil { + logger.Error("failed to save admin reply", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + // Push the reply to the user's live WebSocket session (if connected). + hub.publish(userID, hubMessage{ + ConversationID: convID, + Sender: models.SenderAdmin, + Message: body.Message, + Timestamp: msg.Timestamp.Format(time.RFC3339), + }) + + logger.Info("admin reply enqueued", + zap.String("adminID", adminID), + zap.String("targetUserID", userID), + zap.Int64("conversationID", convID)) + + c.JSON(http.StatusCreated, gin.H{"message": "reply sent"}) +} + +// AdminEndConversation lets an admin close a conversation on behalf of the user. +// It notifies the user's live WebSocket session via the hub so the user's UI +// transitions to a fresh conversation immediately. +func AdminEndConversation(c *gin.Context) { + logger := log.GetLogger() + + userID := c.Param("user_id") + convIDStr := c.Param("conv_id") + convID, err := strconv.ParseInt(convIDStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid conversation id"}) + return + } + + adminUsername, _ := c.Request.Context().Value("username").(string) + _ = adminUsername // username stored on the user's messages, not the admin's + + // Persist the ended sentinel so the status survives reconnects. + if err := dbCon.MarkConversationEnded(c.Request.Context(), userID, "", convID); err != nil { + logger.Error("failed to mark conversation ended in DB", zap.Error(err)) + // Non-fatal: still notify the user and return success. + } + + // Push an "ended" signal to the user's live WebSocket (if connected). + hub.publish(userID, hubMessage{ + ConversationID: convID, + Sender: models.SenderSystem, + Message: "conversation_ended", + }) + + logger.Info("admin ended conversation", + zap.String("userID", userID), + zap.Int64("convID", convID)) + + c.JSON(http.StatusOK, gin.H{"message": "conversation ended"}) +} + +// AdminWatchConversation upgrades an admin request to a WebSocket and streams +// new user messages for the specified conversation in real time. +// The admin opens this connection after selecting a conversation; from that +// point on, every message the user sends is forwarded here immediately without +// polling. +func AdminWatchConversation(c *gin.Context) { + logger := log.GetLogger() + + userID := c.Param("user_id") + convIDStr := c.Param("conv_id") + convID, err := strconv.ParseInt(convIDStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid conversation id"}) + return + } + + type unwrapper interface{ Unwrap() http.ResponseWriter } + rw := http.ResponseWriter(c.Writer) + if u, ok := c.Writer.(unwrapper); ok { + rw = u.Unwrap() + } + conn, err := websocket.Accept(rw, c.Request, &websocket.AcceptOptions{ + OriginPatterns: []string{"*"}, + }) + if err != nil { + logger.Error("admin watch: websocket upgrade failed", zap.Error(err)) + return + } + c.Writer.WriteHeaderNow() + + go func() { + defer conn.Close(websocket.StatusNormalClosure, "") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // incomingFromUserCh receives messages published by serveChat for this conversation. + incomingFromUserCh := make(chan hubMessage, 16) + unsubFromUser := hub.subscribe(userToAdminKey(userID, convID), incomingFromUserCh) + defer unsubFromUser() + + logger.Info("admin watching conversation", + zap.String("userID", userID), + zap.Int64("convID", convID)) + + for { + select { + case msg := <-incomingFromUserCh: + var frame map[string]interface{} + if msg.Sender == models.SenderSystem && msg.Message == "conversation_ended" { + frame = map[string]interface{}{ + "type": "ended", + "conversation_id": msg.ConversationID, + } + } else { + frame = map[string]interface{}{ + "conversation_id": msg.ConversationID, + "sender": msg.Sender, + "message": msg.Message, + "timestamp": msg.Timestamp, + } + } + if err := wsWrite(ctx, conn, frame); err != nil { + logger.Error("admin watch: write failed", zap.Error(err)) + return + } + case <-ctx.Done(): + return + } + } + }() +} + +// AdminWatchAll upgrades an admin request to a WebSocket that receives a +// notification for every incoming user message across ALL conversations. +// The admin panel uses this single connection to drive unread indicators in the +// sidebar without polling. Each frame contains user_id and conversation_id so +// the frontend can match the right sidebar entry. +func AdminWatchAll(c *gin.Context) { + logger := log.GetLogger() + + type unwrapper interface{ Unwrap() http.ResponseWriter } + rw := http.ResponseWriter(c.Writer) + if u, ok := c.Writer.(unwrapper); ok { + rw = u.Unwrap() + } + conn, err := websocket.Accept(rw, c.Request, &websocket.AcceptOptions{ + OriginPatterns: []string{"*"}, + }) + if err != nil { + logger.Error("admin watch-all: websocket upgrade failed", zap.Error(err)) + return + } + c.Writer.WriteHeaderNow() + + go func() { + defer conn.Close(websocket.StatusNormalClosure, "") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // incomingBroadcastCh receives a notification for every user message across all conversations. + incomingBroadcastCh := make(chan hubMessage, 32) + unsubFromBroadcast := hub.subscribe(adminBroadcastKey, incomingBroadcastCh) + defer unsubFromBroadcast() + + logger.Info("admin watching all conversations") + + for { + select { + case msg := <-incomingBroadcastCh: + frame := map[string]interface{}{ + "user_id": msg.UserID, + "conversation_id": msg.ConversationID, + "sender": msg.Sender, + } + if err := wsWrite(ctx, conn, frame); err != nil { + return + } + case <-ctx.Done(): + return + } + } + }() +} diff --git a/api/internal/pkg/pac-go-server/services/chat_hub.go b/api/internal/pkg/pac-go-server/services/chat_hub.go new file mode 100644 index 00000000..bbfd5f35 --- /dev/null +++ b/api/internal/pkg/pac-go-server/services/chat_hub.go @@ -0,0 +1,100 @@ +package services + +import ( + "fmt" + "sync" + "sync/atomic" + + log "github.com/IBM/power-access-cloud/api/internal/pkg/pac-go-server/logger" + "go.uber.org/zap" +) + +// chatHub is a simple fan-out hub. Subscribers register a channel under an +// arbitrary string key and receive every message published to that key. +// +// Two key namespaces are used: +// - user side: userID — admin → user pushes (hubMessage.Sender == "admin") +// - admin side: "userID:convID" — user → admin pushes (hubMessage.Sender == "user") +type chatHub struct { + mu sync.Mutex + subs map[string][]chan hubMessage + dropCount atomic.Int64 // total messages dropped due to full subscriber buffers +} + +type hubMessage struct { + ConversationID int64 `json:"conversation_id"` + UserID string `json:"user_id,omitempty"` + Sender string `json:"sender"` + Message string `json:"message"` + Timestamp string `json:"timestamp"` +} + +// adminBroadcastKey is the hub key that every incoming user message is also +// published to, so a single admin WS can receive notifications for all convs. +const adminBroadcastKey = "admin:broadcast" + +var hub = &chatHub{ + subs: make(map[string][]chan hubMessage), +} + +// userToAdminKey returns the hub key under which an admin watches a specific +// user conversation (user → admin direction: "userID:convID"). +func userToAdminKey(userID string, convID int64) string { + return fmt.Sprintf("%s:%d", userID, convID) +} + +// subscribe registers ch under key and returns a cleanup function that removes it. +// The returned function must be called (e.g. via defer) to avoid leaking the +// channel in the hub after the subscriber goroutine exits. +func (h *chatHub) subscribe(key string, ch chan hubMessage) func() { + h.mu.Lock() + h.subs[key] = append(h.subs[key], ch) + h.mu.Unlock() + + // unsubscribe removes ch from the list under key. + // If the list becomes empty the key is deleted from the map entirely. + return func() { + h.mu.Lock() + defer h.mu.Unlock() + list := h.subs[key] + for i, c := range list { + if c == ch { + h.subs[key] = append(list[:i], list[i+1:]...) + break + } + } + if len(h.subs[key]) == 0 { + delete(h.subs, key) + } + } +} + +// publish sends msg to every channel currently subscribed under key. +// Non-blocking: if a channel's buffer is full the message is dropped for that +// subscriber (WebSocket goroutines are assumed to drain quickly). +// +// Every drop increments dropCount and is logged at Warn level so that +// "hot" conversations that are overwhelming the hub are immediately visible +// in the server logs without any external metrics infrastructure. +func (h *chatHub) publish(key string, msg hubMessage) { + h.mu.Lock() + list := make([]chan hubMessage, len(h.subs[key])) + copy(list, h.subs[key]) + h.mu.Unlock() + + for _, ch := range list { + select { + case ch <- msg: + default: + // Subscriber channel is full — message dropped. + // Increment the counter and emit a warning so operators can + // identify which conversation is generating backpressure. + total := h.dropCount.Add(1) + log.GetLogger().Warn("hub: subscriber channel full, message dropped", + zap.String("key", key), + zap.String("sender", msg.Sender), + zap.Int64("total_drops", total), + ) + } + } +} diff --git a/web/src/components/App.jsx b/web/src/components/App.jsx index 0fc61a71..82ab2c59 100644 --- a/web/src/components/App.jsx +++ b/web/src/components/App.jsx @@ -22,6 +22,8 @@ import { Theme } from "@carbon/react"; import Feedbacks from "./Feedbacks"; import MaintenanceNotification from "./MaintenanceNotification"; import MaintenanceManager from "./MaintenanceManager"; +import ChatSupport from "./ChatSupport"; +import ChatAdmin from "./ChatAdmin"; const RouterClass = React.memo(({ isAdmin }) => { return ( @@ -46,6 +48,9 @@ const RouterClass = React.memo(({ isAdmin }) => { {!isAdmin && ( } /> )} + {!isAdmin && ( + } /> + )} {isAdmin && ( { element={} /> )} + {isAdmin && ( + } + /> + )} ); }); @@ -120,6 +131,7 @@ const App = () => { "/keys", "/feedbacks", "/maintenance", + "/chat-admin", ].includes(window.location.pathname) ) { window.location.href = "/login"; diff --git a/web/src/components/ChatAdmin.jsx b/web/src/components/ChatAdmin.jsx new file mode 100644 index 00000000..59cf37ba --- /dev/null +++ b/web/src/components/ChatAdmin.jsx @@ -0,0 +1,421 @@ +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { Send, Renew } from "@carbon/icons-react"; +import { + Button, + DataTableSkeleton, + InlineNotification, + TextArea, +} from "@carbon/react"; +import "../styles/chat-support.scss"; +import axios from "axios"; +import UserService from "../services/UserService"; + +const api = axios.create(); +api.interceptors.request.use((config) => { + const cb = () => { + config.headers.Authorization = `Bearer ${UserService.getToken()}`; + return Promise.resolve(config); + }; + return UserService.updateToken(cb); +}); + +// Returns "username-conv-N" if username is available, else falls back to userID. +const convLabel = (username, userID, convID) => + `${username || userID}-conv-${convID}`; + +// Unique key for a (userID, convID) pair — used for unread tracking. +const convKey = (userID, convID) => `${userID}__${convID}`; + +const ChatAdmin = () => { + const [conversations, setConversations] = useState([]); + const [selected, setSelected] = useState(null); // { userID, convID } + const [messages, setMessages] = useState([]); + const [replyText, setReplyText] = useState(""); + const [loading, setLoading] = useState(false); + const [msgLoading, setMsgLoading] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + const [convEnded, setConvEnded] = useState(false); // true after conversation ended + // unreadCounts: { [convKey]: number } — count of unread user messages per conv. + const [unreadCounts, setUnreadCounts] = useState({}); + + // Ref to the live-watch WebSocket for the currently selected conversation. + const watchWsRef = useRef(null); + const messagesEndRef = useRef(null); + // Ref copy of selected so WS onmessage can read it without a stale closure. + const selectedRef = useRef(null); + + // Auto-scroll to the latest message whenever the list changes. + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + // Keep selectedRef in sync with selected state. + useEffect(() => { + selectedRef.current = selected; + }, [selected]); + + const loadConversations = useCallback(async () => { + setLoading(true); + setError(""); + try { + const res = await api.get("/pac-go-server/admin/conversations"); + setConversations(res.data.conversations ?? []); + } catch (err) { + setError("Failed to load conversations: " + (err.response?.data?.error ?? err.message)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadConversations(); + }, [loadConversations]); + + // Close any existing watch WebSocket cleanly. + const closeWatchWs = () => { + const ws = watchWsRef.current; + if (ws) { + ws.onclose = null; + ws.onerror = null; + ws.onmessage = null; + ws.close(1000, "conversation changed"); + watchWsRef.current = null; + } + }; + + // Open a watch WebSocket for (userID, convID) and append incoming messages. + const openWatchWs = (userID, convID) => { + closeWatchWs(); + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const token = UserService.getToken(); + const url = `${protocol}//${window.location.host}/pac-go-server/admin/conversations/${userID}/${convID}/ws?token=${encodeURIComponent(token)}`; + + const ws = new WebSocket(url); + watchWsRef.current = ws; + + ws.onmessage = (event) => { + if (watchWsRef.current !== ws) return; + try { + const data = JSON.parse(event.data); + + // Conversation ended signal from the hub. + if (data.type === "ended") { + setConvEnded(true); + return; + } + + if (data.sender && data.message) { + setMessages((prev) => [ + ...prev, + { + id: data.id ?? Date.now(), + sender: data.sender, + message: data.message, + timestamp: data.timestamp, + }, + ]); + } + } catch (e) { + console.error("admin watch WS parse error", e); + } + }; + + ws.onerror = () => { + console.error("admin watch WS error"); + }; + + ws.onclose = () => { + if (watchWsRef.current === ws) watchWsRef.current = null; + }; + }; + + // Close the watch WS when the component unmounts. + useEffect(() => () => closeWatchWs(), []); + + // Single persistent WebSocket that subscribes to ALL user messages across + // all conversations. When a message arrives for a conv that isn't currently + // selected, we mark it unread in the sidebar. + useEffect(() => { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const token = UserService.getToken(); + const url = `${protocol}//${window.location.host}/pac-go-server/admin/watch?token=${encodeURIComponent(token)}`; + const ws = new WebSocket(url); + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (!data.user_id || !data.conversation_id) return; + const sel = selectedRef.current; + const isSelected = + sel?.userID === data.user_id && sel?.convID === data.conversation_id; + if (!isSelected) { + const key = convKey(data.user_id, data.conversation_id); + setUnreadCounts((prev) => ({ ...prev, [key]: (prev[key] ?? 0) + 1 })); + } + } catch (e) { + console.error("admin watch-all WS parse error", e); + } + }; + + ws.onerror = () => console.error("admin watch-all WS error"); + + return () => { + ws.onmessage = null; + ws.onerror = null; + ws.onclose = null; + ws.close(1000, "unmount"); + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const selectConversation = async (userID, convID, alreadyEnded = false, username = "") => { + const key = convKey(userID, convID); + setSelected({ userID, convID, username }); + setMessages([]); + setReplyText(""); + setSuccess(""); + setConvEnded(alreadyEnded); // restore persisted ended state immediately + // Clear unread count for this conv. + setUnreadCounts((prev) => { + if (!prev[key]) return prev; + const next = { ...prev }; + delete next[key]; + return next; + }); + setMsgLoading(true); + try { + const res = await api.get(`/pac-go-server/admin/conversations/${userID}/${convID}/messages`); + const msgs = res.data.messages ?? []; + setMessages(msgs); + } catch (err) { + setError("Failed to load messages: " + (err.response?.data?.error ?? err.message)); + } finally { + setMsgLoading(false); + } + // Open the live-watch WebSocket after history is loaded. + openWatchWs(userID, convID); + }; + + const sendReply = async () => { + if (!replyText.trim() || !selected || convEnded) return; + setSuccess(""); + setError(""); + const text = replyText; + setReplyText(""); + try { + await api.post( + `/pac-go-server/admin/conversations/${selected.userID}/${selected.convID}/reply`, + { message: text } + ); + setSuccess("Reply sent."); + // Optimistically append the admin message so it appears immediately. + setMessages((prev) => [ + ...prev, + { + id: Date.now(), + sender: "admin", + message: text, + timestamp: new Date().toISOString(), + }, + ]); + } catch (err) { + setReplyText(text); // restore on failure + setError("Failed to send reply: " + (err.response?.data?.error ?? err.message)); + } + }; + + // onKeyDown for the reply textarea: Enter submits, Shift+Enter adds a newline. + const onReplyKeyDown = (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendReply(); + } + }; + + const endConversation = async () => { + if (!selected || convEnded) return; + setError(""); + try { + await api.post( + `/pac-go-server/admin/conversations/${selected.userID}/${selected.convID}/end` + ); + setConvEnded(true); + setSuccess("Conversation ended."); + // Refresh the conversation list so the new conv appears. + loadConversations(); + } catch (err) { + setError("Failed to end conversation: " + (err.response?.data?.error ?? err.message)); + } + }; + + const formatTime = (ts) => + new Date(ts).toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: true, + }); + + return ( +
+ {/* Left panel — conversation list */} +
+
+

Conversations

+
+ + {loading && } + + {!loading && conversations.length === 0 && ( +

No conversations yet

+ )} + +
+ {conversations.map((conv) => { + const isActive = + selected?.userID === conv.user_id && + selected?.convID === conv.conversation_id; + const unreadCount = unreadCounts[convKey(conv.user_id, conv.conversation_id)] ?? 0; + return ( +
selectConversation(conv.user_id, conv.conversation_id, conv.ended, conv.username)} + > +
+
+ {(conv.username || conv.user_id)?.[0]?.toUpperCase() ?? "U"} +
+
+
+
+ {convLabel(conv.username, conv.user_id, conv.conversation_id)} +
+
+ {conv.ended + ? Ended + : Active} +
+
+ {unreadCount > 0 + ? <>{unreadCount} unread + : null} +
+
+
+ ); + })} +
+
+ + {/* Right panel — message thread + reply */} +
+ {error && ( + setError("")} + /> + )} + {success && ( + setSuccess("")} + /> + )} + + {!selected ? ( +
+

Select a conversation on the left to view messages

+
+ ) : ( + <> +
+

{convLabel(selected.username, selected.userID, selected.convID)}

+
+ + User: {selected.username || selected.userID} + + {!convEnded && ( + + )} + {convEnded && ( + Ended + )} +
+
+ +
+ {msgLoading && } + {!msgLoading && messages.length === 0 && ( +
+

No messages in this conversation

+
+ )} + {!msgLoading && + messages.map((msg, idx) => ( +
+
+
+ + [{msg.sender === "user" ? "User" : "Admin"},{" "} + {formatTime(msg.timestamp)}]: + +
+
{msg.message}
+
+
+ ))} +
+
+ +
+
+