diff --git a/cmd/conversation.go b/cmd/conversation.go index 98d01311..8a17f404 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -57,6 +57,7 @@ type createConversationRequest struct { Content string `json:"content"` Attachments []int `json:"attachments"` Initiator string `json:"initiator"` // "contact" | "agent" + SourceID string `json:"source_id"` // RFC 5322 Message-ID of the inbound message; stored on the created contact message so replies thread on it. Contact-initiated only. CustomAttributes map[string]any `json:"custom_attributes"` } @@ -880,7 +881,7 @@ func handleCreateConversation(r *fastglue.Request) error { } case umodels.UserTypeContact: // Create contact message. - if _, err := app.conversation.CreateContactMessage(media, contact.ID, conversationUUID, req.Content, cmodels.ContentTypeHTML, true); err != nil { + if _, err := app.conversation.CreateContactMessage(media, contact.ID, conversationUUID, req.Content, cmodels.ContentTypeHTML, true, req.SourceID); err != nil { // Delete the conversation if message creation fails. if err := app.conversation.DeleteConversation(conversationUUID); err != nil { app.lo.Error("error deleting conversation", "error", err) diff --git a/cmd/handlers.go b/cmd/handlers.go index ff3120fd..fdce720e 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -14,7 +14,10 @@ import ( "github.com/zerodha/fastglue" ) -const maxPageSize = 500 +const ( + maxPageSize = 500 + maxIDsParam = 200 +) // initHandlers initializes the HTTP routes and handlers for the application. func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { @@ -71,9 +74,9 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.GET("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleGetMessage, "messages:read")) g.GET("/api/v1/conversations/{uuid}/messages", perm(handleGetMessages, "messages:read")) g.GET("/api/v1/conversations/{uuid}/transcript", perm(handleDownloadConversationTranscript, "messages:read")) - g.POST("/api/v1/conversations/{cuuid}/messages", perm(handleSendMessage, "messages:write")) + g.POST("/api/v1/conversations/{cuuid}/messages", auth(handleSendMessage)) g.PUT("/api/v1/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages:write")) - g.DELETE("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleDeleteMessage, "messages:write")) + g.DELETE("/api/v1/conversations/{cuuid}/messages/{uuid}", perm(handleDeleteMessage, "messages:write_private")) g.POST("/api/v1/conversations", perm(handleCreateConversation, "conversations:write")) g.PUT("/api/v1/conversations/{uuid}/custom-attributes", auth(handleUpdateConversationCustomAttributes)) g.PUT("/api/v1/conversations/{uuid}/contacts/custom-attributes", auth(handleUpdateContactCustomAttributes)) @@ -118,7 +121,9 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { // Macros. g.GET("/api/v1/macros", auth(handleGetMacros)) - g.GET("/api/v1/macros/{id}", perm(handleGetMacro, "macros:manage")) + g.GET("/api/v1/macros/compact", perm(handleGetMacrosCompact, "macros:manage")) + g.GET("/api/v1/macros/search", auth(handleSearchMacros)) + g.GET("/api/v1/macros/{id}", auth(handleGetMacro)) g.POST("/api/v1/macros", perm(handleCreateMacro, "macros:manage")) g.PUT("/api/v1/macros/{id}", perm(handleUpdateMacro, "macros:manage")) g.DELETE("/api/v1/macros/{id}", perm(handleDeleteMacro, "macros:manage")) @@ -268,7 +273,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { // AI assistant: reply drafting + copilot chat. g.POST("/api/v1/ai/generate-reply", auth(handleAIGenerateReply)) - g.POST("/api/v1/ai/summarize", perm(handleAISummarizeConversation, "messages:write")) + g.POST("/api/v1/ai/summarize", perm(handleAISummarizeConversation, "messages:write_private")) g.POST("/api/v1/ai/suggest-tags", auth(handleAISuggestTags)) g.POST("/api/v1/ai/copilot", auth(handleAICopilot)) g.GET("/api/v1/ai/copilot/messages", auth(handleGetCopilotMessages)) @@ -546,6 +551,26 @@ func serveWidgetJS(r *fastglue.Request) error { return nil } +// getIDsParam parses a comma separated list of positive IDs from a query param. +func getIDsParam(r *fastglue.Request, name string) []int { + raw := strings.TrimSpace(string(r.RequestCtx.QueryArgs().Peek(name))) + if raw == "" { + return nil + } + var ids []int + for part := range strings.SplitSeq(raw, ",") { + id, err := strconv.Atoi(strings.TrimSpace(part)) + if err != nil || id <= 0 { + continue + } + ids = append(ids, id) + if len(ids) == maxIDsParam { + break + } + } + return ids +} + // getPagination extracts page and page_size from query params with defaults. func getPagination(r *fastglue.Request) (page, pageSize int) { page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page"))) @@ -562,6 +587,22 @@ func getPagination(r *fastglue.Request) (page, pageSize int) { return page, pageSize } +// getOptionalPagination reads page/page_size, returning pageSize 0 (fetch everything) when absent. +func getOptionalPagination(r *fastglue.Request) (page, pageSize int) { + page, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page"))) + pageSize, _ = strconv.Atoi(string(r.RequestCtx.QueryArgs().Peek("page_size"))) + if page < 1 { + page = 1 + } + if pageSize < 0 { + pageSize = 0 + } + if pageSize > maxPageSize { + pageSize = maxPageSize + } + return page, pageSize +} + // sendErrorEnvelope sends a standardized error response to the client. func sendErrorEnvelope(r *fastglue.Request, err error) error { e, ok := err.(envelope.Error) diff --git a/cmd/i18n.go b/cmd/i18n.go index b1df5980..ba5b07a1 100644 --- a/cmd/i18n.go +++ b/cmd/i18n.go @@ -22,6 +22,10 @@ const ( var ( localeI18nMu sync.Mutex localeI18nCache = map[string]*i18n.I18n{} + + // Keyed by resolved language code + i18nJSONMu sync.Mutex + i18nJSONCache = map[string][]byte{} ) // handleGetI18nLang returns the JSON language pack for the given language code. @@ -30,11 +34,14 @@ func handleGetI18nLang(r *fastglue.Request) error { app = r.Context.(*App) lang = r.RequestCtx.UserValue("lang").(string) ) - i, err := loadI18nLang(lang, app.fs) + b, err := i18nLangJSON(app, lang) if err != nil { return sendErrorEnvelope(r, err) } - return r.SendBytes(http.StatusOK, "application/json", i.JSON()) + r.RequestCtx.Response.Header.SetContentType("application/json") + r.RequestCtx.Response.SetStatusCode(http.StatusOK) + r.RequestCtx.Response.SetBodyRaw(b) + return nil } // handleGetAvailableLanguages returns the list of available languages @@ -96,6 +103,23 @@ func localeI18n(app *App, locale string) *i18n.I18n { return i } +// i18nLangJSON returns the marshaled language pack for a language code, cached per resolved code. +func i18nLangJSON(app *App, lang string) ([]byte, error) { + code := matchLangFile(app.fs, strings.ToLower(strings.TrimSpace(lang))) + i18nJSONMu.Lock() + defer i18nJSONMu.Unlock() + if b, ok := i18nJSONCache[code]; ok { + return b, nil + } + i, err := loadI18nLang(code, app.fs) + if err != nil { + return nil, err + } + b := i.JSON() + i18nJSONCache[code] = b + return b, nil +} + // matchLangFile finds the language pack code for a locale: exact match first, then language prefix. func matchLangFile(fs stuffbin.FileSystem, loc string) string { files, err := fs.Glob("/i18n/*.json") diff --git a/cmd/init.go b/cmd/init.go index 47c99ca4..9a91ce96 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -860,7 +860,8 @@ func reloadAuth(app *App) error { app.lo.Info("reloading auth manager") providers, err := buildProviders(app.oidc) if err != nil { - log.Fatalf("error reloading auth: %v", err) + app.lo.Error("error reloading auth", "error", err) + return err } if err := app.auth.Reload(auth_.Config{Providers: providers}); err != nil { app.lo.Error("error reloading auth", "error", err) @@ -885,7 +886,7 @@ func buildProviders(o *oidc.Manager) ([]auth_.Provider, error) { ID: config.ID, Provider: config.Provider, ProviderURL: config.ProviderURL, - RedirectURL: config.RedirectURI, + RedirectURL: func() (string, error) { return o.RedirectURL(config.ID) }, ClientID: config.ClientID, ClientSecret: config.ClientSecret, }) diff --git a/cmd/macro.go b/cmd/macro.go index 38895c72..d8856f49 100644 --- a/cmd/macro.go +++ b/cmd/macro.go @@ -21,17 +21,58 @@ func handleGetMacros(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } for i, m := range macros { - var actions []autoModels.RuleAction - if err := json.Unmarshal(m.Actions, &actions); err != nil { - app.lo.Error("error unmarshalling macro actions", "macro_id", m.ID, "error", err) + if macros[i].Actions, err = decorateMacroActions(app, m.Actions); err != nil { + app.lo.Error("error decorating macro actions", "macro_id", m.ID, "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) } - // Set display values for actions as the value field can contain DB IDs - if err := setDisplayValues(app, actions); err != nil { - app.lo.Warn("error setting display values", "error", err) + } + return r.SendEnvelope(macros) +} + +// handleGetMacrosCompact returns macros without message content, all of them without page params. +func handleGetMacrosCompact(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + query = string(r.RequestCtx.QueryArgs().Peek("q")) + ) + page, pageSize := getOptionalPagination(r) + macros, err := app.macro.GetAllCompact(query, page, pageSize) + if err != nil { + return sendErrorEnvelope(r, err) + } + for i, m := range macros { + if macros[i].Actions, err = decorateMacroActions(app, m.Actions); err != nil { + app.lo.Error("error decorating macro actions", "macro_id", m.ID, "error", err) + return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) } - if macros[i].Actions, err = json.Marshal(actions); err != nil { - app.lo.Error("error marshalling macro actions", "macro_id", m.ID, "error", err) + } + return r.SendEnvelope(macros) +} + +// handleSearchMacros returns the top macros without message content visible to the requesting agent. +func handleSearchMacros(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + auser = r.RequestCtx.UserValue("user").(amodels.User) + query = string(r.RequestCtx.QueryArgs().Peek("q")) + view = string(r.RequestCtx.QueryArgs().Peek("view")) + ) + switch view { + case "", models.VisibleWhenReplying, models.VisibleWhenStartingConversation, models.VisibleWhenAddingPrivateNote: + default: + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError) + } + user, err := app.user.GetAgentCachedOrLoad(auser.ID) + if err != nil { + return sendErrorEnvelope(r, err) + } + macros, err := app.macro.SearchCompact(query, view, user.ID, user.Teams.IDs()) + if err != nil { + return sendErrorEnvelope(r, err) + } + for i, m := range macros { + if macros[i].Actions, err = decorateMacroActions(app, m.Actions); err != nil { + app.lo.Error("error decorating macro actions", "macro_id", m.ID, "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) } } @@ -53,17 +94,8 @@ func handleGetMacro(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - var actions []autoModels.RuleAction - if err := json.Unmarshal(macro.Actions, &actions); err != nil { - app.lo.Error("error unmarshalling macro actions", "macro_id", id, "error", err) - return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) - } - // Set display values for actions as the value field can contain DB IDs - if err := setDisplayValues(app, actions); err != nil { - app.lo.Warn("error setting display values", "error", err) - } - if macro.Actions, err = json.Marshal(actions); err != nil { - app.lo.Error("error marshalling macro actions", "macro_id", id, "error", err) + if macro.Actions, err = decorateMacroActions(app, macro.Actions); err != nil { + app.lo.Error("error decorating macro actions", "macro_id", id, "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) } @@ -226,6 +258,18 @@ func hasActionPermission(action string, userPerms []string) bool { return slices.Contains(userPerms, requiredPerm) } +// decorateMacroActions resolves display values for action IDs and re-marshals the actions. +func decorateMacroActions(app *App, raw json.RawMessage) (json.RawMessage, error) { + var actions []autoModels.RuleAction + if err := json.Unmarshal(raw, &actions); err != nil { + return nil, err + } + if err := setDisplayValues(app, actions); err != nil { + app.lo.Warn("error setting display values", "error", err) + } + return json.Marshal(actions) +} + // setDisplayValues sets display values for actions. func setDisplayValues(app *App, actions []autoModels.RuleAction) error { getters := map[string]func(int) (string, error){ @@ -238,7 +282,7 @@ func setDisplayValues(app *App, actions []autoModels.RuleAction) error { return t.Name, nil }, autoModels.ActionAssignUser: func(id int) (string, error) { - u, err := app.user.GetAgent(id, "") + u, err := app.user.GetAgentCachedOrLoad(id) if err != nil { app.lo.Warn("user not found for macro action", "user_id", id) return "", err diff --git a/cmd/macro_test.go b/cmd/macro_test.go new file mode 100644 index 00000000..b619a0f2 --- /dev/null +++ b/cmd/macro_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "encoding/json" + "testing" + + "github.com/abhinavxd/libredesk/internal/macro/models" + "github.com/abhinavxd/libredesk/internal/testutil" +) + +// Both list endpoints marshal these structs directly, their JSON shape is the API contract. +func TestMacroJSONShapes(t *testing.T) { + full := testutil.JSONKeys(t, models.Macro{Actions: json.RawMessage(`[]`)}) + for _, key := range []string{"id", "name", "actions", "visibility", "visible_when", "message_content", "user_id", "team_id", "usage_count", "created_at", "updated_at"} { + if !full[key] { + t.Errorf("Macro JSON is missing %q", key) + } + } + if full["has_message_content"] { + t.Error("Macro JSON must not have has_message_content") + } + + compact := testutil.JSONKeys(t, models.MacroCompact{Actions: json.RawMessage(`[]`)}) + for _, key := range []string{"id", "name", "actions", "visibility", "visible_when", "has_message_content", "user_id", "team_id", "usage_count", "created_at", "updated_at"} { + if !compact[key] { + t.Errorf("MacroCompact JSON is missing %q", key) + } + } + if compact["message_content"] { + t.Error("MacroCompact JSON must not have message_content") + } +} + +func TestDecorateMacroActions(t *testing.T) { + app := newValidatorTestApp(t) + + out, err := decorateMacroActions(app, json.RawMessage(`[{"type":"add_tags","value":["urgent"]}]`)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var actions []map[string]any + if err := json.Unmarshal(out, &actions); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + if len(actions) != 1 { + t.Fatalf("got %d actions, want 1", len(actions)) + } + if _, ok := actions[0]["display_value"]; !ok { + t.Error("decorated action is missing display_value") + } + + if _, err := decorateMacroActions(app, json.RawMessage(`{not json`)); err == nil { + t.Error("expected an error for malformed actions JSON") + } +} diff --git a/cmd/main.go b/cmd/main.go index 43636c87..41db7ef6 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -340,7 +340,7 @@ func main() { g.Router.NotFound = helpCenterHostNotFound(app, g) // Buffers above this are dropped rather than reused, and the ones we keep stay with the connection until it closes. - fasthttp.SetBodySizePoolLimit(64<<10, 1<<20) // request: 64 KiB, response: 1 MiB + fasthttp.SetBodySizePoolLimit(64<<10, 128<<10) // request: 64 KiB, response: 128 KiB s := &fasthttp.Server{ Name: appName, diff --git a/cmd/media.go b/cmd/media.go index c3483bd5..fb1234b0 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -1,8 +1,10 @@ package main import ( + "bytes" "encoding/json" "fmt" + "io" "mime" "net/http" "path/filepath" @@ -26,6 +28,12 @@ import ( // immutable suppresses revalidation, so this is how long a revoked file stays reachable from a client cache. const mediaCacheTTL = 24 * time.Hour +type preparedImageUpload struct { + thumbnail *bytes.Reader + meta []byte + thumbnailErr error +} + // handleMediaUpload handles media uploads. func handleMediaUpload(r *fastglue.Request) error { var ( @@ -123,29 +131,24 @@ func handleMediaUpload(r *fastglue.Request) error { // Generate and upload thumbnail and store image dimensions in the media meta. var meta = []byte("{}") if slices.Contains(image.Exts, srcExt) && image.IsImageByContent(file) { - file.Seek(0, 0) - thumbFile, err := image.CreateThumb(image.DefThumbSize, file) - if err != nil { - app.lo.Error("error creating thumb image", "error", err) - return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.GeneralError) - } - thumbName, _, err = app.media.Upload(thumbName, srcContentType, thumbFile) - if err != nil { - return sendErrorEnvelope(r, err) - } - - // Store image dimensions in media meta, storing dimensions for image previews in future. - file.Seek(0, 0) - width, height, err := image.GetDimensions(file) + prepared, err := prepareImageUpload(file) if err != nil { cleanUp = true app.lo.Error("error getting image dimensions", "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("globals.messages.errorUploadingFile"), nil, envelope.GeneralError) } - meta, _ = json.Marshal(map[string]interface{}{ - "width": width, - "height": height, - }) + if prepared.thumbnailErr != nil { + app.lo.Error("error creating thumb image", "error", prepared.thumbnailErr) + } else { + // A failed upload returns an empty name, keep the original so cleanup can delete a partial file. + uploadedThumb, _, err := app.media.Upload(thumbName, srcContentType, prepared.thumbnail) + if err != nil { + cleanUp = true + return sendErrorEnvelope(r, err) + } + thumbName = uploadedThumb + } + meta = prepared.meta } // Reset ptr. @@ -321,3 +324,26 @@ func cacheVisibility(private bool) string { } return "public" } + +func prepareImageUpload(file io.ReadSeeker) (preparedImageUpload, error) { + if _, err := file.Seek(0, io.SeekStart); err != nil { + return preparedImageUpload{}, err + } + thumbnail, thumbnailErr := image.CreateThumb(image.DefThumbSize, file) + + if _, err := file.Seek(0, io.SeekStart); err != nil { + return preparedImageUpload{}, err + } + width, height, err := image.GetDimensions(file) + if err != nil { + return preparedImageUpload{}, err + } + meta, err := json.Marshal(map[string]any{ + "width": width, + "height": height, + }) + if err != nil { + return preparedImageUpload{}, err + } + return preparedImageUpload{thumbnail: thumbnail, meta: meta, thumbnailErr: thumbnailErr}, nil +} diff --git a/cmd/media_test.go b/cmd/media_test.go new file mode 100644 index 00000000..3cc8f86a --- /dev/null +++ b/cmd/media_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "bytes" + "encoding/json" + "testing" +) + +func TestPrepareImageUploadAllowsOversizedImageWithoutThumbnail(t *testing.T) { + imageData := []byte{'G', 'I', 'F', '8', '9', 'a', 0x10, 0x27, 0x88, 0x13, 0x00, 0x00, 0x00} + + prepared, err := prepareImageUpload(bytes.NewReader(imageData)) + if err != nil { + t.Fatal(err) + } + if prepared.thumbnail != nil { + t.Fatal("expected thumbnail to be skipped") + } + if prepared.thumbnailErr == nil { + t.Fatal("expected thumbnail error to be retained") + } + + var meta struct { + Width int `json:"width"` + Height int `json:"height"` + } + if err := json.Unmarshal(prepared.meta, &meta); err != nil { + t.Fatal(err) + } + if meta.Width != 10_000 || meta.Height != 5_000 { + t.Fatalf("dimensions = %dx%d, want 10000x5000", meta.Width, meta.Height) + } +} diff --git a/cmd/messages.go b/cmd/messages.go index 147cb081..e6cc9b74 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -1,6 +1,7 @@ package main import ( + "slices" "strings" amodels "github.com/abhinavxd/libredesk/internal/auth/models" @@ -22,6 +23,7 @@ type messageReq struct { SenderType string `json:"sender_type"` Mentions []cmodels.MentionInput `json:"mentions"` EchoID string `json:"echo_id"` + SourceID string `json:"source_id"` // RFC 5322 Message-ID of the inbound message; stored on the created contact message so replies thread on it. Contact sender only. } // handleGetMessages returns messages for a conversation. @@ -198,6 +200,10 @@ func handleSendMessage(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError) } + if !canCreateConversationMessage(user, req) { + return r.SendErrorEnvelope(fasthttp.StatusForbidden, app.i18n.T("status.deniedPermission"), nil, envelope.PermissionError) + } + // Make sure the inbox is enabled. inbox, err := app.inbox.GetDBRecord(conv.InboxID) if err != nil { @@ -243,7 +249,7 @@ func handleSendMessage(r *fastglue.Request) error { // Create contact message. if req.SenderType == umodels.UserTypeContact { - message, err := app.conversation.CreateContactMessage(media, int(conv.ContactID), cuuid, req.Message, cmodels.ContentTypeHTML, false) + message, err := app.conversation.CreateContactMessage(media, int(conv.ContactID), cuuid, req.Message, cmodels.ContentTypeHTML, false, req.SourceID) if err != nil { return sendErrorEnvelope(r, err) } @@ -303,3 +309,12 @@ func resolveQuotedCIDs(app *App, msg *cmodels.Message) { msg.Content = strings.ReplaceAll(msg.Content, "cid:"+ref.ContentID, url) } } + +// canCreateConversationMessage returns whether the user may create a message of the requested visibility. +func canCreateConversationMessage(user umodels.User, req messageReq) bool { + requiredPermission := authzModels.PermMessagesWrite + if req.Private { + requiredPermission = authzModels.PermMessagesWritePrivate + } + return slices.Contains(user.Permissions, requiredPermission) +} diff --git a/cmd/messages_test.go b/cmd/messages_test.go index 48674d09..764446c3 100644 --- a/cmd/messages_test.go +++ b/cmd/messages_test.go @@ -4,9 +4,50 @@ import ( "testing" "github.com/abhinavxd/libredesk/internal/attachment" + authzmodels "github.com/abhinavxd/libredesk/internal/authz/models" cmodels "github.com/abhinavxd/libredesk/internal/conversation/models" + umodels "github.com/abhinavxd/libredesk/internal/user/models" + "github.com/lib/pq" ) +func TestCanCreateConversationMessagePermissionCombinations(t *testing.T) { + tests := []struct { + name string + permissions pq.StringArray + wantPublic bool + wantPrivateNote bool + }{ + {"both permissions", pq.StringArray{authzmodels.PermMessagesWrite, authzmodels.PermMessagesWritePrivate}, true, true}, + {"public message only", pq.StringArray{authzmodels.PermMessagesWrite}, true, false}, + {"private note only", pq.StringArray{authzmodels.PermMessagesWritePrivate}, false, true}, + {"neither permission", nil, false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + user := umodels.User{Permissions: tt.permissions} + public := canCreateConversationMessage(user, messageReq{SenderType: umodels.UserTypeAgent}) + privateNote := canCreateConversationMessage(user, messageReq{SenderType: umodels.UserTypeAgent, Private: true}) + if public != tt.wantPublic { + t.Errorf("public reply allowed = %v, want %v", public, tt.wantPublic) + } + if privateNote != tt.wantPrivateNote { + t.Errorf("private note allowed = %v, want %v", privateNote, tt.wantPrivateNote) + } + }) + } +} + +func TestContactMessageStillRequiresPublicMessagePermission(t *testing.T) { + req := messageReq{SenderType: umodels.UserTypeContact} + if canCreateConversationMessage(umodels.User{Permissions: pq.StringArray{authzmodels.PermMessagesWriteAsContact}}, req) { + t.Fatal("write_as_contact alone unexpectedly bypassed messages:write") + } + if !canCreateConversationMessage(umodels.User{Permissions: pq.StringArray{authzmodels.PermMessagesWrite}}, req) { + t.Fatal("messages:write should pass the base contact-message permission check") + } +} + func TestResolveAttachmentCIDs(t *testing.T) { tests := []struct { name string diff --git a/cmd/pagination_test.go b/cmd/pagination_test.go new file mode 100644 index 00000000..8376d684 --- /dev/null +++ b/cmd/pagination_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "fmt" + "testing" + + "github.com/abhinavxd/libredesk/internal/dbutil" + "github.com/valyala/fasthttp" + "github.com/zerodha/fastglue" +) + +func requestWithQuery(query string) *fastglue.Request { + ctx := &fasthttp.RequestCtx{} + ctx.Request.SetRequestURI("/api/v1/thing?" + query) + return &fastglue.Request{RequestCtx: ctx} +} + +func TestGetIDsParam(t *testing.T) { + tests := []struct { + query string + want []int + }{ + {"", nil}, + {"ids=", nil}, + {"ids=%20%20", nil}, + {"ids=5", []int{5}}, + {"ids=5,7,9", []int{5, 7, 9}}, + {"ids=%205%20,%207%20", []int{5, 7}}, + {"ids=5,5,5", []int{5, 5, 5}}, + {"ids=abc", nil}, + {"ids=0", nil}, + {"ids=-3", nil}, + {"ids=1,abc,2", []int{1, 2}}, + {"ids=1,,2", []int{1, 2}}, + {"ids=1,0,-2,3", []int{1, 3}}, + {"ids=9223372036854775808", nil}, + {"other=1", nil}, + } + + for _, tc := range tests { + got := getIDsParam(requestWithQuery(tc.query), "ids") + if len(got) != len(tc.want) { + t.Fatalf("query %q = %v, want %v", tc.query, got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("query %q = %v, want %v", tc.query, got, tc.want) + } + } + } +} + +func TestGetIDsParamCap(t *testing.T) { + query := "ids=1" + for i := 2; i <= maxIDsParam+50; i++ { + query += fmt.Sprintf(",%d", i) + } + + got := getIDsParam(requestWithQuery(query), "ids") + + if len(got) != maxIDsParam { + t.Fatalf("got %d ids, want the cap of %d", len(got), maxIDsParam) + } + if got[0] != 1 || got[maxIDsParam-1] != maxIDsParam { + t.Fatalf("cap dropped the wrong end: first %d, last %d", got[0], got[maxIDsParam-1]) + } +} + +func TestGetPaginationDefaultsAndClamps(t *testing.T) { + tests := []struct { + query string + wantPage int + wantPageSize int + }{ + {"", 1, 30}, + {"page=0&page_size=0", 1, 30}, + {"page=-4&page_size=-9", 1, 30}, + {"page=abc&page_size=abc", 1, 30}, + {"page=3&page_size=50", 3, 50}, + {"page_size=100000", 1, maxPageSize}, + } + + for _, tc := range tests { + page, pageSize := getPagination(requestWithQuery(tc.query)) + if page != tc.wantPage || pageSize != tc.wantPageSize { + t.Fatalf("query %q = page %d size %d, want page %d size %d", tc.query, page, pageSize, tc.wantPage, tc.wantPageSize) + } + } +} + +func TestGetOptionalPaginationFetchesEverythingWithoutParams(t *testing.T) { + tests := []struct { + query string + wantPage int + wantPageSize int + }{ + {"", 1, 0}, + {"page=2", 2, 0}, + {"page_size=0", 1, 0}, + {"page_size=-5", 1, 0}, + {"page=2&page_size=50", 2, 50}, + {"page_size=100000", 1, maxPageSize}, + } + + for _, tc := range tests { + page, pageSize := getOptionalPagination(requestWithQuery(tc.query)) + if page != tc.wantPage || pageSize != tc.wantPageSize { + t.Fatalf("query %q = page %d size %d, want page %d size %d", tc.query, page, pageSize, tc.wantPage, tc.wantPageSize) + } + } +} + +func TestPageOffset(t *testing.T) { + tests := []struct { + page int + pageSize int + want int + }{ + {1, 30, 0}, + {2, 30, 30}, + {3, 50, 100}, + {1, 0, 0}, + {5, 0, 0}, + } + + for _, tc := range tests { + if got := dbutil.PageOffset(tc.page, tc.pageSize); got != tc.want { + t.Fatalf("PageOffset(%d, %d) = %d, want %d", tc.page, tc.pageSize, got, tc.want) + } + } +} diff --git a/cmd/tags.go b/cmd/tags.go index a40b1255..61e62eaf 100644 --- a/cmd/tags.go +++ b/cmd/tags.go @@ -9,12 +9,21 @@ import ( "github.com/zerodha/fastglue" ) -// handleGetTags returns all tags from the database. +// handleGetTags returns tags from the database, all of them without page params. func handleGetTags(r *fastglue.Request) error { var ( - app = r.Context.(*App) + app = r.Context.(*App) + query = string(r.RequestCtx.QueryArgs().Peek("q")) ) - t, err := app.tag.GetAll() + if ids := getIDsParam(r, "ids"); len(ids) > 0 { + t, err := app.tag.GetByIDs(ids) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(t) + } + page, pageSize := getOptionalPagination(r) + t, err := app.tag.GetAll(query, page, pageSize) if err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/teams.go b/cmd/teams.go index 4727f2eb..bf7a8d5e 100644 --- a/cmd/teams.go +++ b/cmd/teams.go @@ -21,12 +21,21 @@ func handleGetTeams(r *fastglue.Request) error { return r.SendEnvelope(teams) } -// handleGetTeamsCompact returns a list of all teams in a compact format. +// handleGetTeamsCompact returns teams in a compact format, all of them without page params. func handleGetTeamsCompact(r *fastglue.Request) error { var ( - app = r.Context.(*App) + app = r.Context.(*App) + query = string(r.RequestCtx.QueryArgs().Peek("q")) ) - teams, err := app.team.GetAllCompact() + if ids := getIDsParam(r, "ids"); len(ids) > 0 { + teams, err := app.team.GetAllCompactByIDs(ids) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(teams) + } + page, pageSize := getOptionalPagination(r) + teams, err := app.team.GetAllCompact(query, page, pageSize) if err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 30d6229b..f33f2f37 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -47,6 +47,7 @@ var migList = []migFunc{ {"v2.5.0", migrations.V2_5_0}, {"v2.6.0", migrations.V2_6_0}, {"v2.8.0", migrations.V2_8_0}, + {"v2.9.0", migrations.V2_9_0}, } // upgrade upgrades the database to the current version by running SQL migration files diff --git a/cmd/users.go b/cmd/users.go index 8278d403..6e201b9c 100644 --- a/cmd/users.go +++ b/cmd/users.go @@ -65,10 +65,28 @@ func handleGetAgents(r *fastglue.Request) error { return r.SendEnvelope(agents) } -// handleGetAgentsCompact returns all agents in a compact format. +// handleGetAgentsCompact returns agents in a compact format, all of them without page params. func handleGetAgentsCompact(r *fastglue.Request) error { - var app = r.Context.(*App) - agents, err := app.user.GetAgentsCompact() + var ( + app = r.Context.(*App) + query = string(r.RequestCtx.QueryArgs().Peek("q")) + userType = string(r.RequestCtx.QueryArgs().Peek("type")) + enabledOnly = string(r.RequestCtx.QueryArgs().Peek("enabled")) == "true" + ) + switch userType { + case "", models.UserTypeAgent, models.UserTypeAIAssistant: + default: + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError) + } + if ids := getIDsParam(r, "ids"); len(ids) > 0 { + agents, err := app.user.GetAgentsCompactByIDs(ids) + if err != nil { + return sendErrorEnvelope(r, err) + } + return r.SendEnvelope(agents) + } + page, pageSize := getOptionalPagination(r) + agents, err := app.user.GetAgentsCompact(query, userType, enabledOnly, page, pageSize) if err != nil { return sendErrorEnvelope(r, err) } diff --git a/frontend/apps/main/src/App.vue b/frontend/apps/main/src/App.vue index 8bcedf5c..01933ee1 100644 --- a/frontend/apps/main/src/App.vue +++ b/frontend/apps/main/src/App.vue @@ -81,7 +81,6 @@ import { useInboxStore } from './stores/inbox' import { useUsersStore } from './stores/users' import { useTeamStore } from './stores/team' import { useSlaStore } from './stores/sla' -import { useMacroStore } from './stores/macro' import { useSharedViewStore } from './stores/sharedView' import { useTagStore } from './stores/tag' import { useCustomAttributeStore } from './stores/customAttributes' @@ -146,7 +145,6 @@ const usersStore = useUsersStore() const teamStore = useTeamStore() const inboxStore = useInboxStore() const slaStore = useSlaStore() -const macroStore = useMacroStore() const sharedViewStore = useSharedViewStore() const tagStore = useTagStore() const customAttributeStore = useCustomAttributeStore() @@ -197,7 +195,6 @@ const initStores = async () => { teamStore.fetchTeams(), inboxStore.fetchInboxes(), slaStore.fetchSlas(), - macroStore.loadMacros(), tagStore.fetchTags(), customAttributeStore.fetchCustomAttributes() ]) diff --git a/frontend/apps/main/src/api/index.js b/frontend/apps/main/src/api/index.js index 5fb4a93d..475d1307 100644 --- a/frontend/apps/main/src/api/index.js +++ b/frontend/apps/main/src/api/index.js @@ -229,7 +229,7 @@ const createTeam = (data) => http.post('/api/v1/teams', data, { 'Content-Type': 'application/json' } }) -const getTeamsCompact = () => http.get('/api/v1/teams/compact') +const getTeamsCompact = (params) => http.get('/api/v1/teams/compact', { params }) const deleteTeam = (id) => http.delete(`/api/v1/teams/${id}`) const updateUser = (id, data) => http.put(`/api/v1/agents/${id}`, data, { @@ -238,7 +238,7 @@ const updateUser = (id, data) => } }) const getUsers = () => http.get('/api/v1/agents') -const getUsersCompact = () => http.get('/api/v1/agents/compact') +const getUsersCompact = (params) => http.get('/api/v1/agents/compact', { params }) const updateCurrentUser = (data) => http.put('/api/v1/agents/me', data, { headers: { @@ -278,7 +278,7 @@ const createUser = (data) => 'Content-Type': 'application/json' } }) -const getTags = () => http.get('/api/v1/tags') +const getTags = (params) => http.get('/api/v1/tags', { params }) const importTags = (data) => http.post('/api/v1/tags/import', data, { headers: { @@ -349,7 +349,8 @@ const getConversation = (uuid) => http.get(`/api/v1/conversations/${uuid}`, { ab const getConversationTranscript = (uuid) => http.get(`/api/v1/conversations/${uuid}/transcript`, { responseType: 'blob' }) const getContactPageVisits = (uuid) => http.get(`/api/v1/conversations/${uuid}/page-visits`, { abortOnRoute: true }) -const getAllMacros = () => http.get('/api/v1/macros') +const getMacrosCompact = (params) => http.get('/api/v1/macros/compact', { params }) +const searchMacros = (params) => http.get('/api/v1/macros/search', { params }) const getMacro = (id) => http.get(`/api/v1/macros/${id}`) const createMacro = (data) => http.post('/api/v1/macros', data, { @@ -667,7 +668,8 @@ export default { getConversationTranscript, getCurrentUser, getCurrentUserTeams, - getAllMacros, + getMacrosCompact, + searchMacros, getMacro, createMacro, updateMacro, diff --git a/frontend/apps/main/src/components/combobox/SelectAgentCombobox.vue b/frontend/apps/main/src/components/combobox/SelectAgentCombobox.vue new file mode 100644 index 00000000..f4b14136 --- /dev/null +++ b/frontend/apps/main/src/components/combobox/SelectAgentCombobox.vue @@ -0,0 +1,97 @@ + + + diff --git a/frontend/apps/main/src/components/combobox/SelectCombobox.vue b/frontend/apps/main/src/components/combobox/SelectCombobox.vue index 3546ff4d..889226a7 100644 --- a/frontend/apps/main/src/components/combobox/SelectCombobox.vue +++ b/frontend/apps/main/src/components/combobox/SelectCombobox.vue @@ -6,6 +6,7 @@ :items="items" :placeholder="placeholder" :align="align" + :search="search" > @@ -111,6 +111,7 @@ import { useI18n } from 'vue-i18n' import { handleHTTPError } from '@shared-ui/utils/http.js' import { downloadBlobResponse, parseBlobError } from '@shared-ui/utils/file' import api from '@main/api' +import { permissions as perms } from '@main/constants/permissions.js' const conversationStore = useConversationStore() const userStore = useUserStore() const emitter = useEmitter() @@ -118,6 +119,9 @@ const { t } = useI18n() const route = useRoute() const router = useRouter() const isMobile = useIsMobile() +const canCompose = computed( + () => userStore.can(perms.MESSAGES_WRITE) || userStore.can(perms.MESSAGES_WRITE_PRIVATE) +) // Each detail route is `-conversation`. const goBackToList = () => { diff --git a/frontend/apps/main/src/features/conversation/CreateConversation.vue b/frontend/apps/main/src/features/conversation/CreateConversation.vue index 4b502e14..d6673a3e 100644 --- a/frontend/apps/main/src/features/conversation/CreateConversation.vue +++ b/frontend/apps/main/src/features/conversation/CreateConversation.vue @@ -148,15 +148,7 @@ ({{ $t('globals.terms.optional') }}) - + @@ -170,15 +162,7 @@ ({{ $t('globals.terms.optional') }}) - + @@ -286,8 +270,6 @@ import { MACRO_CONTEXT } from '@main/constants/conversation' import { useEmitter } from '@main/composables/useEmitter' import { handleHTTPError } from '@shared-ui/utils/http.js' import { useInboxStore } from '@main/stores/inbox' -import { useUsersStore } from '@main/stores/users' -import { useTeamStore } from '@main/stores/team' import { Select, SelectContent, @@ -300,7 +282,8 @@ import { useI18n } from 'vue-i18n' import { useFileUpload } from '@/composables/useFileUpload' import Editor from '@/components/editor/ConversationEditor.vue' import { useMacroStore } from '@/stores/macro' -import SelectComboBox from '@/components/combobox/SelectCombobox.vue' +import SelectAgentCombobox from '@/components/combobox/SelectAgentCombobox.vue' +import SelectTeamCombobox from '@/components/combobox/SelectTeamCombobox.vue' import { UserTypeAgent } from '@/constants/user' import { IdCard } from 'lucide-vue-next' import api from '@/api' @@ -314,8 +297,6 @@ const dialogOpen = defineModel({ const inboxStore = useInboxStore() const { t } = useI18n() -const uStore = useUsersStore() -const teamStore = useTeamStore() const emitter = useEmitter() const isCramped = useIsComposerCramped() const loading = ref(false) diff --git a/frontend/apps/main/src/features/conversation/ReplyBox.vue b/frontend/apps/main/src/features/conversation/ReplyBox.vue index f7a4dd9c..2df3b897 100644 --- a/frontend/apps/main/src/features/conversation/ReplyBox.vue +++ b/frontend/apps/main/src/features/conversation/ReplyBox.vue @@ -76,6 +76,8 @@ @filesDropped="uploadFiles" @aiPromptSelected="handleAiPromptSelected" :isGenerating="isGenerating" + :canSendReply="canSendReply" + :canSendPrivateNote="canSendPrivateNote" @generateReply="handleGenerateReply" class="h-full flex-grow" /> @@ -136,6 +138,8 @@ @filesDropped="uploadFiles" @aiPromptSelected="handleAiPromptSelected" :isGenerating="isGenerating" + :canSendReply="canSendReply" + :canSendPrivateNote="canSendPrivateNote" @generateReply="handleGenerateReply" /> @@ -175,6 +179,7 @@ import { useFileUpload } from '@main/composables/useFileUpload' import { hasInlineImage, hasPendingInlineUpload } from '@main/composables/useInlineImageUpload' import ReplyBoxContent from '@/features/conversation/ReplyBoxContent.vue' import { UserTypeAgent } from '@/constants/user' +import { permissions as perms } from '@main/constants/permissions.js' const { t } = useI18n() const conversationStore = useConversationStore() @@ -185,6 +190,16 @@ const userStore = useUserStore() const isCramped = useIsComposerCramped() useVisualViewportHeight() +const canSendReply = computed(() => userStore.can(perms.MESSAGES_WRITE)) +const canSendPrivateNote = computed(() => userStore.can(perms.MESSAGES_WRITE_PRIVATE)) +const defaultMessageType = computed(() => (canSendReply.value ? 'reply' : 'private_note')) +const isAllowedMessageType = (type) => + (type === 'reply' && canSendReply.value) || (type === 'private_note' && canSendPrivateNote.value) +const resolveAllowedDraftType = (uuid) => { + const type = conversationStore.resolveDraftType(uuid) + return isAllowedMessageType(type) ? type : defaultMessageType.value +} + // Setup file upload composable const { uploadingFiles, @@ -205,14 +220,15 @@ watch( async (uuid, prevUuid) => { if (prevUuid) conversationStore.setSelectedDraftType(prevUuid, messageType.value) if (!uuid) { - messageType.value = 'reply' + messageType.value = defaultMessageType.value return } - messageType.value = conversationStore.resolveDraftType(uuid) + const initialType = resolveAllowedDraftType(uuid) + messageType.value = initialType // Prefetch may still be in flight on first load; re-resolve once drafts land. await conversationStore.draftsReady - if (uuid !== currentConversationUUID.value) return - messageType.value = conversationStore.resolveDraftType(uuid) + if (uuid !== currentConversationUUID.value || messageType.value !== initialType) return + messageType.value = resolveAllowedDraftType(uuid) }, { immediate: true } ) @@ -277,7 +293,7 @@ const handleGenerateReply = () => // Copilot's "Insert into reply" replaces the draft with its answer (already HTML from the panel), // forcing reply mode so a private note in progress does not silently receive customer-facing text. const handleCopilotInsertReply = (html) => { - if (!html) return + if (!html || !canSendReply.value) return if (messageType.value === 'private_note') messageType.value = 'reply' htmlContent.value = html } @@ -311,6 +327,8 @@ const processSend = async (skipContactEmailCheck = false, skipMissingTagsCheck = const convUUID = conversationStore.current.uuid const isPrivate = messageType.value === 'private_note' + if ((isPrivate && !canSendPrivateNote.value) || (!isPrivate && !canSendReply.value)) return + const currentInbox = inboxStore.inboxes.find( (i) => i.id === conversationStore.current.inbox_id ) diff --git a/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue b/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue index b68e8da4..b28f8f8c 100644 --- a/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue +++ b/frontend/apps/main/src/features/conversation/ReplyBoxContent.vue @@ -3,28 +3,32 @@
- - + + {{ $t('globals.terms.reply') }} {{ $t('globals.terms.privateNote') }} -
@@ -32,43 +36,39 @@
-
- +
+
-
- +
+ -
-
- +
+
@@ -77,7 +77,7 @@

{{ error }}

@@ -124,7 +124,7 @@