diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index eec9aad9..efd5bed3 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -27,6 +27,11 @@ jobs: image: redis ports: - 6379:6379 + mailhog: + image: mailhog/mailhog + ports: + - 1025:1025 + - 8025:8025 steps: - uses: actions/checkout@v3 @@ -42,7 +47,7 @@ jobs: node-version: "20" - name: Install pnpm - run: npm install -g pnpm + run: npm install -g pnpm@9.15.3 - name: Install cypress deps run: sudo apt-get update && sudo apt-get install -y libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2t64 libxtst6 xauth xvfb @@ -62,6 +67,11 @@ jobs: - name: Install db schema and run tests env: LIBREDESK_SYSTEM_USER_PASSWORD: "StrongPass!123" + # E2E runs over plain HTTP, so the Secure session/csrf cookies must be off + # or real login + writes would be rejected. + LIBREDESK_APP__SERVER__DISABLE_SECURE_COOKIES: "true" + CYPRESS_SYSTEM_PASSWORD: "StrongPass!123" + CYPRESS_MAILHOG_URL: "http://localhost:8025" run: | ./libredesk --install --idempotent-install --yes --config ./config.toml ./libredesk --upgrade --yes --config ./config.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e3367a7b..58f46c34 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,10 +46,10 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v3 with: - node-version: '18.12' + node-version: "20" - name: Install pnpm - run: npm install -g pnpm + run: npm install -g pnpm@9.15.3 - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0b422a82..59993f38 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,21 +2,23 @@ Thanks for your interest in contributing. -## Contribution Guidelines -- **All PRs must align with the project’s scope, goals, and technical direction.** -- **Large or non-trivial PRs should be discussed first via an issue.** -- Bug fixes and documentation improvements do **not** require prior discussion. -- Please keep PRs focused on a single concern where possible. - -## Process +## Before you start -- **Propose first (for large changes)** - Open an issue describing what you want to build, why it fits libredesk, - and the high-level implementation approach. +- All contributions should align with the project's scope, goals, and technical direction. +- Bug fixes and documentation improvements can go straight to a PR, no prior discussion needed. +- Anything larger should start with an issue. See below for why. +- Keep changes focused on a single concern wherever possible. -- **Keep PRs small** - Smaller, well-scoped PRs are easier to review and test. - Bundling multiple features into a single PR is discouraged. +## Pull Requests + +PRs are tricky. Any change, whether a new feature or a small tweak, has to fit the project's scope, goals, and technical direction. The code needs to match libredesk's existing style and conventions, and its impact on performance, usability, and stability should be clear before it can be merged. + +That makes reviewing a slow, careful process. Larger PRs are harder to reason about, and reviewing them properly, discussing changes, and confirming they're safe to merge often takes more effort than writing the PR did. Whether a PR is accepted, and on what timeline, is ultimately up to the maintainers. This isn't personal. Maintainers carry the long-term context for the project, so decisions on direction and trade-offs sit with them. + +To keep things moving: + +1. **Propose first.** Open an issue describing what you want to build, why it fits libredesk, and how you plan to implement it, with relevant technical detail, before writing code. The change might not align with the project's direction, or it could already be in progress. Checking first saves everyone time. +2. **Keep PRs small.** Send focused PRs with a clear, single scope wherever possible. Smaller PRs are easier to review and test. Bundling multiple features into one PR is strongly discouraged. Thanks for helping keep libredesk focused and maintainable. diff --git a/ROADMAP.md b/ROADMAP.md index 04b938d0..5e1bcf09 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,8 +4,9 @@ The open-source customer support platform you actually own. One inbox for email, ## Near Term - WhatsApp channel - WIP -- Contact notes in conversation sidebar - WIP -- Contact merging - WIP +- Telegram channel - WIP +- Contact merging - TODO +- 2FA for password login - TODO - Deletable private notes - TODO - Help center with articles - TODO - AI assistants - TODO diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..8d13f3db --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security Reports + +Report vulnerabilities privately via GitHub Security Advisories: https://github.com/abhinavxd/libredesk/security/advisories + +## Threat model + +Libredesk is **self-hosted and single-tenant**. Agents and admins are trusted internal staff. The only untrusted surfaces are the **livechat widget** (anonymous contacts) and **inbound email**. +The permission system is a policy layer over already-trusted users, not a boundary between mutually distrusting parties. Admin permissions (`*:manage`, `*:read_all`) grant full control over their scope and are not granted by default. + +## Out of scope + +- An admin exercising a documented admin capability (configuring webhooks, OIDC providers, automations, templates, inboxes, etc.). It is up to the operator to grant these capabilities only to trusted users. Defects in the admin code paths (sqli, RCE, auth bypass etc.) remain in scope. +- SSRF via admin-configured outbound URLs (webhooks, OIDC provider discovery, etc.). + +Anything else is in scope. diff --git a/cmd/actvity_log.go b/cmd/actvity_log.go index efbf1670..e0012439 100644 --- a/cmd/actvity_log.go +++ b/cmd/actvity_log.go @@ -15,7 +15,7 @@ func handleGetActivityLogs(r *fastglue.Request) error { total = 0 ) page, pageSize := getPagination(r) - logs, err := app.activityLog.GetAll(order, orderBy, filters, page, pageSize) + logs, err := app.activityLog.GetAll(order, orderBy, filters, page, pageSize, app.setting.GetAppTimezone()) if err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/auth.go b/cmd/auth.go index d7b49847..8f089c8d 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -103,6 +103,7 @@ func handleOIDCCallback(r *fastglue.Request) error { if err := app.user.UpdateLastLoginAt(user.ID); err != nil { return sendErrorEnvelope(r, err) } + app.user.InvalidateAgentCache(user.ID) // Insert activity log. if err := app.activityLog.Login(user.ID, user.Email.String, ip); err != nil { diff --git a/cmd/contacts.go b/cmd/contacts.go index 55f55860..71593370 100644 --- a/cmd/contacts.go +++ b/cmd/contacts.go @@ -32,7 +32,7 @@ func handleGetContacts(r *fastglue.Request) error { total = 0 ) page, pageSize := getPagination(r) - contacts, err := app.user.GetContacts(page, pageSize, order, orderBy, filters) + contacts, err := app.user.GetContacts(page, pageSize, order, orderBy, filters, app.setting.GetAppTimezone()) if err != nil { return sendErrorEnvelope(r, err) } @@ -237,7 +237,7 @@ func handleDeleteContactNote(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError) } - agent, err := app.user.GetAgent(auser.ID, "") + agent, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/context_links.go b/cmd/context_links.go index 64361695..76bc90f0 100644 --- a/cmd/context_links.go +++ b/cmd/context_links.go @@ -5,8 +5,8 @@ import ( "strings" amodels "github.com/abhinavxd/libredesk/internal/auth/models" - "github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/context_link/models" + "github.com/abhinavxd/libredesk/internal/envelope" "github.com/abhinavxd/libredesk/internal/stringutil" "github.com/valyala/fasthttp" "github.com/zerodha/fastglue" @@ -141,12 +141,11 @@ func handleGetContextLinkURL(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError) } - // Verify agent has access to this conversation. - user, err := app.user.GetAgent(auser.ID, "") + agent, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } - conv, err := enforceConversationAccess(app, conversationUUID, user) + conv, err := enforceConversationAccess(app, conversationUUID, agent) if err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/conversation.go b/cmd/conversation.go index 7542f46e..67c013e4 100644 --- a/cmd/conversation.go +++ b/cmd/conversation.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "fmt" "slices" "strconv" "time" @@ -201,7 +202,7 @@ func handleGetViewConversations(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -320,7 +321,7 @@ func handleGetConversation(r *fastglue.Request) error { auser = r.RequestCtx.UserValue("user").(amodels.User) ) - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -335,6 +336,40 @@ func handleGetConversation(r *fastglue.Request) error { return r.SendEnvelope(conv) } +// handleDownloadConversationTranscript sends the conversation transcript as a text file download. +func handleDownloadConversationTranscript(r *fastglue.Request) error { + var ( + app = r.Context.(*App) + uuid = r.RequestCtx.UserValue("uuid").(string) + auser = r.RequestCtx.UserValue("user").(amodels.User) + ) + + user, err := app.user.GetAgentCachedOrLoad(auser.ID) + if err != nil { + return sendErrorEnvelope(r, err) + } + + conversation, err := enforceConversationAccess(app, uuid, user) + if err != nil { + return sendErrorEnvelope(r, err) + } + + private := false + messages, err := app.conversation.GetAllConversationMessages(uuid, &private, []string{cmodels.MessageIncoming, cmodels.MessageOutgoing}) + if err != nil { + return sendErrorEnvelope(r, err) + } + + transcript := app.conversation.BuildTranscript(*conversation, messages, time.Now()) + safeRef := stringutil.SanitizeFilename(conversation.ReferenceNumber) + filename := fmt.Sprintf("transcript-%s.txt", safeRef) + r.RequestCtx.Response.Header.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + r.RequestCtx.Response.Header.Set("X-Content-Type-Options", "nosniff") + r.RequestCtx.SetContentType("text/plain; charset=utf-8") + r.RequestCtx.SetBody(transcript) + return nil +} + // handleGetContactPageVisits returns the recent page visits for the contact of a conversation. func handleGetContactPageVisits(r *fastglue.Request) error { var ( @@ -343,7 +378,7 @@ func handleGetContactPageVisits(r *fastglue.Request) error { auser = r.RequestCtx.UserValue("user").(amodels.User) ) - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -364,7 +399,7 @@ func handleUpdateConversationAssigneeLastSeen(r *fastglue.Request) error { uuid = r.RequestCtx.UserValue("uuid").(string) auser = r.RequestCtx.UserValue("user").(amodels.User) ) - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -399,7 +434,7 @@ func handleMarkConversationAsUnread(r *fastglue.Request) error { uuid = r.RequestCtx.UserValue("uuid").(string) auser = r.RequestCtx.UserValue("user").(amodels.User) ) - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -421,7 +456,7 @@ func handleGetConversationParticipants(r *fastglue.Request) error { uuid = r.RequestCtx.UserValue("uuid").(string) auser = r.RequestCtx.UserValue("user").(amodels.User) ) - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -450,7 +485,7 @@ func handleUpdateUserAssignee(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError) } - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -488,7 +523,7 @@ func handleUpdateTeamAssignee(r *fastglue.Request) error { assigneeID := req.AssigneeID - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -533,7 +568,7 @@ func handleUpdateConversationPriority(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`priority`"), nil, envelope.InputError) } - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -580,7 +615,7 @@ func handleUpdateConversationStatus(r *fastglue.Request) error { } // Enforce conversation access. - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -636,7 +671,7 @@ func handleUpdateConversationtags(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError) } - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -665,7 +700,7 @@ func handleUpdateConversationCustomAttributes(r *fastglue.Request) error { } // Enforce conversation access. - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -695,7 +730,7 @@ func handleUpdateContactCustomAttributes(r *fastglue.Request) error { } // Enforce conversation access. - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -734,7 +769,7 @@ func handleRemoveUserAssignee(r *fastglue.Request) error { uuid = r.RequestCtx.UserValue("uuid").(string) auser = r.RequestCtx.UserValue("user").(amodels.User) ) - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -755,7 +790,7 @@ func handleRemoveTeamAssignee(r *fastglue.Request) error { uuid = r.RequestCtx.UserValue("uuid").(string) auser = r.RequestCtx.UserValue("user").(amodels.User) ) - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -797,7 +832,7 @@ func handleCreateConversation(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/draft.go b/cmd/draft.go index 9a02ccd3..c0cffe36 100644 --- a/cmd/draft.go +++ b/cmd/draft.go @@ -26,7 +26,7 @@ func handleUpsertConversationDraft(r *fastglue.Request) error { req = draftReq{} ) - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -66,7 +66,7 @@ func handleGetAllDrafts(r *fastglue.Request) error { auser = r.RequestCtx.UserValue("user").(amodels.User) ) - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -87,7 +87,7 @@ func handleDeleteConversationDraft(r *fastglue.Request) error { uuid = r.RequestCtx.UserValue("uuid").(string) ) - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/handlers.go b/cmd/handlers.go index 466f4625..cc2c1abe 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -72,6 +72,7 @@ func initHandlers(g *fastglue.Fastglue, hub *ws.Hub) { g.GET("/api/v1/conversations/{uuid}/page-visits", perm(handleGetContactPageVisits, "conversations:read")) 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.PUT("/api/v1/conversations/{cuuid}/messages/{uuid}/retry", perm(handleRetryMessage, "messages:write")) g.POST("/api/v1/conversations", perm(handleCreateConversation, "conversations:write")) diff --git a/cmd/inboxes.go b/cmd/inboxes.go index ca1bc550..22c51ccb 100644 --- a/cmd/inboxes.go +++ b/cmd/inboxes.go @@ -600,6 +600,7 @@ func validateEmailConfig(app *App, configJSON json.RawMessage) error { func trimInboxFields(inb *imodels.Inbox) error { inb.Name = strings.TrimSpace(inb.Name) inb.From = strings.TrimSpace(inb.From) + inb.FromNameTemplate = strings.TrimSpace(inb.FromNameTemplate) // WhatsApp has no separate display-name field; the inbox name is the message From. if inb.Channel == whatsappChannel.ChannelWhatsApp && inb.From == "" { diff --git a/cmd/init.go b/cmd/init.go index 13611374..3677784d 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -405,7 +405,7 @@ func initCSAT(db *sqlx.DB, i18n *i18n.I18n) *csat.Manager { // initWS inits websocket hub. func initWS(user *user.Manager) *ws.Hub { - return ws.NewHub(user) + return ws.NewHub(initLogger("ws"), user) } // getCustomStaticDir returns the custom static directory path from CLI flag or config. @@ -674,6 +674,7 @@ func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrS } config.From = inboxRecord.From + config.FromNameTemplate = inboxRecord.FromNameTemplate if len(config.From) == 0 { log.Printf("WARNING: No `from` email address set for `%s` inbox: Name: `%s`", inboxRecord.Channel, inboxRecord.Name) @@ -700,6 +701,7 @@ func initEmailInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, usrS inbox, err := email.New(msgStore, usrStore, email.Opts{ ID: inboxRecord.ID, + Name: inboxRecord.Name, Config: config, Lo: initLogger("email_inbox"), TokenRefreshCallback: tokenRefreshCallback, @@ -729,6 +731,7 @@ func initLiveChatInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, u inbox, err := livechat.New(msgStore, usrStore, livechat.Opts{ ID: inboxRecord.ID, + Name: inboxRecord.Name, Config: config, Lo: initLogger("livechat_inbox"), SignAvatarURL: signAvatarURL, @@ -752,6 +755,7 @@ func initWhatsAppInbox(inboxRecord imodels.Inbox, msgStore inbox.MessageStore, c inb, err := whatsappChannel.New(msgStore, whatsappChannel.Opts{ ID: inboxRecord.ID, + Name: inboxRecord.Name, Config: config, From: inboxRecord.From, Client: client, diff --git a/cmd/login.go b/cmd/login.go index 31dc1fd5..e4c6a1e7 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -60,6 +60,7 @@ func handleLogin(r *fastglue.Request) error { if err := app.user.UpdateLastLoginAt(user.ID); err != nil { return sendErrorEnvelope(r, err) } + app.user.InvalidateAgentCache(user.ID) // Insert activity log. if err := app.activityLog.Login(user.ID, user.Email.String, ip); err != nil { diff --git a/cmd/macro.go b/cmd/macro.go index d0a32db9..e0257886 100644 --- a/cmd/macro.go +++ b/cmd/macro.go @@ -144,7 +144,7 @@ func handleApplyMacro(r *fastglue.Request) error { id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) incomingActions = []autoModels.RuleAction{} ) - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/main.go b/cmd/main.go index fb6b21b7..608dadb7 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -50,6 +50,7 @@ import ( "github.com/abhinavxd/libredesk/internal/webhook" whatsappapi "github.com/abhinavxd/libredesk/internal/whatsapp" whatsappTemplate "github.com/abhinavxd/libredesk/internal/whatsapp_template" + "github.com/abhinavxd/libredesk/internal/ws" "github.com/knadh/go-i18n" "github.com/knadh/koanf/v2" "github.com/knadh/stuffbin" @@ -118,6 +119,7 @@ type App struct { whatsappIngester *WhatsAppIngester // WhatsApp inbox IDs whose Meta token was recently rejected, keyed to the last error time. whatsappAuthErrors sync.Map + wsHub *ws.Hub // Global state that stores data on an available app update. update *AppUpdate @@ -298,6 +300,7 @@ func main() { userNotification: userNotification, whatsappClient: waClient, whatsappTemplate: waTemplates, + wsHub: wsHub, } app.consts.Store(constants) whatsappIngester, err := newWhatsAppIngester(app) @@ -328,10 +331,10 @@ func main() { go func() { colorlog.Green("Server started at %s", ko.String("app.server.address")) - if ko.String("server.socket") != "" { - colorlog.Green("Unix socket created at %s", ko.String("server.socket")) + if ko.String("app.server.socket") != "" { + colorlog.Green("Unix socket created at %s", ko.String("app.server.socket")) } - if err := g.ListenAndServe(ko.String("app.server.address"), ko.String("server.socket"), s); err != nil { + if err := g.ListenAndServe(ko.String("app.server.address"), ko.String("app.server.socket"), s); err != nil { log.Fatalf("error starting server: %v", err) } }() @@ -379,7 +382,7 @@ func onUsersOffline(conv *conversation.Manager) func([]umodels.OfflineUser) { for _, u := range users { switch u.Type { case umodels.UserTypeAgent: - conv.BroadcastAgentStatusToWidget(u.ID, umodels.Offline) + conv.BroadcastAgentAvailability(u.ID, umodels.Offline) case umodels.UserTypeContact, umodels.UserTypeVisitor: conv.BroadcastContactUpdate(u.ID, map[string]any{"availability_status": umodels.Offline}) } diff --git a/cmd/media.go b/cmd/media.go index 442442d1..83ea806f 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -165,7 +165,7 @@ func handleServeMedia(r *fastglue.Request) error { // Session/API key authenticated - perform full permission check. auser := r.RequestCtx.UserValue("user").(amodels.User) - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/messages.go b/cmd/messages.go index 70c3ba30..03324d47 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -52,7 +52,7 @@ func handleGetMessages(r *fastglue.Request) error { msgTypes = append(msgTypes, string(v)) } - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -109,7 +109,7 @@ func handleGetMessage(r *fastglue.Request) error { cuuid = r.RequestCtx.UserValue("cuuid").(string) auser = r.RequestCtx.UserValue("user").(amodels.User) ) - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -125,6 +125,10 @@ func handleGetMessage(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } + if message.ConversationUUID != cuuid { + return sendErrorEnvelope(r, envelope.NewError(envelope.PermissionError, "Permission denied", nil)) + } + // Process CSAT status for the message (will only affect CSAT messages) messages := []cmodels.Message{message} app.conversation.ProcessCSATStatus(messages) @@ -155,7 +159,7 @@ func handleRetryMessage(r *fastglue.Request) error { auser = r.RequestCtx.UserValue("user").(amodels.User) ) - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -190,7 +194,7 @@ func handleSendMessage(r *fastglue.Request) error { req = messageReq{} ) - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } diff --git a/cmd/roles.go b/cmd/roles.go index bb5fb5d1..be862379 100644 --- a/cmd/roles.go +++ b/cmd/roles.go @@ -43,13 +43,16 @@ func handleDeleteRole(r *fastglue.Request) error { app = r.Context.(*App) id, _ = strconv.Atoi(r.RequestCtx.UserValue("id").(string)) ) + userIDs, _ := app.user.GetUserIDsByRole(id) if err := app.role.Delete(id); err != nil { return sendErrorEnvelope(r, err) } - // Invalidate all caches when role is deleted. app.user.InvalidateAllAgentCache() - app.authz.InvalidateAllCache() + + for _, uid := range userIDs { + app.wsHub.KickUser(uid) + } return r.SendEnvelope(true) } @@ -94,12 +97,18 @@ func handleUpdateRole(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - // Log permission changes and invalidate caches. added, removed := role.ComparePermissions(oldRole.Permissions, updatedRole.Permissions) if len(added) > 0 || len(removed) > 0 { - // Invalidate all caches when role permissions change. app.user.InvalidateAllAgentCache() - app.authz.InvalidateAllCache() + + if len(removed) > 0 { + userIDs, err := app.user.GetUserIDsByRole(updatedRole.ID) + if err == nil { + for _, id := range userIDs { + app.wsHub.KickUser(id) + } + } + } if err := app.activityLog.RolePermissionsChanged(auser.ID, auser.Email, ip, updatedRole.ID, updatedRole.Name, added, removed); err != nil { app.lo.Error("error creating activity log", "error", err) diff --git a/cmd/search.go b/cmd/search.go index 1380a383..50d9c83c 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -3,7 +3,9 @@ package main import ( "fmt" + amodels "github.com/abhinavxd/libredesk/internal/auth/models" "github.com/abhinavxd/libredesk/internal/envelope" + smodels "github.com/abhinavxd/libredesk/internal/search/models" "github.com/zerodha/fastglue" ) @@ -13,45 +15,87 @@ const ( // handleSearchConversations searches conversations based on the query. func handleSearchConversations(r *fastglue.Request) error { - app := r.Context.(*App) - wrapper := func(query string) (interface{}, error) { - return app.search.Conversations(query) + app, user, q, err := searchInputs(r) + if err != nil { + return sendErrorEnvelope(r, err) } - return handleSearch(r, wrapper) + results, err := app.search.Conversations(q) + if err != nil { + return sendErrorEnvelope(r, err) + } + uuids := make([]string, len(results)) + for i, c := range results { + uuids[i] = c.UUID + } + allowed, err := app.conversation.FilterAuthorizedListUUIDs(user.ID, uuids) + if err != nil { + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) + } + set := uuidSet(allowed) + out := make([]smodels.ConversationResult, 0, len(allowed)) + for _, c := range results { + if _, ok := set[c.UUID]; ok { + out = append(out, c) + } + } + return r.SendEnvelope(out) } // handleSearchMessages searches messages based on the query. func handleSearchMessages(r *fastglue.Request) error { - app := r.Context.(*App) - wrapper := func(query string) (interface{}, error) { - return app.search.Messages(query) + app, user, q, err := searchInputs(r) + if err != nil { + return sendErrorEnvelope(r, err) } - return handleSearch(r, wrapper) + results, err := app.search.Messages(q) + if err != nil { + return sendErrorEnvelope(r, err) + } + uuids := make([]string, len(results)) + for i, m := range results { + uuids[i] = m.ConversationUUID + } + allowed, err := app.conversation.FilterAuthorizedListUUIDs(user.ID, uuids) + if err != nil { + return sendErrorEnvelope(r, envelope.NewError(envelope.GeneralError, app.i18n.T("globals.messages.somethingWentWrong"), nil)) + } + set := uuidSet(allowed) + out := make([]smodels.MessageResult, 0, len(allowed)) + for _, m := range results { + if _, ok := set[m.ConversationUUID]; ok { + out = append(out, m) + } + } + return r.SendEnvelope(out) } // handleSearchContacts searches contacts based on the query. func handleSearchContacts(r *fastglue.Request) error { - app := r.Context.(*App) - wrapper := func(query string) (interface{}, error) { - return app.search.Contacts(query) + app, _, q, err := searchInputs(r) + if err != nil { + return sendErrorEnvelope(r, err) } - return handleSearch(r, wrapper) -} - -// handleSearch searches for the given query using the provided search function. -func handleSearch(r *fastglue.Request, searchFunc func(string) (interface{}, error)) error { - var ( - app = r.Context.(*App) - q = string(r.RequestCtx.QueryArgs().Peek("query")) - ) - - if len(q) < minSearchQueryLength { - return sendErrorEnvelope(r, envelope.NewError(envelope.InputError, app.i18n.Ts("search.minQueryLength", "length", fmt.Sprintf("%d", minSearchQueryLength)), nil)) - } - - results, err := searchFunc(q) + results, err := app.search.Contacts(q) if err != nil { return sendErrorEnvelope(r, err) } return r.SendEnvelope(results) } + +func searchInputs(r *fastglue.Request) (*App, amodels.User, string, error) { + app := r.Context.(*App) + user, _ := r.RequestCtx.UserValue("user").(amodels.User) + q := string(r.RequestCtx.QueryArgs().Peek("query")) + if len(q) < minSearchQueryLength { + return app, user, "", envelope.NewError(envelope.InputError, app.i18n.Ts("search.minQueryLength", "length", fmt.Sprintf("%d", minSearchQueryLength)), nil) + } + return app, user, q, nil +} + +func uuidSet(uuids []string) map[string]struct{} { + s := make(map[string]struct{}, len(uuids)) + for _, u := range uuids { + s[u] = struct{}{} + } + return s +} diff --git a/cmd/settings.go b/cmd/settings.go index cd057401..ab90ccd3 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -52,6 +52,9 @@ func handleUpdateGeneralSettings(r *fastglue.Request) error { req.FaviconURL = strings.TrimSpace(req.FaviconURL) req.LogoURL = strings.TrimSpace(req.LogoURL) req.Timezone = strings.TrimSpace(req.Timezone) + if req.Timezone != "" && !stringutil.IsValidTimezone(req.Timezone) { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Invalid timezone.", nil, envelope.InputError) + } // Trim whitespace and trailing slash from root URL. req.RootURL = strings.TrimRight(strings.TrimSpace(req.RootURL), "/") diff --git a/cmd/teams.go b/cmd/teams.go index 93933c9f..4727f2eb 100644 --- a/cmd/teams.go +++ b/cmd/teams.go @@ -87,6 +87,14 @@ func handleUpdateTeam(r *fastglue.Request) error { if err != nil { return sendErrorEnvelope(r, err) } + members, err := app.team.GetMembers(id) + if err != nil { + app.lo.Error("error fetching team members for cache invalidation", "team_id", id, "error", err) + } else { + for _, m := range members { + app.user.InvalidateAgentCache(m.ID) + } + } return r.SendEnvelope(updatedTeam) } @@ -99,9 +107,12 @@ func handleDeleteTeam(r *fastglue.Request) error { if err != nil || id == 0 { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError) } - err = app.team.Delete(id) + memberIDs, err := app.team.Delete(id) if err != nil { return sendErrorEnvelope(r, err) } + for _, mid := range memberIDs { + app.user.InvalidateAgentCache(mid) + } return r.SendEnvelope(true) } diff --git a/cmd/templates.go b/cmd/templates.go index 52e9cf0a..c8eda7d4 100644 --- a/cmd/templates.go +++ b/cmd/templates.go @@ -53,6 +53,9 @@ func handleCreateTemplate(r *fastglue.Request) error { if req.Name == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError) } + if req.Type == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`type`"), nil, envelope.InputError) + } template, err := app.tmpl.Create(req) if err != nil { return sendErrorEnvelope(r, err) @@ -77,6 +80,9 @@ func handleUpdateTemplate(r *fastglue.Request) error { if req.Name == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`name`"), nil, envelope.InputError) } + if req.Type == "" { + return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`type`"), nil, envelope.InputError) + } updatedTemplate, err := app.tmpl.Update(id, req) if err != nil { return sendErrorEnvelope(r, err) diff --git a/cmd/upgrade.go b/cmd/upgrade.go index e6093d57..2a8da750 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -43,6 +43,7 @@ var migList = []migFunc{ {"v2.0.0", migrations.V2_0_0}, {"v2.2.0", migrations.V2_2_0}, {"v2.3.0", migrations.V2_3_0}, + {"v2.4.0", migrations.V2_4_0}, {"v3.0.0", migrations.V3_0_0}, } diff --git a/cmd/users.go b/cmd/users.go index 2282a303..416d1d77 100644 --- a/cmd/users.go +++ b/cmd/users.go @@ -101,7 +101,7 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), nil, envelope.InputError) } - agent, err := app.user.GetAgent(auser.ID, "") + agent, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -119,8 +119,9 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error { if err := app.user.UpdateAvailability(auser.ID, availReq.Status); err != nil { return sendErrorEnvelope(r, err) } + app.user.InvalidateAgentCache(auser.ID) - go app.conversation.BroadcastAgentStatusToWidget(auser.ID, availReq.Status) + go app.conversation.BroadcastAgentAvailability(auser.ID, availReq.Status) // Skip activity log when returning online from idle-away to avoid log spam. if !(agent.AvailabilityStatus == models.Away && availReq.Status == models.Online) { @@ -129,7 +130,7 @@ func handleUpdateAgentAvailability(r *fastglue.Request) error { } } - agent, err = app.user.GetAgent(auser.ID, "") + agent, err = app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -166,7 +167,7 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error { // Upload avatar? if ok && len(files) > 0 { - agent, err := app.user.GetAgent(auser.ID, "") + agent, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -176,7 +177,7 @@ func handleUpdateCurrentAgent(r *fastglue.Request) error { } // Fetch updated agent and return. - agent, err := app.user.GetAgent(auser.ID, "") + agent, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -278,8 +279,8 @@ func handleUpdateAgent(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } - // Invalidate authz cache. - defer app.authz.InvalidateUserCache(id) + app.user.InvalidateAgentCache(id) + app.wsHub.KickUser(id) // Create activity log if user availability status changed. if oldAvailabilityStatus != req.AvailabilityStatus { @@ -330,6 +331,9 @@ func handleDeleteAgent(r *fastglue.Request) error { return sendErrorEnvelope(r, err) } + defer app.wsHub.KickUser(id) + defer app.user.InvalidateAgentCache(id) + // Unassign all open conversations assigned to the user. if err := app.conversation.UnassignOpen(id); err != nil { return sendErrorEnvelope(r, err) @@ -344,7 +348,7 @@ func handleGetCurrentAgent(r *fastglue.Request) error { app = r.Context.(*App) auser = r.RequestCtx.UserValue("user").(amodels.User) ) - u, err := app.user.GetAgent(auser.ID, "") + u, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -359,7 +363,7 @@ func handleDeleteCurrentAgentAvatar(r *fastglue.Request) error { ) // Get user - agent, err := app.user.GetAgent(auser.ID, "") + agent, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -379,6 +383,7 @@ func handleDeleteCurrentAgentAvatar(r *fastglue.Request) error { if err = app.user.UpdateAvatar(agent.ID, ""); err != nil { return sendErrorEnvelope(r, err) } + app.user.InvalidateAgentCache(agent.ID) return r.SendEnvelope(true) } @@ -455,9 +460,12 @@ func handleSetPassword(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "{globals.terms.password}"), nil, envelope.InputError) } - if err := app.user.ResetPassword(req.Token, req.Password); err != nil { + id, err := app.user.ResetPassword(req.Token, req.Password) + if err != nil { return sendErrorEnvelope(r, err) } + app.user.InvalidateAgentCache(id) + app.wsHub.KickUser(id) return r.SendEnvelope(true) } @@ -518,6 +526,7 @@ func uploadUserAvatar(r *fastglue.Request, user models.User, files []*multipart. if err := app.user.UpdateAvatar(user.ID, "/uploads/"+media.UUID); err != nil { return sendErrorEnvelope(r, err) } + app.user.InvalidateAgentCache(user.ID) return nil } @@ -542,6 +551,7 @@ func handleGenerateAPIKey(r *fastglue.Request) error { if err != nil { return sendErrorEnvelope(r, err) } + app.user.InvalidateAgentCache(user.ID) // Return the API key and secret (only shown once) response := struct { @@ -575,6 +585,7 @@ func handleRevokeAPIKey(r *fastglue.Request) error { if err := app.user.RevokeAPIKey(id); err != nil { return sendErrorEnvelope(r, err) } + app.user.InvalidateAgentCache(id) return r.SendEnvelope(true) } diff --git a/cmd/views.go b/cmd/views.go index 874823f8..1c945694 100644 --- a/cmd/views.go +++ b/cmd/views.go @@ -16,7 +16,7 @@ func handleGetUserViews(r *fastglue.Request) error { app = r.Context.(*App) auser = r.RequestCtx.UserValue("user").(amodels.User) ) - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -37,7 +37,7 @@ func handleCreateUserView(r *fastglue.Request) error { if err := r.Decode(&view, "json"); err != nil { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), err.Error(), envelope.InputError) } - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -47,6 +47,9 @@ func handleCreateUserView(r *fastglue.Request) error { if string(view.Filters) == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`Filters`"), nil, envelope.InputError) } + if err := app.conversation.ValidateListFilters(string(view.Filters)); err != nil { + return sendErrorEnvelope(r, err) + } createdView, err := app.view.Create(view.Name, view.Filters, user.ID) if err != nil { return sendErrorEnvelope(r, err) @@ -64,7 +67,7 @@ func handleDeleteUserView(r *fastglue.Request) error { if err != nil || id <= 0 { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("globals.messages.somethingWentWrong"), nil, envelope.InputError) } - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -96,7 +99,7 @@ func handleUpdateUserView(r *fastglue.Request) error { if err := r.Decode(&view, "json"); err != nil { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.T("errors.parsingRequest"), err.Error(), envelope.InputError) } - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -106,6 +109,9 @@ func handleUpdateUserView(r *fastglue.Request) error { if string(view.Filters) == "" { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, app.i18n.Ts("globals.messages.empty", "name", "`filters`"), nil, envelope.InputError) } + if err := app.conversation.ValidateListFilters(string(view.Filters)); err != nil { + return sendErrorEnvelope(r, err) + } v, err := app.view.Get(id) if err != nil { return sendErrorEnvelope(r, err) @@ -127,7 +133,7 @@ func handleGetSharedViews(r *fastglue.Request) error { app = r.Context.(*App) auser = r.RequestCtx.UserValue("user").(amodels.User) ) - user, err := app.user.GetAgent(auser.ID, "") + user, err := app.user.GetAgentCachedOrLoad(auser.ID) if err != nil { return sendErrorEnvelope(r, err) } @@ -264,6 +270,9 @@ func validateSharedView(app *App, view vmodels.View) error { if string(view.Filters) == "" { return envelope.NewError(envelope.InputError, app.i18n.Ts("globals.messages.empty", "name", "`filters`"), nil) } + if err := app.conversation.ValidateListFilters(string(view.Filters)); err != nil { + return err + } if view.Visibility != vmodels.VisibilityAll && view.Visibility != vmodels.VisibilityTeam { return envelope.NewError(envelope.InputError, app.i18n.T("globals.messages.somethingWentWrong"), nil) } diff --git a/cmd/websocket.go b/cmd/websocket.go index d174f21f..90d44401 100644 --- a/cmd/websocket.go +++ b/cmd/websocket.go @@ -64,7 +64,7 @@ func handleWS(r *fastglue.Request, hub *ws.Hub) error { ID: auser.ID, Hub: hub, Conn: conn, - Send: make(chan wsmodels.WSMessage, 256), + Send: make(chan wsmodels.WSMessage, 128), } hub.AddClient(&c) go c.Listen() diff --git a/cmd/widget_ws.go b/cmd/widget_ws.go index 6dee1b78..340e133d 100644 --- a/cmd/widget_ws.go +++ b/cmd/widget_ws.go @@ -170,8 +170,8 @@ func handleWidgetWS(r *fastglue.Request) error { continue } if userID > 0 { - wasOffline := app.user.IsOffline(userID) - if err := app.user.UpdateLastActive(userID); err != nil { + wasOffline, err := app.user.UpdateLastActive(userID) + if err != nil { app.lo.Error("error updating user last active timestamp", "user_id", userID, "error", err) } else if wasOffline { app.conversation.BroadcastContactUpdate(userID, map[string]any{"availability_status": "online"}) diff --git a/config.sample.toml b/config.sample.toml index ad05ffcf..ced9aec0 100644 --- a/config.sample.toml +++ b/config.sample.toml @@ -42,6 +42,8 @@ provider = "fs" [upload.fs] # Directory where uploaded files are stored, make sure this directory exists and is writable by the application. upload_path = 'uploads' +# Signed URL expiry duration (e.g., "30m", "1h"). Defaults to 1h if unset. +expiry = "1h" # S3 provider. [upload.s3] @@ -132,7 +134,7 @@ allowed_hosts = [] # How often to check for conversations to unsnooze unsnooze_interval = "5m" # How long to keep drafts before deleting them from the database. (e.g. "360h", "48h") -draft_retention_period = "360h" +draft_retention_duration = "360h" # How often to check for offline conversations in database to send continuity emails continuity_scan_interval = "5m" diff --git a/frontend/apps/main/index.html b/frontend/apps/main/index.html index bcbc60c2..1aa0515e 100644 --- a/frontend/apps/main/index.html +++ b/frontend/apps/main/index.html @@ -6,7 +6,7 @@ - diff --git a/frontend/apps/main/src/App.vue b/frontend/apps/main/src/App.vue index 7761ba48..5cc1c235 100644 --- a/frontend/apps/main/src/App.vue +++ b/frontend/apps/main/src/App.vue @@ -99,7 +99,7 @@ :userTeams="userStore.teams" :userViews="userViews" :sharedViews="sharedViewStore.sharedViewList" - @create-view="openCreateViewForm = true" + @create-view="createView" @edit-view="editView" @delete-view="deleteView" @create-conversation="() => (openCreateConversationDialog = true)" @@ -191,9 +191,10 @@ watch( () => route.path, (path) => { if (path.startsWith('/inboxes') && path !== '/inboxes/search') { - lastInboxPath.value = path.replace(/\/conversation\/[^/]+$/, '') + lastInboxPath.value = path } - } + }, + { immediate: true } ) const userStore = useUserStore() const conversationStore = useConversationStore() @@ -258,6 +259,11 @@ const initStores = async () => { ]) } +const createView = () => { + view.value = {} + openCreateViewForm.value = true +} + const editView = (v) => { view.value = { ...v } openCreateViewForm.value = true @@ -292,6 +298,7 @@ const getUserViews = async () => { const initToaster = () => { emitter.on(EMITTER_EVENTS.SHOW_TOAST, (message) => { + if (!message.description) return if (message.variant === 'destructive') { sooner.error(message.description) } else if (message.variant === 'warning') { diff --git a/frontend/apps/main/src/OuterApp.vue b/frontend/apps/main/src/OuterApp.vue index f5c37d4d..60bf3a20 100644 --- a/frontend/apps/main/src/OuterApp.vue +++ b/frontend/apps/main/src/OuterApp.vue @@ -12,6 +12,7 @@ import { toast as sooner } from 'vue-sonner' const emitter = useEmitter() const toastHandler = (message) => { + if (!message.description) return if (message.variant === 'destructive') { sooner.error(message.description) } else { diff --git a/frontend/apps/main/src/api/index.js b/frontend/apps/main/src/api/index.js index 848a554e..07118aa0 100644 --- a/frontend/apps/main/src/api/index.js +++ b/frontend/apps/main/src/api/index.js @@ -18,22 +18,31 @@ function getCSRFToken () { return '' } -// Request interceptor. +// Route-scoped abort, opt-in via { abortOnRoute: true }. Default no-abort protects in-flight saves. +let routeAbort = new AbortController() +export function abortRouteScope () { + routeAbort.abort() + routeAbort = new AbortController() +} + http.interceptors.request.use((request) => { const token = getCSRFToken() if (token) { request.headers['X-CSRFTOKEN'] = token } - // Set content type for POST/PUT requests if the content type is not set. if ((request.method === 'post' || request.method === 'put') && !request.headers['Content-Type']) { request.headers['Content-Type'] = 'application/json' } - + if (request.headers['Content-Type'] === 'application/x-www-form-urlencoded') { request.data = qs.stringify(request.data) } - + + if (request.abortOnRoute && request.signal === undefined) { + request.signal = routeAbort.signal + } + return request }) @@ -323,15 +332,17 @@ const getConversationMessage = (cuuid, uuid) => const retryMessage = (cuuid, uuid) => http.put(`/api/v1/conversations/${cuuid}/messages/${uuid}/retry`) const getConversationMessages = (uuid, params) => - http.get(`/api/v1/conversations/${uuid}/messages`, { params }) + http.get(`/api/v1/conversations/${uuid}/messages`, { params, abortOnRoute: true }) const sendMessage = (uuid, data) => http.post(`/api/v1/conversations/${uuid}/messages`, data, { headers: { 'Content-Type': 'application/json' } }) -const getConversation = (uuid) => http.get(`/api/v1/conversations/${uuid}`) -const getContactPageVisits = (uuid) => http.get(`/api/v1/conversations/${uuid}/page-visits`) +const getConversation = (uuid) => http.get(`/api/v1/conversations/${uuid}`, { abortOnRoute: true }) +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 getMacro = (id) => http.get(`/api/v1/macros/${id}`) const createMacro = (data) => @@ -354,15 +365,17 @@ const applyMacro = (uuid, id, data) => } }) const getTeamUnassignedConversations = (teamID, params) => - http.get(`/api/v1/teams/${teamID}/conversations/unassigned`, { params }) -const getAssignedConversations = (params) => http.get('/api/v1/conversations/assigned', { params }) + http.get(`/api/v1/teams/${teamID}/conversations/unassigned`, { params, abortOnRoute: true }) +const getAssignedConversations = (params) => + http.get('/api/v1/conversations/assigned', { params, abortOnRoute: true }) const getUnassignedConversations = (params) => - http.get('/api/v1/conversations/unassigned', { params }) -const getAllConversations = (params) => http.get('/api/v1/conversations/all', { params }) + http.get('/api/v1/conversations/unassigned', { params, abortOnRoute: true }) +const getAllConversations = (params) => + http.get('/api/v1/conversations/all', { params, abortOnRoute: true }) const getMentionedConversations = (params) => - http.get('/api/v1/conversations/mentioned', { params }) + http.get('/api/v1/conversations/mentioned', { params, abortOnRoute: true }) const getViewConversations = (id, params) => - http.get(`/api/v1/views/${id}/conversations`, { params }) + http.get(`/api/v1/views/${id}/conversations`, { params, abortOnRoute: true }) const uploadMedia = (data) => http.post('/api/v1/media', data, { headers: { @@ -574,6 +587,7 @@ export default { getOverviewTagDistribution, getConversationMessage, getConversationMessages, + getConversationTranscript, getCurrentUser, getCurrentUserTeams, getAllMacros, diff --git a/frontend/apps/main/src/components/combobox/SelectCombobox.vue b/frontend/apps/main/src/components/combobox/SelectCombobox.vue index 6e4c9d0e..3546ff4d 100644 --- a/frontend/apps/main/src/components/combobox/SelectCombobox.vue +++ b/frontend/apps/main/src/components/combobox/SelectCombobox.vue @@ -16,10 +16,18 @@